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
+13 -3
View File
@@ -353,14 +353,24 @@ class LocalStateStore:
row = connection.execute(
"""SELECT service_url,device_id,adb_path,adb_serial,transport,
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=?""",
(profile_id,),
).fetchone()
if row is None:
raise StateError("profile_not_found")
settings = ProfileSettings(profile_id, *row[:9])
return ProfileSummary(settings, bool(row[9]))
# 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])
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:
now = self._utc_now()
@@ -51,7 +51,7 @@ class ClaimGateway(Protocol):
class ExecutionConsumer(Protocol):
def accept_claim(self, claimed: ClaimedTask) -> None: ...
def accept_claim(self, claimed: ClaimedTask, profile: ProfileSettings) -> None: ...
@dataclass(frozen=True)
@@ -469,9 +469,12 @@ class PollingCoordinator(QObject):
return
try:
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")
consumer.accept_claim(claimed)
# consumer 只能使用本次显式 Start 冻结的不可变配置;不得在
# 已领取后回读可变 UI/store,否则 ADB 身份和超时会发生趟内漂移。
consumer.accept_claim(claimed, frozen_profile)
except Exception:
self._request_stop(
PollingState.RECOVERY_REQUIRED,
+6 -4
View File
@@ -5,8 +5,8 @@ from __future__ import annotations
from pathlib import Path
from typing import Protocol
from PySide6.QtCore import QRegularExpression, Qt, Signal, Slot
from PySide6.QtGui import QAction, QKeySequence, QRegularExpressionValidator
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import (
QComboBox,
QFormLayout,
@@ -77,9 +77,11 @@ class SettingsPage(QScrollArea):
self.device_token = QLineEdit()
self.device_token.setObjectName("deviceToken")
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.setValidator(QRegularExpressionValidator(QRegularExpression("[0-9a-f]{0,64}"), self))
form.addRow("设备 token", self.device_token)
self.token_status = QLabel()
+90 -5
View File
@@ -46,6 +46,15 @@ class FakeProtector:
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:
return ProfileSettings(
PROFILE,
@@ -113,19 +122,95 @@ class LocalStateStoreTests(unittest.TestCase):
os.chdir(original_cwd)
def test_profile_summary_reads_settings_and_token_presence_without_unprotect(self) -> None:
class NoUnprotectProtector(FakeProtector):
def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes:
raise AssertionError("metadata_read_must_not_unprotect")
protector = NoUnprotectProtector()
summary_store = LocalStateStore(
self.database,
NoUnprotectProtector(),
protector,
now=lambda: self.clock[0],
)
summary = summary_store.load_profile_summary(PROFILE)
self.assertEqual(summary.settings, settings())
self.assertTrue(summary.has_stored_device_token)
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:
with self.assertRaisesRegex(StateError, "profile_not_found"):
+25 -1
View File
@@ -121,9 +121,11 @@ class FakeGateway:
class FakeConsumer:
def __init__(self) -> None:
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.profiles.append(profile)
def wait_until(predicate, timeout_ms: int = 2000) -> None:
@@ -339,6 +341,28 @@ class PollingCoordinatorTests(unittest.TestCase):
coordinator.stop()
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:
wire = claim_wire()
wire["task"]["title"] = "标题 " + SENTINEL_TOKEN
+28
View File
@@ -68,6 +68,20 @@ class SettingsPageTests(unittest.TestCase):
self.assertTrue(page.device_token.hasFocus())
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:
store = FakeStore()
page = SettingsPage(store)
@@ -99,6 +113,20 @@ class SettingsPageTests(unittest.TestCase):
self.assertNotIn(TOKEN, page.feedback.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:
store = FakeStore(RuntimeError("must-not-appear-" + TOKEN))
page = SettingsPage(store, settings=self.existing_settings(), has_stored_device_token=True)