fix(client): fail closed on polling configuration drift
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user