fix(client): fail closed on polling configuration drift

This commit is contained in:
QiuSW
2026-08-05 01:59:35 +08:00
parent dfd88c3336
commit 3a27225977
6 changed files with 168 additions and 16 deletions
+12 -2
View File
@@ -353,14 +353,24 @@ class LocalStateStore:
row = connection.execute( row = connection.execute(
"""SELECT service_url,device_id,adb_path,adb_serial,transport, """SELECT service_url,device_id,adb_path,adb_serial,transport,
poll_interval_seconds,failure_threshold,http_timeout_seconds,step_timeout_seconds, poll_interval_seconds,failure_threshold,http_timeout_seconds,step_timeout_seconds,
CASE WHEN length(device_token_cipher) > 0 THEN 1 ELSE 0 END typeof(device_token_cipher),length(device_token_cipher)
FROM profiles WHERE profile_id=?""", FROM profiles WHERE profile_id=?""",
(profile_id,), (profile_id,),
).fetchone() ).fetchone()
if row is None: if row is None:
raise StateError("profile_not_found") raise StateError("profile_not_found")
# summary 不解密,但也不能把损坏的密文降级成“尚未保存”。否则 UI 会
# 允许覆盖本应进入人工恢复的本地状态。DPAPI 密文长度不固定,只要求
# SQLite storage class 确为 BLOB 且非空。
if row[9] != "blob" or type(row[10]) is not int or row[10] <= 0:
raise StateError("invalid_device_token_cipher")
try:
settings = ProfileSettings(profile_id, *row[:9]) settings = ProfileSettings(profile_id, *row[:9])
return ProfileSummary(settings, bool(row[9])) except (TypeError, ValueError, ValidationError):
# 存储字段损坏不得把裸模型异常或实际值带到 UI;也不得以默认配置
# 继续启动。配置修复必须显式进行。
raise StateError("stored_profile_invalid") from None
return ProfileSummary(settings, True)
def start_or_resume_polling(self, profile_id: str) -> PollingSession: def start_or_resume_polling(self, profile_id: str) -> PollingSession:
now = self._utc_now() now = self._utc_now()
@@ -51,7 +51,7 @@ class ClaimGateway(Protocol):
class ExecutionConsumer(Protocol): class ExecutionConsumer(Protocol):
def accept_claim(self, claimed: ClaimedTask) -> None: ... def accept_claim(self, claimed: ClaimedTask, profile: ProfileSettings) -> None: ...
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -469,9 +469,12 @@ class PollingCoordinator(QObject):
return return
try: try:
consumer = self._consumer consumer = self._consumer
if consumer is None: frozen_profile = self._frozen_profile
if consumer is None or frozen_profile is None:
raise RuntimeError("execution_consumer_missing") raise RuntimeError("execution_consumer_missing")
consumer.accept_claim(claimed) # consumer 只能使用本次显式 Start 冻结的不可变配置;不得在
# 已领取后回读可变 UI/store,否则 ADB 身份和超时会发生趟内漂移。
consumer.accept_claim(claimed, frozen_profile)
except Exception: except Exception:
self._request_stop( self._request_stop(
PollingState.RECOVERY_REQUIRED, PollingState.RECOVERY_REQUIRED,
+6 -4
View File
@@ -5,8 +5,8 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Protocol from typing import Protocol
from PySide6.QtCore import QRegularExpression, Qt, Signal, Slot from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtGui import QAction, QKeySequence, QRegularExpressionValidator from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QComboBox,
QFormLayout, QFormLayout,
@@ -77,9 +77,11 @@ class SettingsPage(QScrollArea):
self.device_token = QLineEdit() self.device_token = QLineEdit()
self.device_token.setObjectName("deviceToken") self.device_token.setObjectName("deviceToken")
self.device_token.setEchoMode(QLineEdit.EchoMode.Password) self.device_token.setEchoMode(QLineEdit.EchoMode.Password)
self.device_token.setMaxLength(64) # 控件只负责给输入设置合理上限;长度与字符集必须在 save() 中显式
# 验证。若这里限制为 64,粘贴 65 位 token 会被 Qt 静默截成合法
# 64 位并覆盖原凭据。
self.device_token.setMaxLength(256)
self.device_token.setPlaceholderText("首次必填;已有凭据时留空表示保留") self.device_token.setPlaceholderText("首次必填;已有凭据时留空表示保留")
self.device_token.setValidator(QRegularExpressionValidator(QRegularExpression("[0-9a-f]{0,64}"), self))
form.addRow("设备 token", self.device_token) form.addRow("设备 token", self.device_token)
self.token_status = QLabel() self.token_status = QLabel()
+90 -5
View File
@@ -46,6 +46,15 @@ class FakeProtector:
return plaintext return plaintext
class NoUnprotectProtector(FakeProtector):
def __init__(self) -> None:
self.unprotect_calls = 0
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
self.unprotect_calls += 1
raise AssertionError("metadata_read_must_not_unprotect")
def settings(device_id: str = DEVICE_ID) -> ProfileSettings: def settings(device_id: str = DEVICE_ID) -> ProfileSettings:
return ProfileSettings( return ProfileSettings(
PROFILE, PROFILE,
@@ -113,19 +122,95 @@ class LocalStateStoreTests(unittest.TestCase):
os.chdir(original_cwd) os.chdir(original_cwd)
def test_profile_summary_reads_settings_and_token_presence_without_unprotect(self) -> None: def test_profile_summary_reads_settings_and_token_presence_without_unprotect(self) -> None:
class NoUnprotectProtector(FakeProtector): protector = NoUnprotectProtector()
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
raise AssertionError("metadata_read_must_not_unprotect")
summary_store = LocalStateStore( summary_store = LocalStateStore(
self.database, self.database,
NoUnprotectProtector(), protector,
now=lambda: self.clock[0], now=lambda: self.clock[0],
) )
summary = summary_store.load_profile_summary(PROFILE) summary = summary_store.load_profile_summary(PROFILE)
self.assertEqual(summary.settings, settings()) self.assertEqual(summary.settings, settings())
self.assertTrue(summary.has_stored_device_token) self.assertTrue(summary.has_stored_device_token)
self.assertNotIn(DEVICE_TOKEN, repr(summary)) self.assertNotIn(DEVICE_TOKEN, repr(summary))
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_empty_cipher_fails_closed_without_unprotect(self) -> None:
connection = sqlite3.connect(self.database)
try:
connection.execute(
"UPDATE profiles SET device_token_cipher=? WHERE profile_id=?",
(sqlite3.Binary(b""), PROFILE),
)
connection.commit()
finally:
connection.close()
protector = NoUnprotectProtector()
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
with self.assertRaisesRegex(StateError, "invalid_device_token_cipher"):
summary_store.load_profile_summary(PROFILE)
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_wrong_cipher_storage_class_fails_closed_without_unprotect(self) -> None:
current = settings()
row = (
current.service_url,
current.device_id,
current.adb_path,
current.adb_serial,
current.transport,
current.poll_interval_seconds,
current.failure_threshold,
current.http_timeout_seconds,
current.step_timeout_seconds,
"text",
64,
)
protector = NoUnprotectProtector()
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
fake_connection = mock.Mock()
fake_connection.execute.return_value.fetchone.return_value = row
with mock.patch.object(summary_store, "_read_transaction") as read_transaction:
read_transaction.return_value.__enter__.return_value = fake_connection
with self.assertRaisesRegex(StateError, "invalid_device_token_cipher"):
summary_store.load_profile_summary(PROFILE)
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_invalid_stored_settings_are_normalized_without_unprotect(self) -> None:
original = settings()
protector = NoUnprotectProtector()
summary_store = LocalStateStore(self.database, protector, now=lambda: self.clock[0])
for column, invalid, valid in (
("service_url", "http://127.0.0.1:9999", original.service_url),
("transport", "bluetooth", original.transport),
("poll_interval_seconds", 4, original.poll_interval_seconds),
):
with self.subTest(column=column):
connection = sqlite3.connect(self.database)
try:
connection.execute("PRAGMA ignore_check_constraints=ON")
connection.execute(
f"UPDATE profiles SET {column}=? WHERE profile_id=?",
(invalid, PROFILE),
)
connection.commit()
finally:
connection.close()
try:
with self.assertRaisesRegex(StateError, "stored_profile_invalid") as captured:
summary_store.load_profile_summary(PROFILE)
self.assertNotIn(DEVICE_TOKEN, str(captured.exception))
finally:
connection = sqlite3.connect(self.database)
try:
connection.execute("PRAGMA ignore_check_constraints=ON")
connection.execute(
f"UPDATE profiles SET {column}=? WHERE profile_id=?",
(valid, PROFILE),
)
connection.commit()
finally:
connection.close()
self.assertEqual(protector.unprotect_calls, 0)
def test_profile_summary_missing_profile_fails_without_creating_defaults(self) -> None: def test_profile_summary_missing_profile_fails_without_creating_defaults(self) -> None:
with self.assertRaisesRegex(StateError, "profile_not_found"): with self.assertRaisesRegex(StateError, "profile_not_found"):
+25 -1
View File
@@ -121,9 +121,11 @@ class FakeGateway:
class FakeConsumer: class FakeConsumer:
def __init__(self) -> None: def __init__(self) -> None:
self.claims: list[ClaimedTask] = [] self.claims: list[ClaimedTask] = []
self.profiles: list[ProfileSettings] = []
def accept_claim(self, claimed: ClaimedTask) -> None: def accept_claim(self, claimed: ClaimedTask, profile: ProfileSettings) -> None:
self.claims.append(claimed) self.claims.append(claimed)
self.profiles.append(profile)
def wait_until(predicate, timeout_ms: int = 2000) -> None: def wait_until(predicate, timeout_ms: int = 2000) -> None:
@@ -339,6 +341,28 @@ class PollingCoordinatorTests(unittest.TestCase):
coordinator.stop() coordinator.stop()
wait_until(lambda: not coordinator.operation_in_flight) wait_until(lambda: not coordinator.operation_in_flight)
def test_consumer_receives_start_snapshot_even_if_profile_changes_while_claiming(self) -> None:
gate = threading.Event()
store = FakeStore(snapshot())
gateway = FakeGateway([claimed_task()], gate)
consumer = FakeConsumer()
original = profile(http_timeout=10)
coordinator, frozen = self.make_coordinator(
store,
gateway,
consumer,
settings=original,
)
coordinator.start()
wait_until(lambda: coordinator.state == PollingState.CLAIMING and gateway.calls == 1)
changed = profile(http_timeout=27)
coordinator.update_profile_settings(changed)
gate.set()
wait_until(lambda: coordinator.state == PollingState.ACTIVE)
self.assertEqual(frozen, [original])
self.assertEqual(consumer.profiles, [original])
self.assertIsNot(consumer.profiles[0], changed)
def test_claim_view_redacts_sentinel_even_if_title_contains_it(self) -> None: def test_claim_view_redacts_sentinel_even_if_title_contains_it(self) -> None:
wire = claim_wire() wire = claim_wire()
wire["task"]["title"] = "标题 " + SENTINEL_TOKEN wire["task"]["title"] = "标题 " + SENTINEL_TOKEN
+28
View File
@@ -68,6 +68,20 @@ class SettingsPageTests(unittest.TestCase):
self.assertTrue(page.device_token.hasFocus()) self.assertTrue(page.device_token.hasFocus())
self.assertIn("首次保存", page.feedback.text()) self.assertIn("首次保存", page.feedback.text())
def test_first_save_rejects_65_character_token_without_silent_truncation(self) -> None:
store = FakeStore()
page = SettingsPage(store)
page.show()
QTest.qWait(10)
self.fill(page)
invalid_token = TOKEN + "b"
page.device_token.setText(invalid_token)
page.save()
self.assertEqual(store.calls, [])
self.assertEqual(page.device_token.text(), invalid_token)
self.assertTrue(page.device_token.hasFocus())
self.assertIn("64 位", page.feedback.text())
def test_visual_order_focuses_invalid_device_before_missing_token_or_adb(self) -> None: def test_visual_order_focuses_invalid_device_before_missing_token_or_adb(self) -> None:
store = FakeStore() store = FakeStore()
page = SettingsPage(store) page = SettingsPage(store)
@@ -99,6 +113,20 @@ class SettingsPageTests(unittest.TestCase):
self.assertNotIn(TOKEN, page.feedback.text()) self.assertNotIn(TOKEN, page.feedback.text())
self.assertNotIn(TOKEN, page.token_status.text()) self.assertNotIn(TOKEN, page.token_status.text())
def test_existing_token_replacement_rejects_65_characters_without_store_write(self) -> None:
store = FakeStore()
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)
page.show()
QTest.qWait(10)
invalid_token = TOKEN + "b"
page.device_token.setText(invalid_token)
page.save()
# 零写入即表示已保存的原 token 未被替换。
self.assertEqual(store.calls, [])
self.assertEqual(page.device_token.text(), invalid_token)
self.assertTrue(page.device_token.hasFocus())
self.assertEqual(page.token_status.text(), "已保存")
def test_failed_save_preserves_token_and_focuses_secret_field_without_echo(self) -> None: def test_failed_save_preserves_token_and_focuses_secret_field_without_echo(self) -> None:
store = FakeStore(RuntimeError("must-not-appear-" + TOKEN)) store = FakeStore(RuntimeError("must-not-appear-" + TOKEN))
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True) page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)