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