diff --git a/client/src/cmbuyer_client/localstate/store.py b/client/src/cmbuyer_client/localstate/store.py index a91bc68..d840c2f 100644 --- a/client/src/cmbuyer_client/localstate/store.py +++ b/client/src/cmbuyer_client/localstate/store.py @@ -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() diff --git a/client/src/cmbuyer_client/polling/coordinator.py b/client/src/cmbuyer_client/polling/coordinator.py index 875b5f9..40aa74b 100644 --- a/client/src/cmbuyer_client/polling/coordinator.py +++ b/client/src/cmbuyer_client/polling/coordinator.py @@ -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, diff --git a/client/src/cmbuyer_client/ui/settings.py b/client/src/cmbuyer_client/ui/settings.py index dfcb121..68b52c5 100644 --- a/client/src/cmbuyer_client/ui/settings.py +++ b/client/src/cmbuyer_client/ui/settings.py @@ -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() diff --git a/client/tests/localstate/test_store.py b/client/tests/localstate/test_store.py index 7b8c595..44da544 100644 --- a/client/tests/localstate/test_store.py +++ b/client/tests/localstate/test_store.py @@ -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"): diff --git a/client/tests/polling/test_coordinator.py b/client/tests/polling/test_coordinator.py index 74d0527..1ea876d 100644 --- a/client/tests/polling/test_coordinator.py +++ b/client/tests/polling/test_coordinator.py @@ -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 diff --git a/client/tests/ui/test_settings.py b/client/tests/ui/test_settings.py index 29bb7f7..4df56fe 100644 --- a/client/tests/ui/test_settings.py +++ b/client/tests/ui/test_settings.py @@ -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)