diff --git a/client/src/cmbuyer_client/core/__init__.py b/client/src/cmbuyer_client/core/__init__.py new file mode 100644 index 0000000..74f39c6 --- /dev/null +++ b/client/src/cmbuyer_client/core/__init__.py @@ -0,0 +1,44 @@ +"""与 UI、HTTP 和拼多多页面实现无关的客户端核心契约。""" + +from .errors import ( + AmbiguousRemoteError, + CredentialRemoteError, + ManualRemoteError, + ProtocolRemoteError, + StateError, + ValidationError, +) +from .models import ( + AssetReceipt, + AuthorizationSnapshot, + ClaimRequest, + ClaimedTask, + DeviceCredentials, + EvidenceUpload, + PurchaseTask, + RenewRequest, + RenewResult, + SecretToken, +) +from .ports import EvidenceSink, TaskSource + +__all__ = [ + "AmbiguousRemoteError", + "AssetReceipt", + "AuthorizationSnapshot", + "ClaimRequest", + "ClaimedTask", + "CredentialRemoteError", + "DeviceCredentials", + "EvidenceSink", + "EvidenceUpload", + "ManualRemoteError", + "ProtocolRemoteError", + "PurchaseTask", + "RenewRequest", + "RenewResult", + "SecretToken", + "StateError", + "TaskSource", + "ValidationError", +] diff --git a/client/src/cmbuyer_client/core/errors.py b/client/src/cmbuyer_client/core/errors.py new file mode 100644 index 0000000..3c0b294 --- /dev/null +++ b/client/src/cmbuyer_client/core/errors.py @@ -0,0 +1,47 @@ +"""可安全呈现的客户端错误分类。""" + +from __future__ import annotations + + +class ClientError(RuntimeError): + """错误文本只使用固定 reason code,不携带凭据、响应或本机路径。""" + + def __init__(self, reason: str) -> None: + self.reason = reason + super().__init__(reason) + + +class ValidationError(ClientError): + """本地输入或 wire schema 不满足固定契约。""" + + +class StateError(ClientError): + """本地状态无法安全推进;调用方必须停止而不是绕过。""" + + +class ProtectionError(ClientError): + """秘密保护失败。""" + + +class SingleInstanceError(ClientError): + """同一配置已经由另一个采购工具进程持有。""" + + +class RemoteError(ClientError): + """服务端调用的稳定错误分类。""" + + +class AmbiguousRemoteError(RemoteError): + """请求结果不明;只允许以原幂等键、原载荷显式恢复。""" + + +class CredentialRemoteError(RemoteError): + """设备凭据无效或已撤销。""" + + +class ProtocolRemoteError(RemoteError): + """请求/响应与固定协议不兼容,不得自动重试。""" + + +class ManualRemoteError(RemoteError): + """服务端要求人工处理的确定性冲突。""" diff --git a/client/src/cmbuyer_client/core/models.py b/client/src/cmbuyer_client/core/models.py new file mode 100644 index 0000000..e537e8f --- /dev/null +++ b/client/src/cmbuyer_client/core/models.py @@ -0,0 +1,327 @@ +"""任务领取、续租和单张证据上传的不可变值对象。""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import hashlib +from pathlib import Path + +from .errors import ValidationError +from .validation import ( + canonical_product_url, + require_exact_fields, + require_goods_id, + require_lower_hex_64, + require_money, + require_positive_int, + require_rfc3339_z, + rfc3339_z_nanoseconds, + require_string, + require_uuid4, +) + + +EVIDENCE_KIND = "SKU_PANEL_GATE_1" +PRIVACY_TIER = "INTERNAL_RAW" + + +@dataclass(frozen=True, repr=False) +class SecretToken: + """64 位小写 token;repr 永不暴露明文。""" + + value: str + + def __post_init__(self) -> None: + require_lower_hex_64(self.value, "invalid_token") + + def __repr__(self) -> str: + return "SecretToken([已隐藏])" + + def __str__(self) -> str: + return "[已隐藏]" + + +@dataclass(frozen=True, repr=False) +class DeviceCredentials: + device_id: str + token: SecretToken + + def __post_init__(self) -> None: + require_uuid4(self.device_id, "invalid_device_id") + if not isinstance(self.token, SecretToken): + raise ValidationError("invalid_device_token") + + def __repr__(self) -> str: + return f"DeviceCredentials(device_id={self.device_id!r}, token=[已隐藏])" + + +@dataclass(frozen=True) +class ClaimRequest: + session_id: str + claim_request_id: str + + def __post_init__(self) -> None: + require_uuid4(self.session_id, "invalid_session_id") + require_uuid4(self.claim_request_id, "invalid_claim_request_id") + + def to_wire(self) -> dict[str, object]: + return {"session_id": self.session_id, "claim_request_id": self.claim_request_id} + + +@dataclass(frozen=True) +class PurchaseTask: + id: str + version: int + title: str + product_url: str + goods_id: str + sku_color: str + sku_size: str + quantity: int + max_total_price: str + + def __post_init__(self) -> None: + require_uuid4(self.id, "invalid_task_id") + require_positive_int(self.version, "invalid_task_version") + require_string(self.title, "invalid_task_title", maximum=32 * 1024) + if not self.title.strip(): + raise ValidationError("invalid_task_title") + require_goods_id(self.goods_id) + if self.product_url != canonical_product_url(self.goods_id): + raise ValidationError("invalid_product_url") + require_string(self.sku_color, "invalid_sku_color", maximum=32 * 1024) + require_string(self.sku_size, "invalid_sku_size", maximum=32 * 1024) + require_positive_int(self.quantity, "invalid_quantity") + require_money(self.max_total_price, "invalid_max_total_price") + + @classmethod + def from_wire(cls, value: object) -> "PurchaseTask": + data = require_exact_fields( + value, + ("id", "version", "title", "product_url", "goods_id", "sku_color", "sku_size", "quantity", "max_total_price"), + ) + return cls(**data) # type: ignore[arg-type] + + +@dataclass(frozen=True) +class AuthorizationSnapshot: + id: str + task_version: int + expires_at: str + + def __post_init__(self) -> None: + require_uuid4(self.id, "invalid_authorization_id") + require_positive_int(self.task_version, "invalid_authorization_task_version") + require_rfc3339_z(self.expires_at, "invalid_authorization_expiry") + + @classmethod + def from_wire(cls, value: object) -> "AuthorizationSnapshot": + data = require_exact_fields(value, ("id", "task_version", "expires_at")) + return cls(**data) # type: ignore[arg-type] + + +@dataclass(frozen=True) +class AttemptSnapshot: + id: str + claim_token: SecretToken + claim_generation: int + lease_expires_at: str + + def __post_init__(self) -> None: + require_uuid4(self.id, "invalid_attempt_id") + if not isinstance(self.claim_token, SecretToken): + object.__setattr__(self, "claim_token", SecretToken(self.claim_token)) + require_positive_int(self.claim_generation, "invalid_claim_generation") + require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry") + + @classmethod + def from_wire(cls, value: object) -> "AttemptSnapshot": + data = require_exact_fields(value, ("id", "claim_token", "claim_generation", "lease_expires_at")) + return cls( + id=data["id"], # type: ignore[arg-type] + claim_token=SecretToken(data["claim_token"]), # type: ignore[arg-type] + claim_generation=data["claim_generation"], # type: ignore[arg-type] + lease_expires_at=data["lease_expires_at"], # type: ignore[arg-type] + ) + + +@dataclass(frozen=True) +class ClaimedTask: + task: PurchaseTask + authorization: AuthorizationSnapshot + attempt: AttemptSnapshot = field(repr=False) + + def __post_init__(self) -> None: + if self.task.version != self.authorization.task_version + 1: + raise ValidationError("task_authorization_version_mismatch") + if rfc3339_z_nanoseconds(self.attempt.lease_expires_at) > rfc3339_z_nanoseconds(self.authorization.expires_at): + raise ValidationError("claim_lease_exceeds_authorization") + + @classmethod + def from_wire(cls, value: object) -> "ClaimedTask": + data = require_exact_fields(value, ("task", "authorization", "attempt")) + return cls( + task=PurchaseTask.from_wire(data["task"]), + authorization=AuthorizationSnapshot.from_wire(data["authorization"]), + attempt=AttemptSnapshot.from_wire(data["attempt"]), + ) + + +@dataclass(frozen=True, repr=False) +class RenewRequest: + task_id: str + renew_request_id: str + session_id: str + attempt_id: str + claim_generation: int + claim_token: SecretToken + expected_lease_expires_at: str + authorization_expires_at: str + + def __post_init__(self) -> None: + require_uuid4(self.task_id, "invalid_task_id") + require_uuid4(self.renew_request_id, "invalid_renew_request_id") + require_uuid4(self.session_id, "invalid_session_id") + require_uuid4(self.attempt_id, "invalid_attempt_id") + require_positive_int(self.claim_generation, "invalid_claim_generation") + if not isinstance(self.claim_token, SecretToken): + object.__setattr__(self, "claim_token", SecretToken(self.claim_token)) + require_rfc3339_z(self.expected_lease_expires_at, "invalid_expected_lease_expiry") + require_rfc3339_z(self.authorization_expires_at, "invalid_authorization_expiry") + + def __repr__(self) -> str: + return ( + f"RenewRequest(task_id={self.task_id!r}, renew_request_id={self.renew_request_id!r}, " + "claim_token=[已隐藏])" + ) + + def to_wire(self) -> dict[str, object]: + return { + "renew_request_id": self.renew_request_id, + "session_id": self.session_id, + "attempt_id": self.attempt_id, + "claim_generation": self.claim_generation, + "claim_token": self.claim_token.value, + "expected_lease_expires_at": self.expected_lease_expires_at, + } + + +@dataclass(frozen=True) +class RenewResult: + task_id: str + attempt_id: str + claim_generation: int + lease_expires_at: str + + def __post_init__(self) -> None: + require_uuid4(self.task_id, "invalid_task_id") + require_uuid4(self.attempt_id, "invalid_attempt_id") + require_positive_int(self.claim_generation, "invalid_claim_generation") + require_rfc3339_z(self.lease_expires_at, "invalid_lease_expiry") + + @classmethod + def from_wire(cls, value: object) -> "RenewResult": + data = require_exact_fields(value, ("task_id", "attempt_id", "claim_generation", "lease_expires_at")) + return cls(**data) # type: ignore[arg-type] + + +@dataclass(frozen=True, repr=False) +class EvidenceUpload: + task_id: str + upload_key: str + attempt_id: str + sha256: str + captured_at: str + content: bytes = field(repr=False) + kind: str = EVIDENCE_KIND + privacy_tier: str = PRIVACY_TIER + width_px: int = field(init=False) + height_px: int = field(init=False) + + def __post_init__(self) -> None: + require_uuid4(self.task_id, "invalid_task_id") + require_uuid4(self.upload_key, "invalid_upload_key") + require_uuid4(self.attempt_id, "invalid_attempt_id") + require_lower_hex_64(self.sha256, "invalid_evidence_sha256") + require_rfc3339_z(self.captured_at, "invalid_captured_at") + if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER: + raise ValidationError("invalid_evidence_metadata") + if not isinstance(self.content, bytes) or not self.content or len(self.content) > 10 * 1024 * 1024: + raise ValidationError("invalid_evidence_size") + if len(self.content) < 24 or not self.content.startswith(b"\x89PNG\r\n\x1a\n") or self.content[12:16] != b"IHDR": + raise ValidationError("invalid_evidence_png") + width = int.from_bytes(self.content[16:20], "big") + height = int.from_bytes(self.content[20:24], "big") + if width <= 0 or height <= 0 or width > 8192 or height > 8192 or width * height > 16_777_216: + raise ValidationError("invalid_evidence_dimensions") + object.__setattr__(self, "width_px", width) + object.__setattr__(self, "height_px", height) + if hashlib.sha256(self.content).hexdigest() != self.sha256: + raise ValidationError("evidence_hash_mismatch") + + def __repr__(self) -> str: + return ( + f"EvidenceUpload(task_id={self.task_id!r}, upload_key={self.upload_key!r}, " + f"attempt_id={self.attempt_id!r}, byte_size={len(self.content)})" + ) + + +@dataclass(frozen=True) +class AssetReceipt: + asset_id: str + task_id: str + attempt_id: str + kind: str + privacy_tier: str + sha256: str + byte_size: int + content_type: str + width_px: int + height_px: int + captured_at: str + + def __post_init__(self) -> None: + require_uuid4(self.asset_id, "invalid_asset_id") + require_uuid4(self.task_id, "invalid_task_id") + require_uuid4(self.attempt_id, "invalid_attempt_id") + if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER: + raise ValidationError("invalid_asset_metadata") + require_lower_hex_64(self.sha256, "invalid_asset_sha256") + require_positive_int(self.byte_size, "invalid_asset_byte_size") + if self.byte_size > 10 * 1024 * 1024 or self.content_type != "image/png": + raise ValidationError("invalid_asset_content") + width = require_positive_int(self.width_px, "invalid_asset_width") + height = require_positive_int(self.height_px, "invalid_asset_height") + if width > 8192 or height > 8192 or width * height > 16_777_216: + raise ValidationError("invalid_asset_dimensions") + require_rfc3339_z(self.captured_at, "invalid_captured_at") + + @classmethod + def from_wire(cls, value: object) -> "AssetReceipt": + data = require_exact_fields( + value, + ("asset_id", "task_id", "attempt_id", "kind", "privacy_tier", "sha256", "byte_size", "content_type", "width_px", "height_px", "captured_at"), + ) + return cls(**data) # type: ignore[arg-type] + + +@dataclass(frozen=True, repr=False) +class ScreenshotAsset: + """调用方显式选择的唯一 PNG;路径不会进入 repr 或 HTTP。""" + + path: Path = field(repr=False) + task_id: str + attempt_id: str + captured_at: str + kind: str = EVIDENCE_KIND + privacy_tier: str = PRIVACY_TIER + + def __post_init__(self) -> None: + require_uuid4(self.task_id, "invalid_task_id") + require_uuid4(self.attempt_id, "invalid_attempt_id") + require_rfc3339_z(self.captured_at, "invalid_captured_at") + if self.kind != EVIDENCE_KIND or self.privacy_tier != PRIVACY_TIER: + raise ValidationError("invalid_evidence_metadata") + + def __repr__(self) -> str: + return f"ScreenshotAsset(task_id={self.task_id!r}, attempt_id={self.attempt_id!r}, path=[已隐藏])" diff --git a/client/src/cmbuyer_client/core/ports.py b/client/src/cmbuyer_client/core/ports.py new file mode 100644 index 0000000..e6801da --- /dev/null +++ b/client/src/cmbuyer_client/core/ports.py @@ -0,0 +1,17 @@ +"""由 HTTP 适配器实现的窄端口。""" + +from __future__ import annotations + +from typing import Protocol + +from .models import AssetReceipt, ClaimRequest, ClaimedTask, DeviceCredentials, EvidenceUpload, RenewRequest, RenewResult + + +class TaskSource(Protocol): + def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ... + + def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ... + + +class EvidenceSink(Protocol): + def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ... diff --git a/client/src/cmbuyer_client/core/validation.py b/client/src/cmbuyer_client/core/validation.py new file mode 100644 index 0000000..c4655c8 --- /dev/null +++ b/client/src/cmbuyer_client/core/validation.py @@ -0,0 +1,175 @@ +"""客户端与服务端共享 wire 的严格值校验。""" + +from __future__ import annotations + +import calendar +from datetime import datetime, timezone +import json +import re +from typing import Any, Iterable, Mapping +from urllib.parse import quote + +from .errors import ValidationError + + +UUID4_RE = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" +) +LOWER_HEX_64_RE = re.compile(r"[0-9a-f]{64}") +RFC3339_Z_RE = re.compile( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z" +) +MONEY_RE = re.compile(r"(?:0|[1-9][0-9]*)\.[0-9]{2}") +GOODS_ID_RE = re.compile(r"[0-9]+") + + +def require_string(value: object, reason: str, *, maximum: int = 4096) -> str: + if not isinstance(value, str) or not value or len(value) > maximum: + raise ValidationError(reason) + if any(0xD800 <= ord(character) <= 0xDFFF for character in value): + raise ValidationError(reason) + return value + + +def require_uuid4(value: object, reason: str = "invalid_uuid") -> str: + text = require_string(value, reason, maximum=36) + if UUID4_RE.fullmatch(text) is None: + raise ValidationError(reason) + return text + + +def require_lower_hex_64(value: object, reason: str = "invalid_hex") -> str: + text = require_string(value, reason, maximum=64) + if LOWER_HEX_64_RE.fullmatch(text) is None: + raise ValidationError(reason) + return text + + +def require_rfc3339_z(value: object, reason: str = "invalid_timestamp") -> str: + text = require_string(value, reason, maximum=40) + if RFC3339_Z_RE.fullmatch(text) is None: + raise ValidationError(reason) + parsed: datetime | None = None + try: + parsed = datetime.fromisoformat(text[:-1] + "+00:00") + except ValueError: + pass + if parsed is None: + raise ValidationError(reason) + if parsed.utcoffset() is None or parsed.utcoffset().total_seconds() != 0: + raise ValidationError(reason) + return text + + +def rfc3339_z_nanoseconds(value: object, reason: str = "invalid_timestamp") -> int: + """无浮点、无微秒截断地把 UTC RFC3339Nano 转成纳秒时间轴。""" + + text = require_rfc3339_z(value, reason) + base: datetime | None = None + try: + base = datetime.strptime(text[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc) + except ValueError: + pass + if base is None: + raise ValidationError(reason) + fraction = "" if len(text) == 20 else text[20:-1] + nanoseconds = int(fraction.ljust(9, "0")) if fraction else 0 + return calendar.timegm(base.utctimetuple()) * 1_000_000_000 + nanoseconds + + +def datetime_nanoseconds(value: datetime, reason: str = "invalid_timestamp") -> int: + if not isinstance(value, datetime) or value.utcoffset() is None: + raise ValidationError(reason) + utc = value.astimezone(timezone.utc) + return calendar.timegm(utc.utctimetuple()) * 1_000_000_000 + utc.microsecond * 1_000 + + +def require_positive_int(value: object, reason: str = "invalid_integer") -> int: + # bool 是 int 的子类;wire 中必须显式拒绝 true/false。 + if type(value) is not int or value <= 0 or value > 9_223_372_036_854_775_807: + raise ValidationError(reason) + return value + + +def require_money(value: object, reason: str = "invalid_money") -> str: + text = require_string(value, reason, maximum=32 * 1024) + if MONEY_RE.fullmatch(text) is None or text == "0.00": + raise ValidationError(reason) + return text + + +def require_goods_id(value: object) -> str: + text = require_string(value, "invalid_goods_id", maximum=32 * 1024) + if GOODS_ID_RE.fullmatch(text) is None: + raise ValidationError("invalid_goods_id") + return text + + +def canonical_product_url(goods_id: str) -> str: + require_goods_id(goods_id) + return "https://mobile.yangkeduo.com/goods.html?goods_id=" + quote(goods_id, safe="") + + +def require_exact_fields( + value: object, + required: Iterable[str], + reason: str = "invalid_schema", +) -> Mapping[str, Any]: + if not isinstance(value, dict): + raise ValidationError(reason) + expected = frozenset(required) + if frozenset(value) != expected: + raise ValidationError(reason) + return value + + +def strict_json_loads(raw: bytes, *, maximum: int) -> object: + if not isinstance(raw, bytes) or len(raw) == 0 or len(raw) > maximum: + raise ValidationError("invalid_json_size") + text: str | None = None + try: + text = raw.decode("utf-8") + except UnicodeDecodeError: + pass + if text is None: + raise ValidationError("invalid_json_utf8") + if text.startswith("\ufeff"): + raise ValidationError("invalid_json_bom") + + def pairs_hook(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValidationError("duplicate_json_key") + result[key] = value + return result + + def reject_number(_: str) -> object: + raise ValidationError("invalid_json_number") + + def parse_integer(value: str) -> int: + digits = value[1:] if value.startswith("-") else value + if len(digits) > 19: + raise ValidationError("invalid_json_integer") + parsed = int(value) + if parsed < -9_223_372_036_854_775_808 or parsed > 9_223_372_036_854_775_807: + raise ValidationError("invalid_json_integer") + return parsed + + parsed_json: object | None = None + failed = False + try: + parsed_json = json.loads( + text, + object_pairs_hook=pairs_hook, + parse_int=parse_integer, + parse_float=reject_number, + parse_constant=reject_number, + ) + except ValidationError: + raise + except (json.JSONDecodeError, UnicodeError, ValueError, RecursionError): + failed = True + if failed: + raise ValidationError("invalid_json") + return parsed_json diff --git a/client/src/cmbuyer_client/localstate/__init__.py b/client/src/cmbuyer_client/localstate/__init__.py new file mode 100644 index 0000000..e41bd46 --- /dev/null +++ b/client/src/cmbuyer_client/localstate/__init__.py @@ -0,0 +1,18 @@ +"""Windows 本地恢复、凭据保护和单实例底座。""" + +from .models import PollingSession, ProfileSettings, RecoverySnapshot +from .facade import DurableClientGateway +from .protection import DpapiProtector, SecretProtector +from .single_instance import NamedMutex +from .store import LocalStateStore + +__all__ = [ + "DpapiProtector", + "DurableClientGateway", + "LocalStateStore", + "NamedMutex", + "PollingSession", + "ProfileSettings", + "RecoverySnapshot", + "SecretProtector", +] diff --git a/client/src/cmbuyer_client/localstate/facade.py b/client/src/cmbuyer_client/localstate/facade.py new file mode 100644 index 0000000..824c2cf --- /dev/null +++ b/client/src/cmbuyer_client/localstate/facade.py @@ -0,0 +1,82 @@ +"""把“先持久化,再发一次 HTTP”固化成 T-304/T-306 的唯一集成入口。""" + +from __future__ import annotations + +from cmbuyer_client.core.errors import ( + AmbiguousRemoteError, + CredentialRemoteError, + ManualRemoteError, + ProtocolRemoteError, +) +from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, ScreenshotAsset +from cmbuyer_client.core.ports import EvidenceSink, TaskSource + +from .store import LocalStateStore + + +class DurableClientGateway: + """不隐藏重试;每次方法调用最多发一次请求,结果不明保留原槽。""" + + def __init__(self, store: LocalStateStore, task_source: TaskSource, evidence_sink: EvidenceSink) -> None: + self._store = store + self._task_source = task_source + self._evidence_sink = evidence_sink + + def claim_next(self, profile_id: str) -> ClaimedTask | None: + request = self._store.prepare_claim(profile_id) + credentials = self._store.load_profile(profile_id).credentials + try: + claimed = self._task_source.claim_next(credentials, request) + except AmbiguousRemoteError: + raise + except CredentialRemoteError: + raise + except ProtocolRemoteError: + self._store.mark_claim_terminal(profile_id, request, "PROTOCOL") + raise + except ManualRemoteError: + self._store.mark_claim_terminal(profile_id, request, "MANUAL") + raise + if claimed is None: + self._store.commit_claim_empty(profile_id, request) + return None + self._store.commit_claim_success(profile_id, request, claimed) + return claimed + + def renew(self, profile_id: str): + request = self._store.prepare_renew(profile_id) + credentials = self._store.load_profile(profile_id).credentials + try: + result = self._task_source.renew(credentials, request) + except AmbiguousRemoteError: + raise + except CredentialRemoteError: + raise + except ProtocolRemoteError: + self._store.mark_renew_terminal(profile_id, request, "PROTOCOL") + raise + except ManualRemoteError: + self._store.mark_renew_terminal(profile_id, request, "MANUAL") + raise + self._store.commit_renew_success(profile_id, request, result) + return result + + def upload_evidence(self, profile_id: str, asset: ScreenshotAsset) -> AssetReceipt: + prepared = self._store.prepare_or_resume_evidence(profile_id, asset) + if isinstance(prepared, AssetReceipt): + return prepared + credentials = self._store.load_profile(profile_id).credentials + try: + receipt = self._evidence_sink.upload(credentials, prepared) + except AmbiguousRemoteError: + raise + except CredentialRemoteError: + raise + except ProtocolRemoteError: + self._store.mark_evidence_terminal(profile_id, prepared, "PROTOCOL") + raise + except ManualRemoteError: + self._store.mark_evidence_terminal(profile_id, prepared, "MANUAL") + raise + self._store.commit_evidence_success(profile_id, prepared, receipt) + return receipt diff --git a/client/src/cmbuyer_client/localstate/models.py b/client/src/cmbuyer_client/localstate/models.py new file mode 100644 index 0000000..5720b6a --- /dev/null +++ b/client/src/cmbuyer_client/localstate/models.py @@ -0,0 +1,86 @@ +"""供 T-304 使用的稳定本地配置与恢复快照。""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import re + +from cmbuyer_client.core.models import ClaimRequest, ClaimedTask, DeviceCredentials, RenewRequest +from cmbuyer_client.core.validation import require_string, require_uuid4 + + +LOOPBACK_SERVICE_URL = "http://127.0.0.1:8080" +PROFILE_ID_RE = re.compile(r"[a-z0-9][a-z0-9_-]{0,63}") + + +@dataclass(frozen=True) +class ProfileSettings: + profile_id: str + service_url: str + device_id: str + adb_path: str + adb_serial: str + transport: str + poll_interval_seconds: int = 15 + failure_threshold: int = 3 + http_timeout_seconds: int = 10 + step_timeout_seconds: int = 45 + + def __post_init__(self) -> None: + if not isinstance(self.profile_id, str) or PROFILE_ID_RE.fullmatch(self.profile_id) is None: + raise ValueError("invalid_profile_id") + if self.service_url != LOOPBACK_SERVICE_URL: + raise ValueError("service_url_not_allowed") + require_uuid4(self.device_id, "invalid_device_id") + require_string(self.adb_path, "invalid_adb_path", maximum=1024) + require_string(self.adb_serial, "invalid_adb_serial", maximum=200) + if self.transport not in ("usb", "wifi"): + raise ValueError("invalid_transport") + _range(self.poll_interval_seconds, 5, 300, "invalid_poll_interval") + _range(self.failure_threshold, 1, 10, "invalid_failure_threshold") + _range(self.http_timeout_seconds, 1, 120, "invalid_http_timeout") + _range(self.step_timeout_seconds, 5, 300, "invalid_step_timeout") + + +@dataclass(frozen=True, repr=False) +class LoadedProfile: + settings: ProfileSettings + credentials: DeviceCredentials = field(repr=False) + + def __repr__(self) -> str: + return f"LoadedProfile(settings={self.settings!r}, credentials=[已隐藏])" + + +@dataclass(frozen=True) +class PollingSession: + profile_id: str + session_id: str + accept_new: bool + + def __post_init__(self) -> None: + require_uuid4(self.session_id, "invalid_session_id") + if type(self.accept_new) is not bool: + raise ValueError("invalid_accept_new") + + +@dataclass(frozen=True) +class PendingEvidence: + task_id: str + attempt_id: str + kind: str + upload_key: str + status: str + + +@dataclass(frozen=True) +class RecoverySnapshot: + session: PollingSession | None + pending_claim: ClaimRequest | None + active_claim: ClaimedTask | None = field(repr=False) + pending_renew: RenewRequest | None = field(repr=False) + pending_evidence: tuple[PendingEvidence, ...] + + +def _range(value: object, minimum: int, maximum: int, reason: str) -> None: + if type(value) is not int or not minimum <= value <= maximum: + raise ValueError(reason) diff --git a/client/src/cmbuyer_client/localstate/protection.py b/client/src/cmbuyer_client/localstate/protection.py new file mode 100644 index 0000000..97695ac --- /dev/null +++ b/client/src/cmbuyer_client/localstate/protection.py @@ -0,0 +1,112 @@ +"""Windows 当前用户范围 DPAPI 封装;生产环境绝不降级为明文。""" + +from __future__ import annotations + +import ctypes +from ctypes import wintypes +import os +import re +from typing import Protocol + +from cmbuyer_client.core.errors import ProtectionError + + +class SecretProtector(Protocol): + def protect(self, plaintext: bytes, *, purpose: str) -> bytes: ... + + def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes: ... + + +class _DataBlob(ctypes.Structure): + _fields_ = (("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_ubyte))) + + +def _blob(data: bytes) -> tuple[_DataBlob, object]: + buffer = (ctypes.c_ubyte * len(data)).from_buffer_copy(data) if data else (ctypes.c_ubyte * 1)() + return _DataBlob(len(data), ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ubyte))), buffer + + +class DpapiProtector: + """使用 CryptProtectData/UI_FORBIDDEN;错误只暴露固定 reason code。""" + + _UI_FORBIDDEN = 0x1 + _ENTROPY_PREFIX = b"cmbuyer-localstate-v1:" + _PURPOSE_RE = re.compile( + r"(?:device-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36}|" + r"claim-token:[a-z0-9][a-z0-9_-]{0,63}:[0-9a-f-]{36})", + flags=re.ASCII, + ) + + def __init__(self) -> None: + if os.name != "nt": + raise ProtectionError("dpapi_requires_windows") + self._crypt32 = ctypes.WinDLL("crypt32", use_last_error=True) + self._kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + self._crypt32.CryptProtectData.argtypes = ( + ctypes.POINTER(_DataBlob), + wintypes.LPCWSTR, + ctypes.POINTER(_DataBlob), + wintypes.LPVOID, + wintypes.LPVOID, + wintypes.DWORD, + ctypes.POINTER(_DataBlob), + ) + self._crypt32.CryptProtectData.restype = wintypes.BOOL + self._crypt32.CryptUnprotectData.argtypes = ( + ctypes.POINTER(_DataBlob), + ctypes.POINTER(wintypes.LPWSTR), + ctypes.POINTER(_DataBlob), + wintypes.LPVOID, + wintypes.LPVOID, + wintypes.DWORD, + ctypes.POINTER(_DataBlob), + ) + self._crypt32.CryptUnprotectData.restype = wintypes.BOOL + self._kernel32.LocalFree.argtypes = (wintypes.HLOCAL,) + self._kernel32.LocalFree.restype = wintypes.HLOCAL + + def protect(self, plaintext: bytes, *, purpose: str) -> bytes: + if not isinstance(plaintext, bytes) or not plaintext: + raise ProtectionError("invalid_plaintext") + entropy = self._entropy(purpose) + source, source_buffer = _blob(plaintext) + entropy_blob, entropy_buffer = _blob(entropy) + output = _DataBlob() + if not self._crypt32.CryptProtectData( + ctypes.byref(source), None, ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output) + ): + raise ProtectionError("dpapi_protect_failed") + # ctypes 指针不持有底层 Python buffer;局部引用必须活到系统调用返回。 + del source_buffer, entropy_buffer + return self._take_output(output, "dpapi_protect_failed") + + def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes: + if not isinstance(ciphertext, bytes) or not ciphertext: + raise ProtectionError("invalid_ciphertext") + entropy = self._entropy(purpose) + source, source_buffer = _blob(ciphertext) + entropy_blob, entropy_buffer = _blob(entropy) + output = _DataBlob() + description = wintypes.LPWSTR() + if not self._crypt32.CryptUnprotectData( + ctypes.byref(source), ctypes.byref(description), ctypes.byref(entropy_blob), None, None, self._UI_FORBIDDEN, ctypes.byref(output) + ): + raise ProtectionError("dpapi_unprotect_failed") + del source_buffer, entropy_buffer + if description: + self._kernel32.LocalFree(ctypes.cast(description, wintypes.HLOCAL)) + return self._take_output(output, "dpapi_unprotect_failed") + + def _take_output(self, output: _DataBlob, reason: str) -> bytes: + if not output.pbData or output.cbData <= 0: + raise ProtectionError(reason) + try: + return ctypes.string_at(output.pbData, output.cbData) + finally: + self._kernel32.LocalFree(ctypes.cast(output.pbData, wintypes.HLOCAL)) + + @classmethod + def _entropy(cls, purpose: str) -> bytes: + if not isinstance(purpose, str) or cls._PURPOSE_RE.fullmatch(purpose) is None: + raise ProtectionError("invalid_protection_purpose") + return cls._ENTROPY_PREFIX + purpose.encode("ascii") diff --git a/client/src/cmbuyer_client/localstate/single_instance.py b/client/src/cmbuyer_client/localstate/single_instance.py new file mode 100644 index 0000000..bc1e496 --- /dev/null +++ b/client/src/cmbuyer_client/localstate/single_instance.py @@ -0,0 +1,51 @@ +"""同一本地数据库的 Windows named mutex。""" + +from __future__ import annotations + +import ctypes +from ctypes import wintypes +import hashlib +import os +from pathlib import Path + +from cmbuyer_client.core.errors import SingleInstanceError + + +class NamedMutex: + _ALREADY_EXISTS = 183 + + def __init__(self, database_path: Path) -> None: + if os.name != "nt": + raise SingleInstanceError("named_mutex_requires_windows") + canonical = str(database_path.expanduser().resolve()).casefold().encode("utf-8") + # Global namespace 覆盖同一 Windows 用户的多个交互 session;默认 DACL 不向其他用户泄露句柄。 + name = "Global\\cmbuyer-" + hashlib.sha256(canonical).hexdigest() + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CreateMutexW.argtypes = (wintypes.LPVOID, wintypes.BOOL, wintypes.LPCWSTR) + kernel32.CreateMutexW.restype = wintypes.HANDLE + kernel32.ReleaseMutex.argtypes = (wintypes.HANDLE,) + kernel32.ReleaseMutex.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = (wintypes.HANDLE,) + kernel32.CloseHandle.restype = wintypes.BOOL + ctypes.set_last_error(0) + handle = kernel32.CreateMutexW(None, True, name) + if not handle: + raise SingleInstanceError("named_mutex_failed") + if ctypes.get_last_error() == self._ALREADY_EXISTS: + kernel32.CloseHandle(handle) + raise SingleInstanceError("instance_already_running") + self._kernel32 = kernel32 + self._handle = handle + + def close(self) -> None: + handle = getattr(self, "_handle", None) + if handle: + self._kernel32.ReleaseMutex(handle) + self._kernel32.CloseHandle(handle) + self._handle = None + + def __enter__(self) -> "NamedMutex": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + self.close() diff --git a/client/src/cmbuyer_client/localstate/store.py b/client/src/cmbuyer_client/localstate/store.py new file mode 100644 index 0000000..fe43a75 --- /dev/null +++ b/client/src/cmbuyer_client/localstate/store.py @@ -0,0 +1,1247 @@ +"""以同步 SQLite 事务保存幂等请求和当前 claim 的唯一状态源。""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import asdict +from datetime import datetime, timezone +import hashlib +import json +import os +from pathlib import Path +import sqlite3 +import stat +from typing import Callable, Iterator +from uuid import uuid4 + +from cmbuyer_client.core.errors import ProtectionError, StateError, ValidationError +from cmbuyer_client.core.models import ( + AssetReceipt, + AttemptSnapshot, + AuthorizationSnapshot, + ClaimRequest, + ClaimedTask, + DeviceCredentials, + EvidenceUpload, + PurchaseTask, + RenewRequest, + RenewResult, + ScreenshotAsset, + SecretToken, +) +from cmbuyer_client.core.validation import datetime_nanoseconds, require_rfc3339_z, require_uuid4, rfc3339_z_nanoseconds + +from .models import LoadedProfile, PendingEvidence, PollingSession, ProfileSettings, RecoverySnapshot +from .protection import SecretProtector + + +_APPLICATION_ID = 0x434D4259 +_SCHEMA_VERSION = 1 +_PENDING = "PENDING" +_TERMINAL_CLAIM = frozenset(("PROTOCOL", "MANUAL")) + + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS profiles ( + profile_id TEXT PRIMARY KEY, + service_url TEXT NOT NULL, + device_id TEXT NOT NULL, + device_token_cipher BLOB NOT NULL CHECK(typeof(device_token_cipher)='blob'), + adb_path TEXT NOT NULL, + adb_serial TEXT NOT NULL, + transport TEXT NOT NULL CHECK(transport IN ('usb','wifi')), + poll_interval_seconds INTEGER NOT NULL, + failure_threshold INTEGER NOT NULL, + http_timeout_seconds INTEGER NOT NULL, + step_timeout_seconds INTEGER NOT NULL, + updated_at TEXT NOT NULL +) STRICT; +CREATE TABLE IF NOT EXISTS polling_sessions ( + session_id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL REFERENCES profiles(profile_id), + service_url TEXT NOT NULL, + device_id TEXT NOT NULL, + adb_path TEXT NOT NULL, + adb_serial TEXT NOT NULL, + transport TEXT NOT NULL CHECK(transport IN ('usb','wifi')), + poll_interval_seconds INTEGER NOT NULL, + failure_threshold INTEGER NOT NULL, + http_timeout_seconds INTEGER NOT NULL, + step_timeout_seconds INTEGER NOT NULL, + accept_new INTEGER NOT NULL CHECK(accept_new IN (0,1)), + started_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + closed_at TEXT +) STRICT; +CREATE UNIQUE INDEX IF NOT EXISTS one_open_polling_session_per_profile + ON polling_sessions(profile_id) WHERE closed_at IS NULL; +CREATE TRIGGER IF NOT EXISTS polling_sessions_no_delete +BEFORE DELETE ON polling_sessions BEGIN SELECT RAISE(ABORT, 'polling session is append-only'); END; +CREATE TRIGGER IF NOT EXISTS polling_sessions_identity_immutable +BEFORE UPDATE OF session_id,profile_id,service_url,device_id,adb_path,adb_serial,transport, + poll_interval_seconds,failure_threshold,http_timeout_seconds,step_timeout_seconds,started_at ON polling_sessions BEGIN + SELECT RAISE(ABORT, 'polling session identity is immutable'); +END; +CREATE TRIGGER IF NOT EXISTS polling_sessions_closed_monotonic +BEFORE UPDATE OF closed_at ON polling_sessions +WHEN OLD.closed_at IS NOT NULL OR NEW.closed_at IS NULL BEGIN + SELECT RAISE(ABORT, 'closed session cannot be reopened'); +END; +CREATE TABLE IF NOT EXISTS claim_requests ( + claim_request_id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL REFERENCES profiles(profile_id), + session_id TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('PENDING','EMPTY','SUCCEEDED','PROTOCOL','MANUAL')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; +CREATE UNIQUE INDEX IF NOT EXISTS one_pending_claim_per_profile + ON claim_requests(profile_id) WHERE status='PENDING'; +CREATE TRIGGER IF NOT EXISTS claim_requests_no_delete +BEFORE DELETE ON claim_requests BEGIN SELECT RAISE(ABORT, 'claim request is append-only'); END; +CREATE TRIGGER IF NOT EXISTS claim_requests_identity_immutable +BEFORE UPDATE OF claim_request_id,profile_id,session_id,created_at ON claim_requests BEGIN + SELECT RAISE(ABORT, 'claim request identity is immutable'); +END; +CREATE TRIGGER IF NOT EXISTS claim_requests_status_monotonic +BEFORE UPDATE OF status ON claim_requests WHEN OLD.status!='PENDING' OR NEW.status='PENDING' BEGIN + SELECT RAISE(ABORT, 'claim request status is monotonic'); +END; +CREATE TABLE IF NOT EXISTS active_claims ( + attempt_id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL REFERENCES profiles(profile_id), + session_id TEXT NOT NULL, + claim_request_id TEXT NOT NULL UNIQUE REFERENCES claim_requests(claim_request_id), + task_id TEXT NOT NULL, + claim_generation INTEGER NOT NULL CHECK(claim_generation > 0), + initial_lease_expires_at TEXT NOT NULL, + lease_expires_at TEXT NOT NULL, + authorization_expires_at TEXT NOT NULL, + snapshot_json TEXT NOT NULL, + snapshot_digest TEXT NOT NULL, + claim_token_cipher BLOB NOT NULL CHECK(typeof(claim_token_cipher)='blob'), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + closed_at TEXT +) STRICT; +CREATE UNIQUE INDEX IF NOT EXISTS one_open_claim_per_profile + ON active_claims(profile_id) WHERE closed_at IS NULL; +CREATE TRIGGER IF NOT EXISTS active_claims_no_delete +BEFORE DELETE ON active_claims BEGIN SELECT RAISE(ABORT, 'active claim is append-only'); END; +CREATE TRIGGER IF NOT EXISTS active_claims_identity_immutable +BEFORE UPDATE OF profile_id,session_id,claim_request_id,task_id,attempt_id,claim_generation, + initial_lease_expires_at,authorization_expires_at,snapshot_json,snapshot_digest,claim_token_cipher,created_at + ON active_claims BEGIN + SELECT RAISE(ABORT, 'active claim identity is immutable'); +END; +CREATE TRIGGER IF NOT EXISTS active_claims_closed_monotonic +BEFORE UPDATE OF closed_at ON active_claims +WHEN OLD.closed_at IS NOT NULL OR NEW.closed_at IS NULL BEGIN + SELECT RAISE(ABORT, 'closed claim cannot be reopened'); +END; +CREATE TABLE IF NOT EXISTS renew_requests ( + renew_request_id TEXT PRIMARY KEY, + profile_id TEXT NOT NULL REFERENCES profiles(profile_id), + task_id TEXT NOT NULL, + session_id TEXT NOT NULL, + attempt_id TEXT NOT NULL, + claim_generation INTEGER NOT NULL CHECK(claim_generation > 0), + expected_lease_expires_at TEXT NOT NULL, + authorization_expires_at TEXT NOT NULL, + claim_token_cipher BLOB NOT NULL CHECK(typeof(claim_token_cipher)='blob'), + status TEXT NOT NULL CHECK(status IN ('PENDING','SUCCEEDED','PROTOCOL','MANUAL')), + response_json TEXT, + response_digest TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +) STRICT; +CREATE UNIQUE INDEX IF NOT EXISTS one_pending_renew_per_profile + ON renew_requests(profile_id) WHERE status='PENDING'; +CREATE TRIGGER IF NOT EXISTS renew_requests_no_delete +BEFORE DELETE ON renew_requests BEGIN SELECT RAISE(ABORT, 'renew request is append-only'); END; +CREATE TRIGGER IF NOT EXISTS renew_requests_identity_immutable +BEFORE UPDATE OF renew_request_id,profile_id,task_id,session_id,attempt_id,claim_generation, + expected_lease_expires_at,authorization_expires_at,claim_token_cipher,created_at ON renew_requests BEGIN + SELECT RAISE(ABORT, 'renew request identity is immutable'); +END; +CREATE TRIGGER IF NOT EXISTS renew_requests_status_monotonic +BEFORE UPDATE OF status ON renew_requests WHEN OLD.status!='PENDING' OR NEW.status='PENDING' BEGIN + SELECT RAISE(ABORT, 'renew request status is monotonic'); +END; +CREATE TRIGGER IF NOT EXISTS renew_requests_response_immutable +BEFORE UPDATE OF response_json,response_digest ON renew_requests WHEN OLD.status='SUCCEEDED' BEGIN + SELECT RAISE(ABORT, 'successful renew response is immutable'); +END; +CREATE TABLE IF NOT EXISTS evidence_slot_markers ( + attempt_id TEXT NOT NULL, + kind TEXT NOT NULL, + profile_id TEXT NOT NULL REFERENCES profiles(profile_id), + upload_key TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + PRIMARY KEY(attempt_id, kind) +) STRICT; +CREATE TRIGGER IF NOT EXISTS evidence_slot_markers_no_delete +BEFORE DELETE ON evidence_slot_markers BEGIN SELECT RAISE(ABORT, 'evidence marker is append-only'); END; +CREATE TRIGGER IF NOT EXISTS evidence_slot_markers_immutable +BEFORE UPDATE ON evidence_slot_markers BEGIN SELECT RAISE(ABORT, 'evidence marker is immutable'); END; +CREATE TABLE IF NOT EXISTS evidence_slots ( + attempt_id TEXT NOT NULL, + kind TEXT NOT NULL, + profile_id TEXT NOT NULL REFERENCES profiles(profile_id), + task_id TEXT NOT NULL, + upload_key TEXT NOT NULL UNIQUE, + privacy_tier TEXT NOT NULL, + sha256 TEXT NOT NULL, + captured_at TEXT NOT NULL, + source_path TEXT NOT NULL, + file_identity TEXT NOT NULL, + byte_size INTEGER NOT NULL CHECK(byte_size > 0), + width_px INTEGER NOT NULL CHECK(width_px > 0), + height_px INTEGER NOT NULL CHECK(height_px > 0), + mtime_ns INTEGER NOT NULL, + status TEXT NOT NULL CHECK(status IN ('PENDING','SUCCEEDED','PROTOCOL','MANUAL')), + receipt_json TEXT, + receipt_digest TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(attempt_id, kind) +) STRICT; +CREATE TRIGGER IF NOT EXISTS evidence_slots_no_delete +BEFORE DELETE ON evidence_slots BEGIN + SELECT RAISE(ABORT, 'evidence slot is append-only'); +END; +CREATE TRIGGER IF NOT EXISTS evidence_slots_identity_immutable +BEFORE UPDATE OF attempt_id,kind,profile_id,task_id,upload_key,privacy_tier,sha256,captured_at, + source_path,file_identity,byte_size,width_px,height_px,mtime_ns ON evidence_slots BEGIN + SELECT RAISE(ABORT, 'evidence slot identity is immutable'); +END; +CREATE TRIGGER IF NOT EXISTS evidence_slots_status_monotonic +BEFORE UPDATE OF status ON evidence_slots WHEN OLD.status!='PENDING' OR NEW.status='PENDING' BEGIN + SELECT RAISE(ABORT, 'evidence slot status is monotonic'); +END; +CREATE TRIGGER IF NOT EXISTS evidence_slots_receipt_immutable +BEFORE UPDATE OF receipt_json,receipt_digest ON evidence_slots WHEN OLD.status='SUCCEEDED' BEGIN + SELECT RAISE(ABORT, 'successful evidence receipt is immutable'); +END; +""" + + +class LocalStateStore: + """所有跨重启状态的唯一读写入口;每个操作使用独立连接。""" + + def __init__( + self, + database_path: Path, + protector: SecretProtector, + *, + now: Callable[[], datetime] | None = None, + uuid_factory: Callable[[], object] = uuid4, + ) -> None: + # 独立使用 store 时同样固化绝对路径,不能因 cwd 变化漂移到另一套状态库。 + self.database_path = database_path.expanduser().resolve(strict=False) + self._protector = protector + self._now = now or (lambda: datetime.now(timezone.utc)) + self._uuid_factory = uuid_factory + try: + self.database_path.parent.mkdir(parents=True, exist_ok=True) + with self._reader() as connection: + application_id = connection.execute("PRAGMA application_id").fetchone()[0] + version = connection.execute("PRAGMA user_version").fetchone()[0] + if application_id not in (0, _APPLICATION_ID) or version not in (0, _SCHEMA_VERSION): + raise StateError("unsupported_localstate_schema") + connection.executescript(_SCHEMA) + connection.execute(f"PRAGMA application_id={_APPLICATION_ID}") + connection.execute(f"PRAGMA user_version={_SCHEMA_VERSION}") + if connection.execute("PRAGMA quick_check").fetchone()[0] != "ok": + raise StateError("localstate_integrity_failed") + if connection.execute("PRAGMA foreign_key_check").fetchone() is not None: + raise StateError("localstate_foreign_key_failed") + except StateError: + raise + except (OSError, sqlite3.Error): + raise StateError("localstate_open_failed") from None + + def save_profile(self, settings: ProfileSettings, token: SecretToken | None) -> None: + new_cipher: bytes | None = None + if token is not None: + new_cipher = self._protect_token( + token, purpose=_device_token_purpose(settings.profile_id, settings.device_id) + ) + now = self._utc_now() + with self._transaction() as connection: + existing = connection.execute( + """SELECT service_url,device_id,device_token_cipher,adb_path,adb_serial,transport, + poll_interval_seconds,failure_threshold,http_timeout_seconds,step_timeout_seconds + FROM profiles WHERE profile_id=?""", + (settings.profile_id,), + ).fetchone() + if existing is not None: + self._validate_state_graph(connection, settings.profile_id) + frozen = self._identity_frozen(connection, settings.profile_id) + if existing is not None: + existing_cipher = bytes(existing[2]) + frozen_values = (existing[0], existing[1], existing[3], existing[4], existing[5], existing[6], existing[7], existing[8], existing[9]) + requested_values = ( + settings.service_url, + settings.device_id, + settings.adb_path, + settings.adb_serial, + settings.transport, + settings.poll_interval_seconds, + settings.failure_threshold, + settings.http_timeout_seconds, + settings.step_timeout_seconds, + ) + if frozen and frozen_values != requested_values: + raise StateError("profile_identity_frozen") + if existing[1] != settings.device_id and new_cipher is None: + raise StateError("device_token_required_for_device_change") + if not frozen and frozen_values != requested_values: + connection.execute( + """UPDATE polling_sessions SET accept_new=0,updated_at=?,closed_at=? + WHERE profile_id=? AND closed_at IS NULL""", + (now, now, settings.profile_id), + ) + cipher = new_cipher if new_cipher is not None else existing_cipher + else: + if new_cipher is None: + raise StateError("device_token_required") + cipher = new_cipher + connection.execute( + """INSERT INTO profiles(profile_id,service_url,device_id,device_token_cipher,adb_path,adb_serial, + transport,poll_interval_seconds,failure_threshold,http_timeout_seconds,step_timeout_seconds,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(profile_id) DO UPDATE SET service_url=excluded.service_url,device_id=excluded.device_id, + device_token_cipher=excluded.device_token_cipher,adb_path=excluded.adb_path,adb_serial=excluded.adb_serial, + transport=excluded.transport,poll_interval_seconds=excluded.poll_interval_seconds, + failure_threshold=excluded.failure_threshold,http_timeout_seconds=excluded.http_timeout_seconds, + step_timeout_seconds=excluded.step_timeout_seconds,updated_at=excluded.updated_at""", + ( + settings.profile_id, + settings.service_url, + settings.device_id, + sqlite3.Binary(cipher), + settings.adb_path, + settings.adb_serial, + settings.transport, + settings.poll_interval_seconds, + settings.failure_threshold, + settings.http_timeout_seconds, + settings.step_timeout_seconds, + now, + ), + ) + + def load_profile(self, profile_id: str) -> LoadedProfile: + with self._read_transaction() as connection: + row = connection.execute( + """SELECT service_url,device_id,device_token_cipher,adb_path,adb_serial,transport, + poll_interval_seconds,failure_threshold,http_timeout_seconds,step_timeout_seconds + FROM profiles WHERE profile_id=?""", + (profile_id,), + ).fetchone() + if row is None: + raise StateError("profile_not_found") + settings = ProfileSettings(profile_id, row[0], row[1], row[3], row[4], row[5], row[6], row[7], row[8], row[9]) + token = self._unprotect_token(bytes(row[2]), purpose=_device_token_purpose(profile_id, settings.device_id)) + return LoadedProfile(settings, DeviceCredentials(settings.device_id, token)) + + def start_or_resume_polling(self, profile_id: str) -> PollingSession: + now = self._utc_now() + with self._transaction() as connection: + self._validate_state_graph(connection, profile_id) + profile = connection.execute( + """SELECT service_url,device_id,adb_path,adb_serial,transport,poll_interval_seconds, + failure_threshold,http_timeout_seconds,step_timeout_seconds FROM profiles WHERE profile_id=?""", + (profile_id,), + ).fetchone() + if profile is None: + raise StateError("profile_not_found") + row = connection.execute( + """SELECT session_id,service_url,device_id,adb_path,adb_serial,transport, + poll_interval_seconds,failure_threshold,http_timeout_seconds,step_timeout_seconds FROM polling_sessions + WHERE profile_id=? AND closed_at IS NULL""", + (profile_id,), + ).fetchone() + if row is None: + session_id = self._new_uuid() + connection.execute( + """INSERT INTO polling_sessions(session_id,profile_id,service_url,device_id,adb_path,adb_serial, + transport,poll_interval_seconds,failure_threshold,http_timeout_seconds,step_timeout_seconds, + accept_new,started_at,updated_at,closed_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,1,?,?,NULL)""", + (session_id, profile_id, *profile, now, now), + ) + else: + session_id = row[0] + if tuple(row[1:]) != tuple(profile): + raise StateError("polling_identity_mismatch") + connection.execute( + "UPDATE polling_sessions SET accept_new=1,updated_at=? WHERE session_id=?", (now, session_id) + ) + return PollingSession(profile_id, session_id, True) + + def request_stop(self, profile_id: str) -> PollingSession: + now = self._utc_now() + with self._transaction() as connection: + row = connection.execute( + "SELECT session_id FROM polling_sessions WHERE profile_id=? AND closed_at IS NULL", (profile_id,) + ).fetchone() + if row is None: + raise StateError("polling_session_not_found") + connection.execute( + "UPDATE polling_sessions SET accept_new=0,updated_at=? WHERE session_id=?", (now, row[0]) + ) + return PollingSession(profile_id, row[0], False) + + def prepare_claim(self, profile_id: str) -> ClaimRequest: + now = self._utc_now() + with self._transaction() as connection: + self._validate_state_graph(connection, profile_id) + session = connection.execute( + """SELECT session_id,accept_new FROM polling_sessions + WHERE profile_id=? AND closed_at IS NULL""", + (profile_id,), + ).fetchone() + if session is None: + raise StateError("polling_session_not_found") + pending = connection.execute( + "SELECT claim_request_id,session_id FROM claim_requests WHERE profile_id=? AND status='PENDING'", + (profile_id,), + ).fetchone() + active_exists = connection.execute( + "SELECT 1 FROM active_claims WHERE profile_id=? AND closed_at IS NULL", (profile_id,) + ).fetchone() + if active_exists is not None: + raise StateError("active_claim_exists") + if pending is not None: + if pending[1] != session[0]: + raise StateError("pending_claim_session_mismatch") + return ClaimRequest(pending[1], pending[0]) + last = connection.execute( + "SELECT status FROM claim_requests WHERE profile_id=? ORDER BY rowid DESC LIMIT 1", (profile_id,) + ).fetchone() + if last is not None and last[0] in _TERMINAL_CLAIM: + raise StateError("claim_requires_intervention") + if session[1] != 1: + raise StateError("polling_stopped") + request_id = self._new_uuid() + connection.execute( + "INSERT INTO claim_requests VALUES(?,?,?,'PENDING',?,?)", + (request_id, profile_id, session[0], now, now), + ) + return ClaimRequest(session[0], request_id) + + def commit_claim_empty(self, profile_id: str, request: ClaimRequest) -> None: + self._finish_claim_request(profile_id, request, "EMPTY") + + def mark_claim_terminal(self, profile_id: str, request: ClaimRequest, outcome: str) -> None: + if outcome not in _TERMINAL_CLAIM: + raise StateError("invalid_claim_outcome") + self._finish_claim_request(profile_id, request, outcome) + + def commit_claim_success(self, profile_id: str, request: ClaimRequest, claimed: ClaimedTask) -> None: + if not isinstance(claimed, ClaimedTask): + raise StateError("invalid_claim_response") + if rfc3339_z_nanoseconds(claimed.attempt.lease_expires_at) > rfc3339_z_nanoseconds( + claimed.authorization.expires_at + ): + raise StateError("claim_lease_exceeds_authorization") + claim_purpose = _claim_token_purpose(profile_id, claimed.attempt.id) + cipher = self._protect_token(claimed.attempt.claim_token, purpose=claim_purpose) + snapshot = _claim_to_json(claimed) + snapshot_digest = hashlib.sha256(snapshot.encode("utf-8")).hexdigest() + now = self._utc_now() + with self._transaction() as connection: + self._validate_state_graph(connection, profile_id) + self._require_pending_claim(connection, profile_id, request) + if connection.execute( + "SELECT 1 FROM active_claims WHERE profile_id=? AND closed_at IS NULL", (profile_id,) + ).fetchone(): + raise StateError("active_claim_exists") + connection.execute( + """INSERT INTO active_claims(attempt_id,profile_id,session_id,claim_request_id,task_id, + claim_generation,initial_lease_expires_at,lease_expires_at,authorization_expires_at,snapshot_json, + snapshot_digest,claim_token_cipher,created_at,updated_at,closed_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL)""", + ( + claimed.attempt.id, + profile_id, + request.session_id, + request.claim_request_id, + claimed.task.id, + claimed.attempt.claim_generation, + claimed.attempt.lease_expires_at, + claimed.attempt.lease_expires_at, + claimed.authorization.expires_at, + snapshot, + snapshot_digest, + sqlite3.Binary(cipher), + now, + now, + ), + ) + updated = connection.execute( + "UPDATE claim_requests SET status='SUCCEEDED',updated_at=? WHERE claim_request_id=? AND status='PENDING'", + (now, request.claim_request_id), + ) + if updated.rowcount != 1: + raise StateError("claim_commit_race") + + def active_claim(self, profile_id: str) -> ClaimedTask | None: + with self._read_transaction() as connection: + return self._validate_state_graph(connection, profile_id) + + def prepare_renew(self, profile_id: str) -> RenewRequest: + with self._transaction() as connection: + self._validate_state_graph(connection, profile_id) + active = connection.execute( + """SELECT task_id,session_id,attempt_id,claim_generation,lease_expires_at,authorization_expires_at,claim_token_cipher + FROM active_claims WHERE profile_id=? AND closed_at IS NULL""", + (profile_id,), + ).fetchone() + if active is None: + raise StateError("active_claim_not_found") + pending = connection.execute( + """SELECT renew_request_id,task_id,session_id,attempt_id,claim_generation, + expected_lease_expires_at,authorization_expires_at,claim_token_cipher FROM renew_requests + WHERE profile_id=? AND status='PENDING'""", + (profile_id,), + ).fetchone() + if pending is not None: + token = self._unprotect_token( + bytes(pending[7]), purpose=_claim_token_purpose(profile_id, pending[3]) + ) + return RenewRequest(pending[1], pending[0], pending[2], pending[3], pending[4], token, pending[5], pending[6]) + terminal = connection.execute( + """SELECT status FROM renew_requests WHERE profile_id=? AND attempt_id=? + ORDER BY rowid DESC LIMIT 1""", + (profile_id, active[2]), + ).fetchone() + if terminal is not None and terminal[0] in _TERMINAL_CLAIM: + raise StateError("renew_requires_intervention") + if rfc3339_z_nanoseconds(active[4]) <= datetime_nanoseconds(self._now()): + raise StateError("lease_expired") + claim_purpose = _claim_token_purpose(profile_id, active[2]) + token = self._unprotect_token(bytes(active[6]), purpose=claim_purpose) + duplicate_cipher = self._protect_token(token, purpose=claim_purpose) + request_id = self._new_uuid() + now = self._utc_now() + connection.execute( + """INSERT INTO renew_requests(renew_request_id,profile_id,task_id,session_id,attempt_id, + claim_generation,expected_lease_expires_at,authorization_expires_at,claim_token_cipher,status, + response_json,response_digest,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?, 'PENDING',NULL,NULL,?,?)""", + (request_id, profile_id, active[0], active[1], active[2], active[3], active[4], active[5], sqlite3.Binary(duplicate_cipher), now, now), + ) + return RenewRequest(active[0], request_id, active[1], active[2], active[3], token, active[4], active[5]) + + def commit_renew_success(self, profile_id: str, request: RenewRequest, result: RenewResult) -> None: + if result.task_id != request.task_id or result.attempt_id != request.attempt_id or result.claim_generation != request.claim_generation: + raise StateError("renew_response_mismatch") + if rfc3339_z_nanoseconds(result.lease_expires_at) < rfc3339_z_nanoseconds(request.expected_lease_expires_at): + raise StateError("renew_response_mismatch") + if rfc3339_z_nanoseconds(result.lease_expires_at) > rfc3339_z_nanoseconds(request.authorization_expires_at): + raise StateError("renew_response_mismatch") + now = self._utc_now() + response_json = json.dumps(asdict(result), separators=(",", ":"), sort_keys=True) + response_digest = hashlib.sha256(response_json.encode("utf-8")).hexdigest() + with self._transaction() as connection: + self._validate_state_graph(connection, profile_id) + row = connection.execute( + """SELECT status,task_id,session_id,attempt_id,claim_generation,expected_lease_expires_at,authorization_expires_at + FROM renew_requests WHERE renew_request_id=? AND profile_id=?""", + (request.renew_request_id, profile_id), + ).fetchone() + if row is None or row[0] != _PENDING or tuple(row[1:]) != ( + request.task_id, + request.session_id, + request.attempt_id, + request.claim_generation, + request.expected_lease_expires_at, + request.authorization_expires_at, + ): + raise StateError("renew_request_not_pending") + current = connection.execute( + """SELECT 1 FROM active_claims WHERE profile_id=? AND task_id=? AND attempt_id=? + AND claim_generation=? AND lease_expires_at=? AND closed_at IS NULL""", + (profile_id, request.task_id, request.attempt_id, request.claim_generation, request.expected_lease_expires_at), + ).fetchone() + if current is None: + raise StateError("renew_cas_failed") + active = connection.execute( + """UPDATE active_claims SET lease_expires_at=?,updated_at=? WHERE profile_id=? AND task_id=? + AND attempt_id=? AND claim_generation=? AND lease_expires_at=? AND closed_at IS NULL""", + ( + result.lease_expires_at, + now, + profile_id, + request.task_id, + request.attempt_id, + request.claim_generation, + request.expected_lease_expires_at, + ), + ) + if active.rowcount != 1: + raise StateError("renew_cas_failed") + connection.execute( + """UPDATE renew_requests SET status='SUCCEEDED',response_json=?,response_digest=?,updated_at=? + WHERE renew_request_id=?""", + (response_json, response_digest, now, request.renew_request_id), + ) + + def mark_renew_terminal(self, profile_id: str, request: RenewRequest, outcome: str) -> None: + if outcome not in _TERMINAL_CLAIM: + raise StateError("invalid_renew_outcome") + with self._transaction() as connection: + self._validate_state_graph(connection, profile_id) + updated = connection.execute( + """UPDATE renew_requests SET status=?,updated_at=? WHERE renew_request_id=? + AND profile_id=? AND status='PENDING'""", + (outcome, self._utc_now(), request.renew_request_id, profile_id), + ) + if updated.rowcount != 1: + raise StateError("renew_request_not_pending") + + def prepare_or_resume_evidence(self, profile_id: str, asset: ScreenshotAsset) -> EvidenceUpload | AssetReceipt: + with self._read_transaction() as connection: + self._validate_state_graph(connection, profile_id) + existing = connection.execute( + """SELECT task_id,upload_key,privacy_tier,sha256,captured_at,source_path,file_identity, + byte_size,mtime_ns,status,receipt_json,width_px,height_px FROM evidence_slots + WHERE profile_id=? AND attempt_id=? AND kind=?""", + (profile_id, asset.attempt_id, asset.kind), + ).fetchone() + marker = connection.execute( + "SELECT profile_id,upload_key FROM evidence_slot_markers WHERE attempt_id=? AND kind=?", + (asset.attempt_id, asset.kind), + ).fetchone() + if marker is not None and marker[0] != profile_id: + raise StateError("evidence_slot_not_owned") + if existing is None and marker is not None: + # marker 与槽同事务创建且不可删除;只剩 marker 表示状态损坏,不能生成第二个 upload key。 + raise StateError("evidence_slot_missing") + if existing is not None and (marker is None or marker[1] != existing[1]): + raise StateError("evidence_marker_missing") + if existing is not None and existing[9] == "SUCCEEDED": + if ( + existing[0] != asset.task_id + or existing[2] != asset.privacy_tier + or rfc3339_z_nanoseconds(existing[4]) != rfc3339_z_nanoseconds(asset.captured_at) + or existing[5] != str(asset.path) + ): + raise StateError("evidence_slot_mismatch") + # 成功资产已经由服务端 receipt 固化;源文件之后变化或删除都只返回原 receipt,绝不再发 HTTP。 + return _receipt_from_json(existing[10]) + if existing is not None and existing[9] != _PENDING: + raise StateError("evidence_requires_intervention") + + content, identity, size, mtime_ns, digest, width, height = _read_stable_png(asset.path) + if existing is not None: + if ( + existing[0] != asset.task_id + or existing[2] != asset.privacy_tier + or existing[3] != digest + or rfc3339_z_nanoseconds(existing[4]) != rfc3339_z_nanoseconds(asset.captured_at) + or existing[5] != str(asset.path) + or existing[6] != identity + or existing[7] != size + or existing[8] != mtime_ns + or existing[11] != width + or existing[12] != height + ): + raise StateError("evidence_slot_mismatch") + # 相等时刻允许不同 RFC3339 精度表示,但重放必须使用首次持久化的原始文本,确保 multipart 字节不变。 + return EvidenceUpload( + existing[0], + existing[1], + asset.attempt_id, + existing[3], + existing[4], + content, + asset.kind, + existing[2], + ) + + upload_key = self._new_uuid() + now = self._utc_now() + with self._transaction() as connection: + self._validate_state_graph(connection, profile_id) + active = connection.execute( + "SELECT task_id,attempt_id FROM active_claims WHERE profile_id=? AND closed_at IS NULL", (profile_id,) + ).fetchone() + if active is None or tuple(active) != (asset.task_id, asset.attempt_id): + raise StateError("evidence_not_owned") + try: + connection.execute( + "INSERT INTO evidence_slot_markers(attempt_id,kind,profile_id,upload_key,created_at) VALUES(?,?,?,?,?)", + (asset.attempt_id, asset.kind, profile_id, upload_key, now), + ) + connection.execute( + """INSERT INTO evidence_slots(attempt_id,kind,profile_id,task_id,upload_key,privacy_tier, + sha256,captured_at,source_path,file_identity,byte_size,width_px,height_px,mtime_ns,status, + receipt_json,receipt_digest,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?, 'PENDING',NULL,NULL,?,?)""", + ( + asset.attempt_id, + asset.kind, + profile_id, + asset.task_id, + upload_key, + asset.privacy_tier, + digest, + asset.captured_at, + str(asset.path), + identity, + size, + width, + height, + mtime_ns, + now, + now, + ), + ) + except sqlite3.IntegrityError: + raise StateError("evidence_slot_race") from None + return EvidenceUpload(asset.task_id, upload_key, asset.attempt_id, digest, asset.captured_at, content, asset.kind, asset.privacy_tier) + + def commit_evidence_success(self, profile_id: str, upload: EvidenceUpload, receipt: AssetReceipt) -> None: + if ( + receipt.task_id != upload.task_id + or receipt.attempt_id != upload.attempt_id + or receipt.kind != upload.kind + or receipt.privacy_tier != upload.privacy_tier + or receipt.sha256 != upload.sha256 + or receipt.byte_size != len(upload.content) + or receipt.width_px != upload.width_px + or receipt.height_px != upload.height_px + or rfc3339_z_nanoseconds(receipt.captured_at) != rfc3339_z_nanoseconds(upload.captured_at) + ): + raise StateError("evidence_response_mismatch") + payload = json.dumps(asdict(receipt), separators=(",", ":"), sort_keys=True) + receipt_digest = hashlib.sha256(payload.encode("utf-8")).hexdigest() + with self._transaction() as connection: + self._validate_state_graph(connection, profile_id) + updated = connection.execute( + """UPDATE evidence_slots SET status='SUCCEEDED',receipt_json=?,receipt_digest=?,updated_at=? + WHERE profile_id=? AND attempt_id=? AND kind=? AND upload_key=? AND sha256=? AND status='PENDING'""", + ( + payload, + receipt_digest, + self._utc_now(), + profile_id, + upload.attempt_id, + upload.kind, + upload.upload_key, + upload.sha256, + ), + ) + if updated.rowcount != 1: + raise StateError("evidence_slot_not_pending") + + def mark_evidence_terminal(self, profile_id: str, upload: EvidenceUpload, outcome: str) -> None: + if outcome not in _TERMINAL_CLAIM: + raise StateError("invalid_evidence_outcome") + with self._transaction() as connection: + self._validate_state_graph(connection, profile_id) + updated = connection.execute( + """UPDATE evidence_slots SET status=?,updated_at=? WHERE profile_id=? AND attempt_id=? + AND kind=? AND upload_key=? AND sha256=? AND status='PENDING'""", + (outcome, self._utc_now(), profile_id, upload.attempt_id, upload.kind, upload.upload_key, upload.sha256), + ) + if updated.rowcount != 1: + raise StateError("evidence_slot_not_pending") + + def recovery_snapshot(self, profile_id: str) -> RecoverySnapshot: + with self._read_transaction() as connection: + validated_active = self._validate_state_graph(connection, profile_id) + session_row = connection.execute( + """SELECT session_id,accept_new FROM polling_sessions + WHERE profile_id=? AND closed_at IS NULL""", + (profile_id,), + ).fetchone() + claim_row = connection.execute( + "SELECT session_id,claim_request_id FROM claim_requests WHERE profile_id=? AND status='PENDING'", + (profile_id,), + ).fetchone() + if validated_active is None: + renew_row = None + evidence_rows = [] + else: + renew_row = connection.execute( + """SELECT renew_request_id,task_id,session_id,attempt_id,claim_generation, + expected_lease_expires_at,authorization_expires_at,claim_token_cipher FROM renew_requests + WHERE profile_id=? AND attempt_id=? AND status='PENDING'""", + (profile_id, validated_active.attempt.id), + ).fetchone() + evidence_rows = connection.execute( + """SELECT task_id,attempt_id,kind,upload_key,status FROM evidence_slots + WHERE profile_id=? AND attempt_id=? AND status!='SUCCEEDED' ORDER BY kind""", + (profile_id, validated_active.attempt.id), + ).fetchall() + session = None if session_row is None else PollingSession(profile_id, session_row[0], bool(session_row[1])) + claim = None if claim_row is None else ClaimRequest(claim_row[0], claim_row[1]) + active = validated_active + renew = None + if renew_row is not None: + token = self._unprotect_token( + bytes(renew_row[7]), purpose=_claim_token_purpose(profile_id, renew_row[3]) + ) + renew = RenewRequest(renew_row[1], renew_row[0], renew_row[2], renew_row[3], renew_row[4], token, renew_row[5], renew_row[6]) + evidence = tuple(PendingEvidence(*row) for row in evidence_rows) + return RecoverySnapshot(session, claim, active, renew, evidence) + + def _validate_state_graph(self, connection: sqlite3.Connection, profile_id: str) -> ClaimedTask | None: + profile = connection.execute( + """SELECT service_url,device_id,adb_path,adb_serial,transport,poll_interval_seconds, + failure_threshold,http_timeout_seconds,step_timeout_seconds FROM profiles WHERE profile_id=?""", + (profile_id,), + ).fetchone() + if profile is None: + raise StateError("profile_not_found") + session_rows = connection.execute( + """SELECT session_id,service_url,device_id,adb_path,adb_serial,transport,poll_interval_seconds, + failure_threshold,http_timeout_seconds,step_timeout_seconds,started_at,closed_at FROM polling_sessions + WHERE profile_id=? ORDER BY started_at,session_id""", + (profile_id,), + ).fetchall() + for stored_session in session_rows: + started_ns = _stored_timestamp_ns(stored_session[10], "invalid_session_started_at") + if stored_session[11] is not None and ( + _stored_timestamp_ns(stored_session[11], "invalid_session_closed_at") < started_ns + ): + raise StateError("invalid_session_timeline") + open_sessions = [row for row in session_rows if row[11] is None] + if len(open_sessions) > 1: + raise StateError("polling_state_conflict") + session = open_sessions[0] if open_sessions else None + if session is not None and tuple(session[1:10]) != tuple(profile): + raise StateError("polling_identity_mismatch") + session_by_id = {row[0]: row for row in session_rows} + + request_rows = connection.execute( + "SELECT claim_request_id,session_id,status FROM claim_requests WHERE profile_id=? ORDER BY rowid", + (profile_id,), + ).fetchall() + pending_claims = [row for row in request_rows if row[2] == _PENDING] + if len(pending_claims) > 1: + raise StateError("claim_state_conflict") + if any(row[1] not in session_by_id for row in request_rows): + raise StateError("claim_session_missing") + if pending_claims and (session is None or pending_claims[0][1] != session[0]): + raise StateError("pending_claim_session_mismatch") + + history_rows = connection.execute( + """SELECT session_id,claim_request_id,task_id,attempt_id,claim_generation,initial_lease_expires_at, + lease_expires_at,authorization_expires_at,snapshot_json,snapshot_digest,claim_token_cipher, + created_at,closed_at FROM active_claims + WHERE profile_id=? ORDER BY created_at,attempt_id""", + (profile_id,), + ).fetchall() + succeeded = [row for row in request_rows if row[2] == "SUCCEEDED"] + request_by_id = {row[0]: row for row in request_rows} + if {row[0] for row in succeeded} != {row[1] for row in history_rows}: + raise StateError("active_claim_request_mismatch") + open_rows = [row for row in history_rows if row[12] is None] + if len(open_rows) > 1: + raise StateError("claim_state_conflict") + active_row = open_rows[0] if open_rows else None + active: ClaimedTask | None = None + claims_by_attempt: dict[str, ClaimedTask] = {} + claim_rows_by_attempt: dict[str, sqlite3.Row] = {} + for history in history_rows: + request = request_by_id.get(history[1]) + history_session = session_by_id.get(history[0]) + if ( + request is None + or request[1] != history[0] + or request[2] != "SUCCEEDED" + or history_session is None + or (history[12] is None and history_session[11] is not None) + ): + raise StateError("active_claim_request_mismatch") + created_ns = _stored_timestamp_ns(history[11], "invalid_claim_created_at") + if history[12] is not None and ( + _stored_timestamp_ns(history[12], "invalid_claim_closed_at") < created_ns + ): + raise StateError("invalid_claim_timeline") + if history[9] != hashlib.sha256(history[8].encode("utf-8")).hexdigest(): + raise StateError("active_claim_snapshot_mismatch") + if _stored_timestamp_ns(history[5], "invalid_initial_lease_expiry") > _stored_timestamp_ns( + history[7], "invalid_authorization_expiry" + ): + raise StateError("claim_lease_exceeds_authorization") + token = self._unprotect_token( + bytes(history[10]), purpose=_claim_token_purpose(profile_id, history[3]) + ) + restored = _claim_from_json(history[8], token, history[5], history[6]) + if ( + restored.task.id != history[2] + or restored.attempt.id != history[3] + or restored.attempt.claim_generation != history[4] + or restored.attempt.lease_expires_at != history[6] + or restored.authorization.expires_at != history[7] + ): + raise StateError("active_claim_snapshot_mismatch") + if history is active_row: + active = restored + claims_by_attempt[restored.attempt.id] = restored + claim_rows_by_attempt[restored.attempt.id] = history + if active_row is not None: + if pending_claims or session is None or active_row[0] != session[0]: + raise StateError("claim_state_conflict") + + renew_rows = connection.execute( + """SELECT task_id,session_id,attempt_id,claim_generation,expected_lease_expires_at, + authorization_expires_at,claim_token_cipher,status,response_json,response_digest FROM renew_requests + WHERE profile_id=? ORDER BY rowid""", + (profile_id,), + ).fetchall() + pending_renew_rows = [row for row in renew_rows if row[7] == _PENDING] + if len(pending_renew_rows) > 1: + raise StateError("renew_state_conflict") + lease_by_attempt = {attempt_id: row[5] for attempt_id, row in claim_rows_by_attempt.items()} + sealed_attempts: set[str] = set() + for renew in renew_rows: + owning_claim = claims_by_attempt.get(renew[2]) + owning_row = claim_rows_by_attempt.get(renew[2]) + if owning_claim is None or owning_row is None or renew[2] in sealed_attempts: + raise StateError("renew_claim_history_mismatch") + renew_token = self._unprotect_token( + bytes(renew[6]), purpose=_claim_token_purpose(profile_id, renew[2]) + ) + if ( + (renew[0], renew[1], renew[2], renew[3], renew[4], renew[5]) + != ( + owning_claim.task.id, + owning_row[0], + owning_claim.attempt.id, + owning_claim.attempt.claim_generation, + lease_by_attempt[renew[2]], + owning_claim.authorization.expires_at, + ) + or renew_token.value != owning_claim.attempt.claim_token.value + ): + raise StateError("renew_active_mismatch") + if renew[7] == "SUCCEEDED": + if renew[8] is None or renew[9] != hashlib.sha256(renew[8].encode("utf-8")).hexdigest(): + raise StateError("renew_response_mismatch") + result = _renew_from_json(renew[8]) + if ( + (result.task_id, result.attempt_id, result.claim_generation) + != (renew[0], renew[2], renew[3]) + or rfc3339_z_nanoseconds(result.lease_expires_at) + < _stored_timestamp_ns(renew[4], "invalid_expected_lease_expiry") + or rfc3339_z_nanoseconds(result.lease_expires_at) + > _stored_timestamp_ns(renew[5], "invalid_authorization_expiry") + ): + raise StateError("renew_response_mismatch") + lease_by_attempt[renew[2]] = result.lease_expires_at + elif renew[8] is not None or renew[9] is not None: + raise StateError("unexpected_renew_response") + if renew[7] in _TERMINAL_CLAIM: + sealed_attempts.add(renew[2]) + if renew[7] == _PENDING and (active is None or renew[2] != active.attempt.id): + raise StateError("renew_without_active_claim") + for attempt_id, claim in claims_by_attempt.items(): + if lease_by_attempt[attempt_id] != claim.attempt.lease_expires_at: + raise StateError("renew_lease_history_mismatch") + + markers = { + (row[0], row[1]): (row[2], row[3]) + for row in connection.execute( + "SELECT attempt_id,kind,profile_id,upload_key FROM evidence_slot_markers WHERE profile_id=?", + (profile_id,), + ).fetchall() + } + slots = connection.execute( + """SELECT attempt_id,kind,task_id,upload_key,privacy_tier,sha256,captured_at,file_identity, + byte_size,width_px,height_px,status,receipt_json,receipt_digest FROM evidence_slots WHERE profile_id=?""", + (profile_id,), + ).fetchall() + slot_keys = {(row[0], row[1]) for row in slots} + if set(markers) != slot_keys: + raise StateError("evidence_marker_mismatch") + for slot in slots: + key = (slot[0], slot[1]) + if markers[key] != (profile_id, slot[3]): + raise StateError("evidence_marker_mismatch") + owning_claim = claims_by_attempt.get(slot[0]) + if owning_claim is None or owning_claim.task.id != slot[2]: + raise StateError("evidence_claim_history_mismatch") + if slot[11] == "PENDING": + if active is None or slot[0] != active.attempt.id: + raise StateError("evidence_without_active_claim") + if slot[11] == "SUCCEEDED": + if slot[12] is None or slot[13] != hashlib.sha256(slot[12].encode("utf-8")).hexdigest(): + raise StateError("evidence_receipt_mismatch") + receipt = _receipt_from_json(slot[12]) + if ( + receipt.task_id != slot[2] + or receipt.attempt_id != slot[0] + or receipt.kind != slot[1] + or receipt.privacy_tier != slot[4] + or receipt.sha256 != slot[5] + or receipt.byte_size != slot[8] + or receipt.width_px != slot[9] + or receipt.height_px != slot[10] + or rfc3339_z_nanoseconds(receipt.captured_at) + != _stored_timestamp_ns(slot[6], "invalid_evidence_captured_at") + ): + raise StateError("evidence_receipt_mismatch") + elif slot[12] is not None or slot[13] is not None: + raise StateError("unexpected_evidence_receipt") + return active + + def _finish_claim_request(self, profile_id: str, request: ClaimRequest, outcome: str) -> None: + with self._transaction() as connection: + self._validate_state_graph(connection, profile_id) + self._require_pending_claim(connection, profile_id, request) + updated = connection.execute( + "UPDATE claim_requests SET status=?,updated_at=? WHERE claim_request_id=? AND status='PENDING'", + (outcome, self._utc_now(), request.claim_request_id), + ) + if updated.rowcount != 1: + raise StateError("claim_request_not_pending") + + @staticmethod + def _require_pending_claim(connection: sqlite3.Connection, profile_id: str, request: ClaimRequest) -> None: + row = connection.execute( + "SELECT profile_id,session_id,status FROM claim_requests WHERE claim_request_id=?", + (request.claim_request_id,), + ).fetchone() + if row is None or tuple(row) != (profile_id, request.session_id, _PENDING): + raise StateError("claim_request_not_pending") + + @staticmethod + def _identity_frozen(connection: sqlite3.Connection, profile_id: str) -> bool: + if connection.execute( + "SELECT 1 FROM active_claims WHERE profile_id=? AND closed_at IS NULL", (profile_id,) + ).fetchone(): + return True + return connection.execute( + "SELECT 1 FROM claim_requests WHERE profile_id=? AND status='PENDING'", (profile_id,) + ).fetchone() is not None + + def _protect_token(self, token: SecretToken, *, purpose: str) -> bytes: + protected: bytes | None = None + try: + protected = self._protector.protect(bytes.fromhex(token.value), purpose=purpose) + except Exception: + pass + if not isinstance(protected, bytes) or not protected: + raise ProtectionError("secret_protection_failed") + return protected + + def _unprotect_token(self, ciphertext: bytes, *, purpose: str) -> SecretToken: + raw: bytes | None = None + try: + raw = self._protector.unprotect(ciphertext, purpose=purpose) + except Exception: + pass + if not isinstance(raw, bytes) or len(raw) != 32: + raise ProtectionError("secret_unprotection_failed") + return SecretToken(raw.hex()) + + def _new_uuid(self) -> str: + return require_uuid4(str(self._uuid_factory()), "invalid_generated_uuid") + + def _utc_now(self) -> str: + raw = self._now() + if raw.utcoffset() is None: + raise StateError("invalid_clock") + current = raw.astimezone(timezone.utc) + return current.isoformat(timespec="microseconds").replace("+00:00", "Z") + + def _connect(self) -> sqlite3.Connection: + connection: sqlite3.Connection | None = None + try: + connection = sqlite3.connect(self.database_path, timeout=5, isolation_level=None) + connection.execute("PRAGMA foreign_keys=ON") + connection.execute("PRAGMA synchronous=FULL") + connection.execute("PRAGMA temp_store=MEMORY") + journal = connection.execute("PRAGMA journal_mode=WAL").fetchone()[0] + if str(journal).lower() != "wal": + connection.close() + raise StateError("localstate_wal_required") + return connection + except StateError: + if connection is not None: + connection.close() + raise + except sqlite3.Error: + if connection is not None: + connection.close() + raise StateError("localstate_connection_failed") from None + + @contextmanager + def _reader(self) -> Iterator[sqlite3.Connection]: + connection = self._connect() + try: + yield connection + finally: + connection.close() + + @contextmanager + def _read_transaction(self) -> Iterator[sqlite3.Connection]: + connection = self._connect() + try: + connection.execute("BEGIN") + yield connection + connection.execute("COMMIT") + except (StateError, ProtectionError, ValidationError): + if connection.in_transaction: + connection.execute("ROLLBACK") + raise + except sqlite3.Error: + if connection.in_transaction: + connection.execute("ROLLBACK") + raise StateError("localstate_read_failed") from None + finally: + connection.close() + + @contextmanager + def _transaction(self) -> Iterator[sqlite3.Connection]: + connection = self._connect() + try: + connection.execute("BEGIN IMMEDIATE") + yield connection + connection.execute("COMMIT") + except (StateError, ProtectionError, ValidationError): + if connection.in_transaction: + connection.execute("ROLLBACK") + raise + except sqlite3.Error: + if connection.in_transaction: + connection.execute("ROLLBACK") + raise StateError("localstate_transaction_failed") from None + except Exception: + if connection.in_transaction: + connection.execute("ROLLBACK") + raise StateError("localstate_operation_failed") from None + finally: + connection.close() + + +def _claim_to_json(claimed: ClaimedTask) -> str: + payload = { + "task": asdict(claimed.task), + "authorization": asdict(claimed.authorization), + "attempt": { + "id": claimed.attempt.id, + "claim_generation": claimed.attempt.claim_generation, + # 这是服务端首次 claim 的不可变租约锚;当前租约只存在独立列并由 renew history 推进。 + "lease_expires_at": claimed.attempt.lease_expires_at, + }, + } + return json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _claim_from_json( + raw: str, + token: SecretToken, + initial_lease_expires_at: str, + current_lease_expires_at: str, +) -> ClaimedTask: + try: + require_rfc3339_z(initial_lease_expires_at, "invalid_initial_lease_expiry") + require_rfc3339_z(current_lease_expires_at, "invalid_lease_expiry") + value = json.loads(raw) + if not isinstance(value, dict) or set(value) != {"task", "authorization", "attempt"}: + raise ValueError + attempt = value["attempt"] + if not isinstance(attempt, dict) or set(attempt) != {"id", "claim_generation", "lease_expires_at"}: + raise ValueError + initial = ClaimedTask( + PurchaseTask.from_wire(value["task"]), + AuthorizationSnapshot.from_wire(value["authorization"]), + AttemptSnapshot(attempt["id"], token, attempt["claim_generation"], attempt["lease_expires_at"]), + ) + if initial.attempt.lease_expires_at != initial_lease_expires_at: + raise ValueError + return ClaimedTask( + initial.task, + initial.authorization, + AttemptSnapshot(initial.attempt.id, token, initial.attempt.claim_generation, current_lease_expires_at), + ) + except (KeyError, TypeError, ValueError, ValidationError, json.JSONDecodeError): + raise StateError("invalid_stored_claim") from None + + +def _stored_timestamp_ns(value: object, reason: str) -> int: + try: + return rfc3339_z_nanoseconds(value, reason) + except ValidationError: + raise StateError(reason) from None + + +def _device_token_purpose(profile_id: str, device_id: str) -> str: + return f"device-token:{profile_id}:{device_id}" + + +def _claim_token_purpose(profile_id: str, attempt_id: str) -> str: + return f"claim-token:{profile_id}:{attempt_id}" +def _receipt_from_json(raw: str | None) -> AssetReceipt: + if raw is None: + raise StateError("missing_evidence_receipt") + try: + return AssetReceipt.from_wire(json.loads(raw)) + except (TypeError, ValueError, ValidationError, json.JSONDecodeError): + raise StateError("invalid_evidence_receipt") from None + + +def _renew_from_json(raw: str) -> RenewResult: + try: + return RenewResult.from_wire(json.loads(raw)) + except (TypeError, ValueError, ValidationError, json.JSONDecodeError): + raise StateError("invalid_renew_response") from None + + +def _read_stable_png(path: Path) -> tuple[bytes, str, int, int, str, int, int]: + try: + if path.suffix.lower() != ".png": + raise StateError("evidence_must_be_png") + before_path = os.lstat(path) + if not stat.S_ISREG(before_path.st_mode) or _is_reparse(before_path): + raise StateError("evidence_file_not_regular") + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(path, flags) + with os.fdopen(descriptor, "rb", closefd=True) as stream: + before = os.fstat(stream.fileno()) + if not stat.S_ISREG(before.st_mode) or not os.path.samestat(before_path, before): + raise StateError("evidence_file_changed") + if before.st_size <= 0 or before.st_size > 10 * 1024 * 1024: + raise StateError("evidence_size_invalid") + content = stream.read(10 * 1024 * 1024 + 1) + after = os.fstat(stream.fileno()) + after_path = os.lstat(path) + if ( + len(content) != before.st_size + or not os.path.samestat(before, after) + or not os.path.samestat(before, after_path) + or _is_reparse(after_path) + or before.st_size != after.st_size + or before.st_mtime_ns != after.st_mtime_ns + or before.st_ino != after.st_ino + ): + raise StateError("evidence_changed_during_read") + if len(content) < 24 or not content.startswith(b"\x89PNG\r\n\x1a\n") or content[12:16] != b"IHDR": + raise StateError("evidence_png_invalid") + width = int.from_bytes(content[16:20], "big") + height = int.from_bytes(content[20:24], "big") + if width <= 0 or height <= 0 or width > 8192 or height > 8192 or width * height > 16_777_216: + raise StateError("evidence_dimensions_invalid") + identity = f"{before.st_dev}:{before.st_ino}" + digest = hashlib.sha256(content).hexdigest() + return content, identity, before.st_size, before.st_mtime_ns, digest, width, height + except StateError: + raise + except OSError: + pass + # 离开 except 后再创建固定错误,确保 OSError.filename 不可从 __context__ 追溯。 + raise StateError("evidence_file_unavailable") + + +def _is_reparse(file_stat: os.stat_result) -> bool: + attributes = getattr(file_stat, "st_file_attributes", 0) + reparse_flag = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400) + return stat.S_ISLNK(file_stat.st_mode) or bool(attributes & reparse_flag) diff --git a/client/src/cmbuyer_client/logging_policy.py b/client/src/cmbuyer_client/logging_policy.py index 1d1512d..5901cef 100644 --- a/client/src/cmbuyer_client/logging_policy.py +++ b/client/src/cmbuyer_client/logging_policy.py @@ -23,6 +23,8 @@ _KEY_VALUE_PATTERN = re.compile( flags=re.IGNORECASE, ) _PHONE_PATTERN = re.compile(r"(? str: @@ -31,7 +33,9 @@ def redact_text(message: str) -> str: def replace_key_value(match: re.Match[str]) -> str: return f"{match.group('key')}{match.group('separator')}{REDACTED}" - redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, message) + redacted = _BEARER_PATTERN.sub("Bearer " + REDACTED, message) + redacted = _KEY_VALUE_PATTERN.sub(replace_key_value, redacted) + redacted = _BARE_TOKEN_PATTERN.sub(REDACTED, redacted) return _PHONE_PATTERN.sub(REDACTED, redacted) @@ -47,6 +51,16 @@ class SensitiveDataFilter(logging.Filter): return True +class RedactingFormatter(logging.Formatter): + """再次处理完整格式化文本,覆盖异常 traceback 中的敏感值。""" + + def format(self, record: logging.LogRecord) -> str: + return redact_text(super().format(record)) + + def formatException(self, exc_info: tuple[type[BaseException], BaseException, object]) -> str: + return redact_text(super().formatException(exc_info)) + + def configure_application_logger(paths: RuntimePaths) -> logging.Logger: """配置唯一的 UTF-8 文件日志,并确保其先经过脱敏过滤。""" @@ -61,6 +75,6 @@ def configure_application_logger(paths: RuntimePaths) -> logging.Logger: handler = logging.FileHandler(Path(paths.logs) / "client.log", encoding="utf-8") handler.addFilter(SensitiveDataFilter()) - handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s")) + handler.setFormatter(RedactingFormatter("%(asctime)s %(levelname)s %(message)s")) logger.addHandler(handler) return logger diff --git a/client/src/cmbuyer_client/remote/__init__.py b/client/src/cmbuyer_client/remote/__init__.py new file mode 100644 index 0000000..1f08d1c --- /dev/null +++ b/client/src/cmbuyer_client/remote/__init__.py @@ -0,0 +1,7 @@ +"""只连接固定本机采购服务的 HTTP 适配器。""" + +from .evidence_sink import HttpEvidenceSink +from .http_transport import HttpTransport, LOOPBACK_SERVICE_URL +from .task_source import HttpTaskSource + +__all__ = ["HttpEvidenceSink", "HttpTaskSource", "HttpTransport", "LOOPBACK_SERVICE_URL"] diff --git a/client/src/cmbuyer_client/remote/evidence_sink.py b/client/src/cmbuyer_client/remote/evidence_sink.py new file mode 100644 index 0000000..ed89078 --- /dev/null +++ b/client/src/cmbuyer_client/remote/evidence_sink.py @@ -0,0 +1,84 @@ +"""仅上传调用方显式提供的单个 PNG 的窄 EvidenceSink。""" + +from __future__ import annotations + +from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError, ValidationError +from cmbuyer_client.core.models import AssetReceipt, DeviceCredentials, EvidenceUpload +from cmbuyer_client.core.validation import rfc3339_z_nanoseconds + +from .http_transport import HttpTransport +from .wire import SMALL_RESPONSE_LIMIT, classify_bodyless_error, common_headers, parse_json_response + + +class HttpEvidenceSink: + def __init__(self, transport: HttpTransport) -> None: + self._transport = transport + + def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: + boundary = "cmbuyer-" + evidence.upload_key.replace("-", "") + marker = ("--" + boundary).encode("ascii") + if marker in evidence.content: + raise ProtocolRemoteError("multipart_boundary_collision") + body = _multipart_body(boundary, evidence) + response = self._transport.request( + "POST", + f"/api/v1/tasks/{evidence.task_id}/evidence", + common_headers( + credentials.device_id, + credentials.token.value, + "multipart/form-data; boundary=" + boundary, + ), + body, + response_limit=SMALL_RESPONSE_LIMIT, + ) + if response.status not in (200, 201): + classify_bodyless_error(response) + try: + receipt = AssetReceipt.from_wire(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT)) + except ValidationError as error: + raise AmbiguousRemoteError("invalid_evidence_success_response") from error + if ( + receipt.task_id != evidence.task_id + or receipt.attempt_id != evidence.attempt_id + or receipt.kind != evidence.kind + or receipt.privacy_tier != evidence.privacy_tier + or receipt.sha256 != evidence.sha256 + or receipt.byte_size != len(evidence.content) + or receipt.width_px != evidence.width_px + or receipt.height_px != evidence.height_px + or rfc3339_z_nanoseconds(receipt.captured_at) != rfc3339_z_nanoseconds(evidence.captured_at) + ): + raise AmbiguousRemoteError("evidence_response_mismatch") + return receipt + + +def _multipart_body(boundary: str, evidence: EvidenceUpload) -> bytes: + chunks: list[bytes] = [] + + def add_field(name: str, value: str) -> None: + chunks.extend( + ( + f"--{boundary}\r\n".encode("ascii"), + f'Content-Disposition: form-data; name="{name}"\r\n\r\n'.encode("ascii"), + value.encode("utf-8"), + b"\r\n", + ) + ) + + add_field("upload_key", evidence.upload_key) + add_field("attempt_id", evidence.attempt_id) + add_field("kind", evidence.kind) + add_field("privacy_tier", evidence.privacy_tier) + add_field("sha256", evidence.sha256) + add_field("captured_at", evidence.captured_at) + chunks.extend( + ( + f"--{boundary}\r\n".encode("ascii"), + b'Content-Disposition: form-data; name="file"; filename="evidence.png"\r\n', + b"Content-Type: image/png\r\n\r\n", + evidence.content, + b"\r\n", + f"--{boundary}--\r\n".encode("ascii"), + ) + ) + return b"".join(chunks) diff --git a/client/src/cmbuyer_client/remote/http_transport.py b/client/src/cmbuyer_client/remote/http_transport.py new file mode 100644 index 0000000..0b59a26 --- /dev/null +++ b/client/src/cmbuyer_client/remote/http_transport.py @@ -0,0 +1,116 @@ +"""无代理、无重定向、无隐藏重试的 localhost HTTP transport。""" + +from __future__ import annotations + +from dataclasses import dataclass +import http.client +import re +from typing import Callable, Iterable + +from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError + + +LOOPBACK_SERVICE_URL = "http://127.0.0.1:8080" +_HOST = "127.0.0.1" +_PORT = 8080 + + +@dataclass(frozen=True) +class HttpResponse: + status: int + headers: tuple[tuple[str, str], ...] + body: bytes + + def header_values(self, name: str) -> tuple[str, ...]: + wanted = name.lower() + return tuple(value for key, value in self.headers if key.lower() == wanted) + + +class HttpTransport: + """每次调用只创建一个直连 TCP 请求;重试只能由持久化恢复层决定。""" + + def __init__( + self, + service_url: str = LOOPBACK_SERVICE_URL, + *, + timeout_seconds: int = 10, + connection_factory: Callable[..., http.client.HTTPConnection] = http.client.HTTPConnection, + ) -> None: + if service_url != LOOPBACK_SERVICE_URL: + raise ProtocolRemoteError("service_url_not_allowed") + if type(timeout_seconds) is not int or not 1 <= timeout_seconds <= 120: + raise ProtocolRemoteError("invalid_http_timeout") + self._timeout_seconds = timeout_seconds + self._connection_factory = connection_factory + + def request( + self, + method: str, + path: str, + headers: Iterable[tuple[str, str]], + body: bytes, + *, + response_limit: int, + ) -> HttpResponse: + if method != "POST" or not path.startswith("/api/v1/") or "?" in path or "#" in path: + raise ProtocolRemoteError("invalid_http_target") + if not isinstance(body, bytes) or type(response_limit) is not int or response_limit <= 0: + raise ProtocolRemoteError("invalid_http_request") + header_items = tuple(headers) + normalized: dict[str, str] = {} + for key, value in header_items: + lowered = key.lower() + if lowered in normalized or "\r" in key or "\n" in key or "\r" in value or "\n" in value: + raise ProtocolRemoteError("invalid_http_headers") + normalized[lowered] = value + + connection: http.client.HTTPConnection | None = None + result: HttpResponse | None = None + failure: str | None = None + try: + connection = self._connection_factory(_HOST, _PORT, timeout=self._timeout_seconds) + connection.request(method, path, body=body, headers={key: value for key, value in header_items}) + response = connection.getresponse() + response_headers = tuple(response.getheaders()) + content_lengths = tuple(value for key, value in response_headers if key.lower() == "content-length") + transfer_encodings = tuple(value for key, value in response_headers if key.lower() == "transfer-encoding") + if len(content_lengths) > 1: + raise AmbiguousRemoteError("invalid_content_length") + if content_lengths and transfer_encodings: + raise AmbiguousRemoteError("ambiguous_response_framing") + if len(transfer_encodings) > 1 or ( + transfer_encodings and transfer_encodings[0].lower() != "chunked" + ): + raise AmbiguousRemoteError("invalid_transfer_encoding") + declared = content_lengths[0] if content_lengths else None + declared_length: int | None = None + if declared is not None: + if re.fullmatch(r"[0-9]+", declared, flags=re.ASCII) is None: + raise AmbiguousRemoteError("invalid_content_length") + if len(declared) > 10: + raise AmbiguousRemoteError("response_too_large") + declared_length = int(declared) + if declared_length > response_limit: + raise AmbiguousRemoteError("response_too_large") + response_body = response.read(response_limit + 1) + if len(response_body) > response_limit: + raise AmbiguousRemoteError("response_too_large") + if declared_length is not None and len(response_body) != declared_length: + raise AmbiguousRemoteError("truncated_response") + result = HttpResponse(response.status, response_headers, response_body) + except AmbiguousRemoteError as error: + failure = error.reason + except (OSError, TimeoutError, http.client.HTTPException): + failure = "http_result_unknown" + finally: + if connection is not None: + try: + connection.close() + except OSError: + if result is None: + failure = "http_result_unknown" + if failure is not None: + raise AmbiguousRemoteError(failure) + if result is None: + raise AmbiguousRemoteError("http_result_unknown") + return result diff --git a/client/src/cmbuyer_client/remote/task_source.py b/client/src/cmbuyer_client/remote/task_source.py new file mode 100644 index 0000000..bcc1983 --- /dev/null +++ b/client/src/cmbuyer_client/remote/task_source.py @@ -0,0 +1,75 @@ +"""领取与续租的固定 localhost HTTP 适配器。""" + +from __future__ import annotations + +from cmbuyer_client.core.errors import AmbiguousRemoteError, ValidationError +from cmbuyer_client.core.models import ClaimRequest, ClaimedTask, DeviceCredentials, RenewRequest, RenewResult +from cmbuyer_client.core.validation import rfc3339_z_nanoseconds + +from .http_transport import HttpTransport +from .wire import ( + JSON_RESPONSE_LIMIT, + SMALL_RESPONSE_LIMIT, + classify_json_error, + common_headers, + encode_json, + parse_json_response, +) + + +class HttpTaskSource: + def __init__(self, transport: HttpTransport) -> None: + self._transport = transport + + def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: + body = encode_json(request.to_wire()) + response = self._transport.request( + "POST", + "/api/v1/tasks/claim-next", + common_headers(credentials.device_id, credentials.token.value, "application/json"), + body, + response_limit=JSON_RESPONSE_LIMIT, + ) + if response.status == 204: + if response.body or response.header_values("Content-Encoding"): + raise AmbiguousRemoteError("invalid_empty_claim_response") + return None + if response.status != 200: + classify_json_error( + response, + allowed_409=frozenset(("idempotency_conflict", "claim_requires_manual")), + ) + try: + claimed = ClaimedTask.from_wire(parse_json_response(response, maximum=JSON_RESPONSE_LIMIT)) + except ValidationError as error: + raise AmbiguousRemoteError("invalid_claim_success_response") from error + if rfc3339_z_nanoseconds(claimed.attempt.lease_expires_at) > rfc3339_z_nanoseconds(claimed.authorization.expires_at): + raise AmbiguousRemoteError("invalid_claim_lease") + return claimed + + def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: + response = self._transport.request( + "POST", + f"/api/v1/tasks/{request.task_id}/lease/renew", + common_headers(credentials.device_id, credentials.token.value, "application/json"), + encode_json(request.to_wire()), + response_limit=SMALL_RESPONSE_LIMIT, + ) + if response.status != 200: + classify_json_error( + response, + allowed_409=frozenset(("idempotency_conflict", "claim_not_current")), + ) + try: + result = RenewResult.from_wire(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT)) + except ValidationError as error: + raise AmbiguousRemoteError("invalid_renew_success_response") from error + if ( + result.task_id != request.task_id + or result.attempt_id != request.attempt_id + or result.claim_generation != request.claim_generation + or rfc3339_z_nanoseconds(result.lease_expires_at) < rfc3339_z_nanoseconds(request.expected_lease_expires_at) + or rfc3339_z_nanoseconds(result.lease_expires_at) > rfc3339_z_nanoseconds(request.authorization_expires_at) + ): + raise AmbiguousRemoteError("renew_response_mismatch") + return result diff --git a/client/src/cmbuyer_client/remote/wire.py b/client/src/cmbuyer_client/remote/wire.py new file mode 100644 index 0000000..4ddc7a6 --- /dev/null +++ b/client/src/cmbuyer_client/remote/wire.py @@ -0,0 +1,106 @@ +"""T-302/T-204 固定 HTTP wire 的编码、解码与错误分类。""" + +from __future__ import annotations + +import json +from typing import Any, Mapping + +from cmbuyer_client.core.errors import ( + AmbiguousRemoteError, + CredentialRemoteError, + ManualRemoteError, + ProtocolRemoteError, + ValidationError, +) +from cmbuyer_client.core.validation import require_exact_fields, strict_json_loads + +from .http_transport import HttpResponse + + +JSON_REQUEST_LIMIT = 4096 +JSON_RESPONSE_LIMIT = 32 * 1024 +SMALL_RESPONSE_LIMIT = 8 * 1024 +JSON_CONTENT_TYPES = frozenset(("application/json", "application/json; charset=utf-8")) + + +def encode_json(value: Mapping[str, object]) -> bytes: + body = json.dumps(value, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8") + if len(body) > JSON_REQUEST_LIMIT: + raise ProtocolRemoteError("request_too_large") + return body + + +def common_headers(device_id: str, token: str, content_type: str) -> tuple[tuple[str, str], ...]: + return ( + ("Authorization", "Bearer " + token), + ("X-CMBuyer-Device-ID", device_id), + ("Accept", "application/json"), + ("Content-Type", content_type), + ) + + +def parse_json_response(response: HttpResponse, *, maximum: int) -> object: + encodings = response.header_values("Content-Encoding") + types = response.header_values("Content-Type") + if encodings or len(types) != 1 or types[0].lower() not in JSON_CONTENT_TYPES: + raise ValidationError("invalid_response_content_type") + return strict_json_loads(response.body, maximum=maximum) + + +def require_empty_response(response: HttpResponse) -> None: + if response.body or response.header_values("Content-Encoding"): + raise ProtocolRemoteError("unexpected_error_body") + + +def classify_json_error(response: HttpResponse, *, allowed_409: frozenset[str]) -> None: + """抛出错误,不返回。调用方只在非成功状态使用。""" + + if 200 <= response.status <= 299: + # 服务端可能已提交幂等事实;未知 2xx 绝不能终结本地槽或换 key。 + raise AmbiguousRemoteError("unknown_success_status") + if response.status == 401: + require_empty_response(response) + raise CredentialRemoteError("device_credential_rejected") + if response.status == 503 or 500 <= response.status <= 599: + # 5xx 无法证明服务端是否在提交响应前完成事务。 + raise AmbiguousRemoteError("server_result_unknown") + if response.status == 409: + try: + data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",)) + code = data["error"] + except ValidationError as error: + raise ProtocolRemoteError("invalid_conflict_response") from error + if not isinstance(code, str) or code not in allowed_409: + raise ProtocolRemoteError("unknown_conflict") + raise ManualRemoteError(code) + expected = {400: "invalid_request", 413: "request_too_large", 415: "unsupported_media_type"} + if response.status in expected: + try: + data = require_exact_fields(parse_json_response(response, maximum=SMALL_RESPONSE_LIMIT), ("error",)) + except ValidationError as error: + raise ProtocolRemoteError("invalid_error_response") from error + if data["error"] != expected[response.status]: + raise ProtocolRemoteError("unexpected_error_code") + raise ProtocolRemoteError(expected[response.status]) + if 300 <= response.status <= 399: + raise ProtocolRemoteError("redirect_rejected") + raise ProtocolRemoteError("unexpected_http_status") + + +def classify_bodyless_error(response: HttpResponse) -> None: + if 200 <= response.status <= 299: + raise AmbiguousRemoteError("unknown_success_status") + if response.status == 401: + require_empty_response(response) + raise CredentialRemoteError("device_credential_rejected") + if response.status == 503 or 500 <= response.status <= 599: + raise AmbiguousRemoteError("server_result_unknown") + if response.status == 409: + require_empty_response(response) + raise ManualRemoteError("evidence_conflict") + if response.status in (400, 403, 413, 415): + require_empty_response(response) + raise ProtocolRemoteError("evidence_request_rejected") + if 300 <= response.status <= 399: + raise ProtocolRemoteError("redirect_rejected") + raise ProtocolRemoteError("unexpected_http_status") diff --git a/client/src/cmbuyer_client/runtime.py b/client/src/cmbuyer_client/runtime.py index 4a761c6..0dc1d0d 100644 --- a/client/src/cmbuyer_client/runtime.py +++ b/client/src/cmbuyer_client/runtime.py @@ -5,6 +5,7 @@ from __future__ import annotations from dataclasses import dataclass import os from pathlib import Path +from typing import Any, Callable @dataclass(frozen=True) @@ -17,21 +18,34 @@ class RuntimePaths: root: Path logs: Path artifacts: Path + state: Path + database: Path @classmethod def from_root(cls, root: Path) -> "RuntimePaths": - resolved_root = root.expanduser() + # 路径在进程启动时一次性固化;之后 cwd 改变不能打开第二套数据库或绕过原 mutex。 + resolved_root = root.expanduser().resolve(strict=False) + state = resolved_root / "state" return cls( root=resolved_root, logs=resolved_root / "logs", artifacts=resolved_root / "artifacts", + state=state, + database=state / "client-state.sqlite3", ) @classmethod def default(cls) -> "RuntimePaths": local_app_data = os.environ.get("LOCALAPPDATA") if local_app_data: - return cls.from_root(Path(local_app_data) / "cmbuyer") + local_root = Path(local_app_data).expanduser() + if not local_root.is_absolute(): + raise RuntimeError("local_app_data_must_be_absolute") + return cls.from_root(local_root / "cmbuyer") + + if os.name == "nt": + # Windows 上回退到 home 会悄悄创建第二套状态库并绕开同一 mutex,必须失败闭合。 + raise RuntimeError("local_app_data_required") return cls.from_root(Path.home() / ".local" / "share" / "cmbuyer") @@ -40,3 +54,49 @@ class RuntimePaths: self.logs.mkdir(parents=True, exist_ok=True) self.artifacts.mkdir(parents=True, exist_ok=True) + self.state.mkdir(parents=True, exist_ok=True) + + +@dataclass +class LocalStateRuntime: + """持有 named mutex 与本地状态库,保证 mutex 总是先取得。""" + + paths: RuntimePaths + mutex: Any + store: Any + + @classmethod + def open( + cls, + paths: RuntimePaths | None = None, + *, + mutex_factory: Callable[[Path], Any] | None = None, + protector_factory: Callable[[], Any] | None = None, + store_factory: Callable[[Path, Any], Any] | None = None, + ) -> "LocalStateRuntime": + from .localstate.protection import DpapiProtector + from .localstate.single_instance import NamedMutex + from .localstate.store import LocalStateStore + + selected = paths or RuntimePaths.default() + selected.ensure_exists() + make_mutex = mutex_factory or NamedMutex + make_protector = protector_factory or DpapiProtector + make_store = store_factory or LocalStateStore + mutex = make_mutex(selected.database) + try: + protector = make_protector() + store = make_store(selected.database, protector) + except Exception: + mutex.close() + raise + return cls(selected, mutex, store) + + def close(self) -> None: + self.mutex.close() + + def __enter__(self) -> "LocalStateRuntime": + return self + + def __exit__(self, exc_type: object, exc: object, traceback: object) -> None: + self.close() diff --git a/client/tests/core/__init__.py b/client/tests/core/__init__.py new file mode 100644 index 0000000..ef6a87f --- /dev/null +++ b/client/tests/core/__init__.py @@ -0,0 +1 @@ +"""core tests。""" diff --git a/client/tests/core/test_models.py b/client/tests/core/test_models.py new file mode 100644 index 0000000..9c10bb7 --- /dev/null +++ b/client/tests/core/test_models.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +import json +import unittest + +from cmbuyer_client.core.errors import ValidationError +from cmbuyer_client.core.models import ClaimedTask, SecretToken +from cmbuyer_client.core.validation import rfc3339_z_nanoseconds, strict_json_loads + + +TASK_ID = "13c9f507-7473-4fa6-8d71-8786c34c6301" +AUTH_ID = "73c9f507-7473-4fa6-8d71-8786c34c6301" +ATTEMPT_ID = "53c9f507-7473-4fa6-8d71-8786c34c6301" +TOKEN = "0123456789abcdef" * 4 + + +def claim_wire() -> dict[str, object]: + return { + "task": { + "id": TASK_ID, + "version": 3, + "title": "纯棉短袖", + "product_url": "https://mobile.yangkeduo.com/goods.html?goods_id=937122477375", + "goods_id": "937122477375", + "sku_color": "黑色CHA(纯棉)", + "sku_size": "M(建议100-115)", + "quantity": 2, + "max_total_price": "30.00", + }, + "authorization": {"id": AUTH_ID, "task_version": 2, "expires_at": "2026-08-04T10:00:00Z"}, + "attempt": { + "id": ATTEMPT_ID, + "claim_token": TOKEN, + "claim_generation": 1, + "lease_expires_at": "2026-08-04T09:05:00Z", + }, + } + + +class CoreModelsTests(unittest.TestCase): + def test_claim_wire_round_trip_and_secret_repr(self) -> None: + claimed = ClaimedTask.from_wire(claim_wire()) + self.assertEqual(claimed.task.quantity, 2) + self.assertNotIn(TOKEN, repr(claimed)) + self.assertNotIn(TOKEN, repr(SecretToken(TOKEN))) + + def test_rejects_bool_float_wrong_url_and_version_drift(self) -> None: + mutations = [] + for mutate in ( + lambda value: value["task"].__setitem__("quantity", True), + lambda value: value["task"].__setitem__("max_total_price", "30.0"), + lambda value: value["task"].__setitem__("max_total_price", "0.00"), + lambda value: value["task"].__setitem__("product_url", "https://example.invalid/"), + lambda value: value["task"].__setitem__("version", 2), + ): + value = claim_wire() + mutate(value) + mutations.append(value) + for value in mutations: + with self.subTest(value=value), self.assertRaises(ValidationError): + ClaimedTask.from_wire(value) + + def test_strict_json_rejects_nested_duplicates_float_nan_bom_and_utf8(self) -> None: + bad_values = ( + b'{"task":{"id":1,"id":2}}', + b'{"value":1.0}', + b'{"value":NaN}', + b'\xef\xbb\xbf{}', + b'\xff', + ('{"value":' + "9" * 5000 + '}').encode(), + ) + for raw in bad_values: + with self.subTest(raw=raw), self.assertRaises(ValidationError): + strict_json_loads(raw, maximum=1024) + self.assertEqual(strict_json_loads(json.dumps({"value": 1}).encode(), maximum=1024), {"value": 1}) + + def test_rfc3339_nano_comparison_preserves_all_fraction_digits(self) -> None: + equal = ( + "2026-08-04T09:01:00.1Z", + "2026-08-04T09:01:00.100000Z", + "2026-08-04T09:01:00.100000000Z", + ) + self.assertEqual(len({rfc3339_z_nanoseconds(value) for value in equal}), 1) + ordered = ( + "2026-08-04T09:01:00Z", + "2026-08-04T09:01:00.000001Z", + "2026-08-04T09:01:00.0000011Z", + "2026-08-04T09:01:00.000001101Z", + "2026-08-04T09:01:01Z", + ) + self.assertEqual([rfc3339_z_nanoseconds(value) for value in ordered], sorted(rfc3339_z_nanoseconds(value) for value in ordered)) + + def test_money_accepts_positive_subunit_but_rejects_zero_and_noncanonical_forms(self) -> None: + value = claim_wire() + value["task"]["max_total_price"] = "0.01" + self.assertEqual(ClaimedTask.from_wire(value).task.max_total_price, "0.01") + for invalid in ("0.00", "00.01", "1.0", "1.000", "1", 1.0, "1.12", "1.٠٠", "12.00"): + with self.subTest(invalid=invalid), self.assertRaises(ValidationError): + changed = claim_wire() + changed["task"]["max_total_price"] = invalid + ClaimedTask.from_wire(changed) + + wide = claim_wire() + wide_goods = "1" * 33 + wide["task"].update( + goods_id=wide_goods, + product_url="https://mobile.yangkeduo.com/goods.html?goods_id=" + wide_goods, + max_total_price="1" * 31 + ".00", + quantity=2_147_483_648, + ) + self.assertEqual(ClaimedTask.from_wire(wide).task.quantity, 2_147_483_648) + for invalid_goods in ("123", "1٢3"): + changed = claim_wire() + changed["task"]["goods_id"] = invalid_goods + changed["task"]["product_url"] = "https://mobile.yangkeduo.com/goods.html?goods_id=" + invalid_goods + with self.subTest(invalid_goods=invalid_goods), self.assertRaises(ValidationError): + ClaimedTask.from_wire(changed) + too_large = claim_wire() + too_large["task"]["quantity"] = 9_223_372_036_854_775_808 + with self.assertRaises(ValidationError): + ClaimedTask.from_wire(too_large) + + def test_wire_strings_reject_lone_surrogates_but_accept_valid_pair(self) -> None: + for escaped in (r'"\ud800"', r'"\udc00"'): + value = claim_wire() + value["task"]["title"] = json.loads(escaped) + with self.subTest(escaped=escaped), self.assertRaises(ValidationError): + ClaimedTask.from_wire(value) + value = claim_wire() + value["task"]["title"] = json.loads(r'"\ud83d\ude00"') + self.assertEqual(ClaimedTask.from_wire(value).task.title, "😀") + + def test_title_rejects_ascii_and_unicode_whitespace_only(self) -> None: + for title in ("", " \t\r\n", "\u3000", " \u3000\t"): + value = claim_wire() + value["task"]["title"] = title + with self.subTest(title=repr(title)), self.assertRaises(ValidationError): + ClaimedTask.from_wire(value) diff --git a/client/tests/localstate/__init__.py b/client/tests/localstate/__init__.py new file mode 100644 index 0000000..cabce14 --- /dev/null +++ b/client/tests/localstate/__init__.py @@ -0,0 +1 @@ +"""localstate tests。""" diff --git a/client/tests/localstate/test_boundaries.py b/client/tests/localstate/test_boundaries.py new file mode 100644 index 0000000..11f1fdb --- /dev/null +++ b/client/tests/localstate/test_boundaries.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import ast +from pathlib import Path +import unittest + + +SRC = Path(__file__).resolve().parents[2] / "src" / "cmbuyer_client" +SCOPED = tuple((SRC / name) for name in ("core", "remote", "localstate")) + + +class StaticBoundaryTests(unittest.TestCase): + def test_scoped_modules_do_not_import_device_pdd_or_unapproved_capabilities(self) -> None: + forbidden_modules = ("cmbuyer_client.device", "cmbuyer_client.pdd") + forbidden_text = ( + "ResultSink", + "/events", + "/fail", + "/submission-fence", + "/result", + "click_permitted", + ) + for directory in SCOPED: + for path in directory.glob("*.py"): + text = path.read_text(encoding="utf-8") + tree = ast.parse(text) + imports = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imports.extend(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imports.append(node.module) + for module in forbidden_modules: + self.assertFalse(any(name.startswith(module) for name in imports), (path, module)) + for value in forbidden_text: + self.assertNotIn(value, text, (path, value)) diff --git a/client/tests/localstate/test_facade.py b/client/tests/localstate/test_facade.py new file mode 100644 index 0000000..69e1890 --- /dev/null +++ b/client/tests/localstate/test_facade.py @@ -0,0 +1,321 @@ +from __future__ import annotations + +import base64 +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import sqlite3 +import tempfile +import unittest +from unittest import mock + +from cmbuyer_client.core.errors import AmbiguousRemoteError, CredentialRemoteError, ManualRemoteError, StateError +from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, RenewResult, ScreenshotAsset, SecretToken +from cmbuyer_client.localstate.facade import DurableClientGateway +from cmbuyer_client.localstate.models import ProfileSettings +from cmbuyer_client.localstate.store import LocalStateStore +from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink +from cmbuyer_client.remote.http_transport import HttpResponse +from cmbuyer_client.remote.task_source import HttpTaskSource +from tests.core.test_models import ATTEMPT_ID, TASK_ID, claim_wire +from tests.localstate.test_store import DEVICE_TOKEN, FakeProtector, PROFILE +from tests.remote.test_task_source import DEVICE_ID, FakeTransport, response + + +PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" +) + + +class InspectingSource: + def __init__(self, store: LocalStateStore, profile_id: str) -> None: + self.store = store + self.profile_id = profile_id + self.calls = 0 + self.mode = "success" + self.request_ids: list[str] = [] + + def claim_next(self, credentials, request): + self.calls += 1 + self.request_ids.append(request.claim_request_id) + # HTTP 适配器被调用时,幂等请求必须已经 durable。 + self.assert_pending(request.claim_request_id) + if self.mode == "ambiguous": + raise AmbiguousRemoteError("http_result_unknown") + if self.mode == "manual": + raise ManualRemoteError("claim_requires_manual") + return ClaimedTask.from_wire(claim_wire()) + + def renew(self, credentials, request): + raise AssertionError("not used") + + def assert_pending(self, request_id: str) -> None: + snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id) + if snapshot.pending_claim is None or snapshot.pending_claim.claim_request_id != request_id: + raise AssertionError("HTTP happened before durable prepare") + + +class InspectingSink: + def __init__(self, store: LocalStateStore, profile_id: str) -> None: + self.store = store + self.profile_id = profile_id + self.calls = 0 + + def upload(self, credentials, upload): + self.calls += 1 + snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id) + if not snapshot.pending_evidence or snapshot.pending_evidence[0].upload_key != upload.upload_key: + raise AssertionError("HTTP happened before durable evidence slot") + return AssetReceipt( + "63c9f507-7473-4fa6-8d71-8786c34c6301", + upload.task_id, + upload.attempt_id, + upload.kind, + upload.privacy_tier, + upload.sha256, + len(upload.content), + "image/png", + 1, + 1, + upload.captured_at, + ) + + +class InspectingRenewSource: + def __init__(self, store: LocalStateStore, profile_id: str) -> None: + self.store = store + self.profile_id = profile_id + self.calls = 0 + + def claim_next(self, credentials, request): + raise AssertionError("not used") + + def renew(self, credentials, request): + self.calls += 1 + snapshot = LocalStateStore(self.store.database_path, FakeProtector()).recovery_snapshot(self.profile_id) + if snapshot.pending_renew is None or snapshot.pending_renew.renew_request_id != request.renew_request_id: + raise AssertionError("HTTP happened before durable renew") + return RenewResult(request.task_id, request.attempt_id, request.claim_generation, request.expected_lease_expires_at) + + +class DurableClientGatewayTests(unittest.TestCase): + def setUp(self) -> None: + self.directory = tempfile.TemporaryDirectory() + self.database = Path(self.directory.name) / "client-state.sqlite3" + self.store = LocalStateStore( + self.database, + FakeProtector(), + now=lambda: datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc), + ) + profile = ProfileSettings( + PROFILE, + "http://127.0.0.1:8080", + DEVICE_ID, + "D:/Portable/adb/adb.exe", + "192.168.0.173:5555", + "wifi", + ) + self.store.save_profile(profile, SecretToken(DEVICE_TOKEN)) + self.store.start_or_resume_polling(PROFILE) + self.source = InspectingSource(self.store, PROFILE) + self.sink = InspectingSink(self.store, PROFILE) + self.gateway = DurableClientGateway(self.store, self.source, self.sink) + + def tearDown(self) -> None: + self.directory.cleanup() + + def test_claim_unknown_replays_same_durable_key_then_commits(self) -> None: + self.source.mode = "ambiguous" + with self.assertRaises(AmbiguousRemoteError): + self.gateway.claim_next(PROFILE) + self.source.mode = "success" + claimed = self.gateway.claim_next(PROFILE) + self.assertEqual(claimed.task.id, TASK_ID) + self.assertEqual(self.source.request_ids[0], self.source.request_ids[1]) + self.assertIsNotNone(self.store.active_claim(PROFILE)) + + def test_unknown_claim_2xx_keeps_pending_key_for_real_adapter_replay(self) -> None: + transport = FakeTransport(response(201, claim_wire())) + gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink) + with self.assertRaises(AmbiguousRemoteError): + gateway.claim_next(PROFILE) + pending = self.store.recovery_snapshot(PROFILE).pending_claim + self.assertIsNotNone(pending) + transport.response = response(200, claim_wire()) + claimed = gateway.claim_next(PROFILE) + self.assertEqual(claimed.task.id, TASK_ID) + sent = [call[3] for call in transport.calls] + self.assertEqual(sent[0], sent[1]) + + def test_claim_401_allows_token_repair_and_same_key_replay(self) -> None: + transport = FakeTransport(HttpResponse(401, (), b"")) + gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink) + with self.assertRaises(CredentialRemoteError): + gateway.claim_next(PROFILE) + request_id = self.store.recovery_snapshot(PROFILE).pending_claim.claim_request_id + self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64)) + transport.response = response(200, claim_wire()) + gateway.claim_next(PROFILE) + self.assertEqual(transport.calls[0][3], transport.calls[1][3]) + self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"]) + self.assertEqual(json.loads(transport.calls[1][3])["claim_request_id"], request_id) + + def test_profile_read_sql_failure_after_prepare_is_fixed_error_and_zero_http(self) -> None: + original_connect = self.store._connect + calls = 0 + + def fail_second_connection(): + nonlocal calls + calls += 1 + connection = original_connect() + if calls == 2: + connection.set_authorizer( + lambda action, table, *_: sqlite3.SQLITE_DENY + if action == sqlite3.SQLITE_READ and table == "profiles" + else sqlite3.SQLITE_OK + ) + return connection + + with mock.patch.object(self.store, "_connect", side_effect=fail_second_connection): + with self.assertRaisesRegex(StateError, "localstate_read_failed") as captured: + self.gateway.claim_next(PROFILE) + self.assertEqual(self.source.calls, 0) + self.assertNotIn(str(self.database), repr(captured.exception)) + + def test_renew_is_durable_before_http(self) -> None: + self.gateway.claim_next(PROFILE) + source = InspectingRenewSource(self.store, PROFILE) + gateway = DurableClientGateway(self.store, source, self.sink) + result = gateway.renew(PROFILE) + self.assertEqual(result.attempt_id, ATTEMPT_ID) + self.assertEqual(source.calls, 1) + + def test_unknown_renew_2xx_keeps_pending_payload_for_replay(self) -> None: + self.gateway.claim_next(PROFILE) + payload = { + "task_id": TASK_ID, + "attempt_id": ATTEMPT_ID, + "claim_generation": 1, + "lease_expires_at": "2026-08-04T09:06:00Z", + } + transport = FakeTransport(response(201, payload)) + gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink) + with self.assertRaises(AmbiguousRemoteError): + gateway.renew(PROFILE) + pending = self.store.recovery_snapshot(PROFILE).pending_renew + self.assertIsNotNone(pending) + transport.response = response(200, payload) + gateway.renew(PROFILE) + self.assertEqual(transport.calls[0][3], transport.calls[1][3]) + + def test_renew_401_allows_bearer_repair_without_changing_claim_payload(self) -> None: + self.gateway.claim_next(PROFILE) + payload = { + "task_id": TASK_ID, + "attempt_id": ATTEMPT_ID, + "claim_generation": 1, + "lease_expires_at": "2026-08-04T09:06:00Z", + } + transport = FakeTransport(HttpResponse(401, (), b"")) + gateway = DurableClientGateway(self.store, HttpTaskSource(transport), self.sink) + with self.assertRaises(CredentialRemoteError): + gateway.renew(PROFILE) + self.assertIsNotNone(self.store.recovery_snapshot(PROFILE).pending_renew) + self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64)) + transport.response = response(200, payload) + gateway.renew(PROFILE) + self.assertEqual(transport.calls[0][3], transport.calls[1][3]) + self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"]) + + def test_unknown_evidence_2xx_keeps_pending_multipart_for_replay(self) -> None: + self.gateway.claim_next(PROFILE) + path = Path(self.directory.name) / "unknown.png" + path.write_bytes(PNG) + asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z") + transport = FakeTransport(HttpResponse(202, (), b"")) + gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport)) + with self.assertRaises(AmbiguousRemoteError): + gateway.upload_evidence(PROFILE, asset) + pending = self.store.recovery_snapshot(PROFILE).pending_evidence + self.assertEqual(len(pending), 1) + digest = hashlib.sha256(PNG).hexdigest() + receipt = { + "asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301", + "task_id": TASK_ID, + "attempt_id": ATTEMPT_ID, + "kind": "SKU_PANEL_GATE_1", + "privacy_tier": "INTERNAL_RAW", + "sha256": digest, + "byte_size": len(PNG), + "content_type": "image/png", + "width_px": 1, + "height_px": 1, + "captured_at": "2026-08-04T09:01:00Z", + } + transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode()) + gateway.upload_evidence(PROFILE, asset) + self.assertEqual(transport.calls[0][3], transport.calls[1][3]) + + def test_evidence_401_allows_bearer_repair_with_same_file_and_multipart(self) -> None: + self.gateway.claim_next(PROFILE) + path = Path(self.directory.name) / "credential.png" + path.write_bytes(PNG) + asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z") + transport = FakeTransport(HttpResponse(401, (), b"")) + gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport)) + with self.assertRaises(CredentialRemoteError): + gateway.upload_evidence(PROFILE, asset) + self.assertEqual(len(self.store.recovery_snapshot(PROFILE).pending_evidence), 1) + self.store.save_profile(self.store.load_profile(PROFILE).settings, SecretToken("c" * 64)) + digest = hashlib.sha256(PNG).hexdigest() + receipt = { + "asset_id": "63c9f507-7473-4fa6-8d71-8786c34c6301", + "task_id": TASK_ID, + "attempt_id": ATTEMPT_ID, + "kind": "SKU_PANEL_GATE_1", + "privacy_tier": "INTERNAL_RAW", + "sha256": digest, + "byte_size": len(PNG), + "content_type": "image/png", + "width_px": 1, + "height_px": 1, + "captured_at": "2026-08-04T09:01:00Z", + } + transport.response = HttpResponse(201, (("Content-Type", "application/json"),), json.dumps(receipt).encode()) + gateway.upload_evidence(PROFILE, asset) + self.assertEqual(transport.calls[0][3], transport.calls[1][3]) + self.assertNotEqual(dict(transport.calls[0][2])["Authorization"], dict(transport.calls[1][2])["Authorization"]) + + def test_equivalent_captured_at_replays_exact_original_multipart_bytes(self) -> None: + self.gateway.claim_next(PROFILE) + path = Path(self.directory.name) / "exact-replay.png" + path.write_bytes(PNG) + transport = FakeTransport(HttpResponse(202, (), b"")) + gateway = DurableClientGateway(self.store, self.source, HttpEvidenceSink(transport)) + first = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.1Z") + equivalent = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.100000Z") + with self.assertRaises(AmbiguousRemoteError): + gateway.upload_evidence(PROFILE, first) + with self.assertRaises(AmbiguousRemoteError): + gateway.upload_evidence(PROFILE, equivalent) + self.assertEqual(transport.calls[0][3], transport.calls[1][3]) + + def test_manual_claim_is_durable_and_never_gets_new_key(self) -> None: + self.source.mode = "manual" + with self.assertRaises(ManualRemoteError): + self.gateway.claim_next(PROFILE) + with self.assertRaises(Exception): + self.gateway.claim_next(PROFILE) + self.assertEqual(self.source.calls, 1) + + def test_evidence_slot_exists_before_http_and_success_never_reuploads(self) -> None: + self.gateway.claim_next(PROFILE) + path = Path(self.directory.name) / "one.png" + path.write_bytes(PNG) + asset = ScreenshotAsset(path, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00.120000Z") + first = self.gateway.upload_evidence(PROFILE, asset) + path.write_bytes(PNG + b"changed") + second = self.gateway.upload_evidence(PROFILE, asset) + self.assertEqual(first, second) + self.assertEqual(self.sink.calls, 1) diff --git a/client/tests/localstate/test_protection.py b/client/tests/localstate/test_protection.py new file mode 100644 index 0000000..c238c5b --- /dev/null +++ b/client/tests/localstate/test_protection.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import os +import unittest + +from cmbuyer_client.core.errors import ProtectionError +from cmbuyer_client.localstate.protection import DpapiProtector + + +@unittest.skipUnless(os.name == "nt", "DPAPI 仅在 Windows 验证") +class DpapiProtectorTests(unittest.TestCase): + def test_current_user_round_trip_purpose_isolation_and_corruption(self) -> None: + protector = DpapiProtector() + plaintext = b"a" * 64 + device_purpose = "device-token:default:33c9f507-7473-4fa6-8d71-8786c34c6301" + claim_purpose = "claim-token:default:53c9f507-7473-4fa6-8d71-8786c34c6301" + ciphertext = protector.protect(plaintext, purpose=device_purpose) + self.assertNotIn(plaintext, ciphertext) + self.assertEqual(protector.unprotect(ciphertext, purpose=device_purpose), plaintext) + with self.assertRaises(ProtectionError): + protector.unprotect(ciphertext, purpose=claim_purpose) + damaged = ciphertext[:-1] + bytes((ciphertext[-1] ^ 1,)) + with self.assertRaises(ProtectionError): + protector.unprotect(damaged, purpose=device_purpose) diff --git a/client/tests/localstate/test_single_instance.py b/client/tests/localstate/test_single_instance.py new file mode 100644 index 0000000..46a3275 --- /dev/null +++ b/client/tests/localstate/test_single_instance.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest + +from cmbuyer_client.core.errors import SingleInstanceError +from cmbuyer_client.localstate.single_instance import NamedMutex + + +@unittest.skipUnless(os.name == "nt", "named mutex 仅在 Windows 验证") +class NamedMutexTests(unittest.TestCase): + def test_second_process_for_same_database_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as directory: + database = Path(directory) / "state.sqlite3" + first = NamedMutex(database) + try: + with self.assertRaises(SingleInstanceError): + NamedMutex(database) + code = ( + "from pathlib import Path; " + "from cmbuyer_client.localstate.single_instance import NamedMutex; " + "from cmbuyer_client.core.errors import SingleInstanceError; " + f"p=Path({str(database)!r}); " + "\ntry:\n NamedMutex(p)\nexcept SingleInstanceError:\n raise SystemExit(17)\nraise SystemExit(0)" + ) + environment = dict(os.environ) + environment["PYTHONPATH"] = str(Path(__file__).resolve().parents[2] / "src") + result = subprocess.run([sys.executable, "-c", code], env=environment, check=False) + self.assertEqual(result.returncode, 17) + finally: + first.close() + + with NamedMutex(database): + pass diff --git a/client/tests/localstate/test_store.py b/client/tests/localstate/test_store.py new file mode 100644 index 0000000..bbc3668 --- /dev/null +++ b/client/tests/localstate/test_store.py @@ -0,0 +1,786 @@ +from __future__ import annotations + +import base64 +from dataclasses import replace +from datetime import datetime, timezone +import hashlib +import hmac +import json +import os +from pathlib import Path +import sqlite3 +import tempfile +import threading +from types import SimpleNamespace +import unittest +from unittest import mock + +from cmbuyer_client.core.errors import ProtectionError, StateError +from cmbuyer_client.core.models import AssetReceipt, ClaimedTask, RenewResult, ScreenshotAsset, SecretToken +from cmbuyer_client.localstate.models import ProfileSettings +from cmbuyer_client.localstate.store import LocalStateStore, _read_stable_png +from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN, claim_wire +from tests.remote.test_task_source import DEVICE_ID + + +DEVICE_TOKEN = "b" * 64 +PROFILE = "default" +PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" +) + + +class FakeProtector: + def protect(self, plaintext: bytes, *, purpose: str) -> bytes: + key = hashlib.sha256(("test:" + purpose).encode()).digest() + encrypted = bytes(value ^ key[index % len(key)] for index, value in enumerate(plaintext)) + return hmac.digest(key, plaintext, "sha256") + encrypted + + def unprotect(self, ciphertext: bytes, *, purpose: str) -> bytes: + if len(ciphertext) < 33: + raise ProtectionError("fake_unprotect_failed") + key = hashlib.sha256(("test:" + purpose).encode()).digest() + plaintext = bytes(value ^ key[index % len(key)] for index, value in enumerate(ciphertext[32:])) + if not hmac.compare_digest(ciphertext[:32], hmac.digest(key, plaintext, "sha256")): + raise ProtectionError("fake_unprotect_failed") + return plaintext + + +def settings(device_id: str = DEVICE_ID) -> ProfileSettings: + return ProfileSettings( + PROFILE, + "http://127.0.0.1:8080", + device_id, + "D:/Portable/adb/adb.exe", + "192.168.0.173:5555", + "wifi", + ) + + +class LocalStateStoreTests(unittest.TestCase): + def setUp(self) -> None: + self.directory = tempfile.TemporaryDirectory() + self.database = Path(self.directory.name) / "state" / "client-state.sqlite3" + self.clock = [datetime(2026, 8, 4, 9, 0, tzinfo=timezone.utc)] + self.store = self.new_store() + self.store.save_profile(settings(), SecretToken(DEVICE_TOKEN)) + + def tearDown(self) -> None: + self.directory.cleanup() + + def new_store(self) -> LocalStateStore: + return LocalStateStore(self.database, FakeProtector(), now=lambda: self.clock[0]) + + def test_claim_unknown_restart_stop_and_atomic_success(self) -> None: + session = self.store.start_or_resume_polling(PROFILE) + request = self.store.prepare_claim(PROFILE) + self.assertEqual(self.store.prepare_claim(PROFILE), request) + + # 模拟 HTTP 已成功但进程在落库前退出;重启只能恢复原 key。 + restarted = self.new_store() + self.assertEqual(restarted.recovery_snapshot(PROFILE).pending_claim, request) + self.assertEqual(restarted.prepare_claim(PROFILE), request) + + # stop 不能丢弃飞行中的 claim,返回结果仍必须落 active。 + restarted.request_stop(PROFILE) + claimed = ClaimedTask.from_wire(claim_wire()) + restarted.commit_claim_success(PROFILE, request, claimed) + snapshot = self.new_store().recovery_snapshot(PROFILE) + self.assertFalse(snapshot.session.accept_new) + self.assertIsNone(snapshot.pending_claim) + self.assertEqual(snapshot.active_claim.task.id, TASK_ID) + self.assertEqual(snapshot.active_claim.attempt.claim_token.value, TOKEN) + with self.assertRaises(StateError): + restarted.prepare_claim(PROFILE) + + def test_relative_database_path_is_frozen_across_cwd_changes(self) -> None: + original_cwd = Path.cwd() + first = Path(self.directory.name) / "first-cwd" + second = Path(self.directory.name) / "second-cwd" + first.mkdir() + second.mkdir() + try: + os.chdir(first) + relative_store = LocalStateStore(Path("relative/state.sqlite3"), FakeProtector(), now=lambda: self.clock[0]) + relative_store.save_profile(settings(), SecretToken(DEVICE_TOKEN)) + frozen_path = relative_store.database_path + os.chdir(second) + self.assertEqual(relative_store.load_profile(PROFILE).settings, settings()) + self.assertEqual(relative_store.database_path, frozen_path) + self.assertTrue(frozen_path.is_absolute()) + self.assertFalse((second / "relative" / "state.sqlite3").exists()) + finally: + os.chdir(original_cwd) + + def test_empty_allows_new_key_but_terminal_does_not(self) -> None: + self.store.start_or_resume_polling(PROFILE) + first = self.store.prepare_claim(PROFILE) + self.store.commit_claim_empty(PROFILE, first) + second = self.store.prepare_claim(PROFILE) + self.assertNotEqual(first.claim_request_id, second.claim_request_id) + self.store.mark_claim_terminal(PROFILE, second, "MANUAL") + with self.assertRaises(StateError): + self.store.prepare_claim(PROFILE) + + def test_idle_profile_identity_change_creates_new_session_and_key(self) -> None: + first_session = self.store.start_or_resume_polling(PROFILE) + first = self.store.prepare_claim(PROFILE) + self.store.commit_claim_empty(PROFILE, first) + other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301" + self.store.save_profile(settings(other_id), SecretToken("c" * 64)) + self.assertIsNone(self.store.recovery_snapshot(PROFILE).session) + second_session = self.store.start_or_resume_polling(PROFILE) + second = self.store.prepare_claim(PROFILE) + self.assertNotEqual(first_session.session_id, second_session.session_id) + self.assertNotEqual(first.claim_request_id, second.claim_request_id) + self.assertEqual(second.session_id, second_session.session_id) + + def test_device_change_requires_new_token_because_cipher_is_identity_bound(self) -> None: + other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301" + with self.assertRaisesRegex(StateError, "device_token_required_for_device_change"): + self.store.save_profile(settings(other_id), None) + self.store.save_profile(settings(other_id), SecretToken("c" * 64)) + self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "c" * 64) + + def test_tampered_profile_session_mismatch_is_not_auto_repaired(self) -> None: + self.store.start_or_resume_polling(PROFILE) + connection = sqlite3.connect(self.database) + try: + connection.execute( + "UPDATE profiles SET device_id=? WHERE profile_id=?", + ("f3c9f507-7473-4fa6-8d71-8786c34c6301", PROFILE), + ) + connection.commit() + finally: + connection.close() + with self.assertRaisesRegex(StateError, "polling_identity_mismatch"): + self.store.start_or_resume_polling(PROFILE) + + def test_pending_and_active_freeze_service_device_and_token_identity(self) -> None: + self.store.start_or_resume_polling(PROFILE) + request = self.store.prepare_claim(PROFILE) + self.store.save_profile(settings(), SecretToken("c" * 64)) + self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "c" * 64) + other_id = "f3c9f507-7473-4fa6-8d71-8786c34c6301" + with self.assertRaises(StateError): + self.store.save_profile(settings(other_id), None) + base = settings() + changed_profiles = ( + replace(base, adb_path="D:/other/adb.exe"), + replace(base, adb_serial="usb-other"), + replace(base, transport="usb"), + replace(base, poll_interval_seconds=16), + replace(base, failure_threshold=4), + replace(base, http_timeout_seconds=11), + replace(base, step_timeout_seconds=46), + ) + for changed in changed_profiles: + with self.subTest(changed=changed), self.assertRaises(StateError): + self.store.save_profile(changed, None) + self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire())) + self.store.save_profile(settings(), SecretToken("d" * 64)) + self.assertEqual(self.store.load_profile(PROFILE).credentials.token.value, "d" * 64) + + def test_renew_reuses_exact_payload_across_restart_and_cas_updates_only_lease(self) -> None: + self._claim_active() + request = self.store.prepare_renew(PROFILE) + self.clock[0] = datetime(2026, 8, 4, 9, 7, tzinfo=timezone.utc) + recovered = self.new_store().prepare_renew(PROFILE) + self.assertEqual(recovered.renew_request_id, request.renew_request_id) + self.assertEqual(recovered.to_wire(), request.to_wire()) + result = RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z") + self.store.commit_renew_success(PROFILE, request, result) + active = self.new_store().active_claim(PROFILE) + self.assertEqual(active.attempt.claim_generation, 1) + self.assertEqual(active.attempt.claim_token.value, TOKEN) + self.assertEqual(active.attempt.lease_expires_at, "2026-08-04T09:06:00Z") + + with self.assertRaises(StateError): + self.store.prepare_renew(PROFILE) + + def test_evidence_slot_is_persisted_before_send_and_rejects_file_change(self) -> None: + self._claim_active() + image = Path(self.directory.name) / "explicit.png" + image.write_bytes(PNG) + asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z") + upload = self.store.prepare_or_resume_evidence(PROFILE, asset) + recovered = self.new_store().prepare_or_resume_evidence(PROFILE, asset) + self.assertEqual(recovered.upload_key, upload.upload_key) + self.assertEqual(recovered.content, upload.content) + + image.write_bytes(PNG + b"changed") + with self.assertRaises(StateError): + self.store.prepare_or_resume_evidence(PROFILE, asset) + image.write_bytes(PNG) + + receipt = AssetReceipt( + "63c9f507-7473-4fa6-8d71-8786c34c6301", + TASK_ID, + ATTEMPT_ID, + "SKU_PANEL_GATE_1", + "INTERNAL_RAW", + upload.sha256, + len(PNG), + "image/png", + 1, + 1, + "2026-08-04T09:01:00Z", + ) + self.store.commit_evidence_success(PROFILE, upload, receipt) + image.write_bytes(PNG + b"different-after-success") + self.assertEqual(self.new_store().prepare_or_resume_evidence(PROFILE, asset), receipt) + image.unlink() + self.assertEqual(self.new_store().prepare_or_resume_evidence(PROFILE, asset), receipt) + + def test_evidence_terminal_outcome_retains_slot_and_blocks_resend(self) -> None: + self._claim_active() + image = Path(self.directory.name) / "manual.png" + image.write_bytes(PNG) + asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z") + upload = self.store.prepare_or_resume_evidence(PROFILE, asset) + self.store.mark_evidence_terminal(PROFILE, upload, "MANUAL") + snapshot = self.store.recovery_snapshot(PROFILE) + self.assertEqual(snapshot.pending_evidence[0].status, "MANUAL") + with self.assertRaises(StateError): + self.store.prepare_or_resume_evidence(PROFILE, asset) + + def test_receipt_dimensions_must_match_local_png_and_pending_slot_survives(self) -> None: + self._claim_active() + image = Path(self.directory.name) / "dimension.png" + image.write_bytes(PNG) + asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z") + upload = self.store.prepare_or_resume_evidence(PROFILE, asset) + wrong = AssetReceipt( + "63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind, + upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 2, 2, upload.captured_at, + ) + with self.assertRaisesRegex(StateError, "evidence_response_mismatch"): + self.store.commit_evidence_success(PROFILE, upload, wrong) + pending = self.new_store().recovery_snapshot(PROFILE).pending_evidence + self.assertEqual(len(pending), 1) + self.assertEqual(pending[0].upload_key, upload.upload_key) + + def test_evidence_slot_is_profile_owned_and_missing_half_fails_closed(self) -> None: + self._claim_active() + image = Path(self.directory.name) / "owned.png" + image.write_bytes(PNG) + asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z") + self.store.prepare_or_resume_evidence(PROFILE, asset) + + other = replace(settings(), profile_id="other") + self.store.save_profile(other, SecretToken("c" * 64)) + with self.assertRaisesRegex(StateError, "evidence_slot_not_owned"): + self.store.prepare_or_resume_evidence("other", asset) + + connection = sqlite3.connect(self.database) + connection.execute("DROP TRIGGER evidence_slots_no_delete") + connection.execute("DELETE FROM evidence_slots WHERE attempt_id=?", (ATTEMPT_ID,)) + connection.commit() + connection.close() + with self.assertRaisesRegex(StateError, "evidence_marker_mismatch"): + self.store.prepare_or_resume_evidence(PROFILE, asset) + + def test_evidence_reader_rejects_reparse_and_path_identity_change(self) -> None: + image = Path(self.directory.name) / "stable.png" + image.write_bytes(PNG) + real = os.lstat(image) + reparse = SimpleNamespace( + st_mode=real.st_mode, + st_file_attributes=0x400, + st_dev=real.st_dev, + st_ino=real.st_ino, + st_size=real.st_size, + st_mtime_ns=real.st_mtime_ns, + ) + with mock.patch("cmbuyer_client.localstate.store.os.lstat", return_value=reparse): + with self.assertRaisesRegex(StateError, "evidence_file_not_regular"): + _read_stable_png(image) + + replaced = SimpleNamespace( + st_mode=real.st_mode, + st_file_attributes=0, + st_dev=real.st_dev, + st_ino=real.st_ino + 1, + st_size=real.st_size, + st_mtime_ns=real.st_mtime_ns, + ) + with mock.patch("cmbuyer_client.localstate.store.os.lstat", side_effect=(real, replaced)): + with self.assertRaisesRegex(StateError, "evidence_changed_during_read"): + _read_stable_png(image) + + def test_corrupt_database_and_ciphertext_fail_closed(self) -> None: + self._claim_active() + connection = sqlite3.connect(self.database) + try: + connection.execute("DROP TRIGGER active_claims_identity_immutable") + connection.execute("UPDATE active_claims SET claim_token_cipher=?", (sqlite3.Binary(b"corrupt"),)) + connection.commit() + finally: + connection.close() + with self.assertRaises(ProtectionError): + self.new_store().active_claim(PROFILE) + + corrupt = Path(self.directory.name) / "corrupt.sqlite3" + corrupt.write_bytes(b"not-a-sqlite-database") + with self.assertRaises(StateError): + LocalStateStore(corrupt, FakeProtector()) + + def test_claim_token_cipher_cannot_be_swapped_between_attempt_histories(self) -> None: + self._claim_active() + connection = sqlite3.connect(self.database) + connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,)) + connection.commit() + connection.close() + request = self.store.prepare_claim(PROFILE) + wire = claim_wire() + wire["task"]["id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301" + wire["authorization"]["id"] = "93c9f507-7473-4fa6-8d71-8786c34c6301" + wire["attempt"]["id"] = "a3c9f507-7473-4fa6-8d71-8786c34c6301" + wire["attempt"]["claim_token"] = "f" * 64 + self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(wire)) + + connection = sqlite3.connect(self.database) + try: + connection.execute("DROP TRIGGER active_claims_identity_immutable") + rows = connection.execute("SELECT attempt_id,claim_token_cipher FROM active_claims ORDER BY created_at,attempt_id").fetchall() + connection.execute("UPDATE active_claims SET claim_token_cipher=? WHERE attempt_id=?", (rows[1][1], rows[0][0])) + connection.execute("UPDATE active_claims SET claim_token_cipher=? WHERE attempt_id=?", (rows[0][1], rows[1][0])) + connection.commit() + finally: + connection.close() + with self.assertRaises(ProtectionError): + self.new_store().active_claim(PROFILE) + + def test_device_token_cipher_cannot_be_swapped_between_profiles(self) -> None: + other = replace(settings(), profile_id="other", device_id="f3c9f507-7473-4fa6-8d71-8786c34c6301") + self.store.save_profile(other, SecretToken("c" * 64)) + connection = sqlite3.connect(self.database) + try: + rows = connection.execute( + "SELECT profile_id,device_token_cipher FROM profiles WHERE profile_id IN (?,?) ORDER BY profile_id", + (PROFILE, "other"), + ).fetchall() + connection.execute("UPDATE profiles SET device_token_cipher=? WHERE profile_id=?", (rows[1][1], rows[0][0])) + connection.execute("UPDATE profiles SET device_token_cipher=? WHERE profile_id=?", (rows[0][1], rows[1][0])) + connection.commit() + finally: + connection.close() + with self.assertRaises(ProtectionError): + self.new_store().load_profile(PROFILE) + + def test_state_graph_rejects_missing_active_pending_overlap_and_snapshot_drift(self) -> None: + self._claim_active() + connection = sqlite3.connect(self.database) + try: + connection.execute( + "INSERT INTO claim_requests VALUES(?,?,?,'PENDING',?,?)", + ( + "63c9f507-7473-4fa6-8d71-8786c34c6301", + PROFILE, + self.store.recovery_snapshot(PROFILE).session.session_id, + "2026-08-04T09:00:00Z", + "2026-08-04T09:00:00Z", + ), + ) + connection.commit() + finally: + connection.close() + with self.assertRaisesRegex(StateError, "claim_state_conflict"): + self.store.prepare_claim(PROFILE) + + connection = sqlite3.connect(self.database) + try: + connection.execute("DROP TRIGGER claim_requests_no_delete") + connection.execute("DELETE FROM claim_requests WHERE status='PENDING'") + connection.execute("DROP TRIGGER active_claims_identity_immutable") + connection.execute("UPDATE active_claims SET task_id=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",)) + connection.commit() + finally: + connection.close() + with self.assertRaisesRegex(StateError, "active_claim_snapshot_mismatch"): + self.store.active_claim(PROFILE) + + def test_succeeded_claim_tombstone_detects_missing_history(self) -> None: + self._claim_active() + connection = sqlite3.connect(self.database) + try: + connection.execute("DROP TRIGGER active_claims_no_delete") + connection.execute("DELETE FROM active_claims") + connection.commit() + finally: + connection.close() + with self.assertRaisesRegex(StateError, "active_claim_request_mismatch"): + self.store.prepare_claim(PROFILE) + + def test_closed_history_is_retained_but_does_not_block_next_claim(self) -> None: + self._claim_active() + connection = sqlite3.connect(self.database) + try: + connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z'") + connection.commit() + finally: + connection.close() + self.assertIsNone(self.store.active_claim(PROFILE)) + changed = replace(settings(), poll_interval_seconds=16) + self.store.save_profile(changed, None) + self.store.start_or_resume_polling(PROFILE) + request = self.store.prepare_claim(PROFILE) + self.assertIsNotNone(request.claim_request_id) + + def test_pending_renew_and_evidence_revalidate_active_graph(self) -> None: + self._claim_active() + renew = self.store.prepare_renew(PROFILE) + connection = sqlite3.connect(self.database) + try: + connection.execute("DROP TRIGGER renew_requests_identity_immutable") + connection.execute("UPDATE renew_requests SET task_id=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",)) + connection.commit() + finally: + connection.close() + with self.assertRaisesRegex(StateError, "renew_active_mismatch"): + self.store.prepare_renew(PROFILE) + + def test_success_receipt_must_match_immutable_slot(self) -> None: + self._claim_active() + image = Path(self.directory.name) / "receipt.png" + image.write_bytes(PNG) + asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z") + upload = self.store.prepare_or_resume_evidence(PROFILE, asset) + receipt = AssetReceipt( + "63c9f507-7473-4fa6-8d71-8786c34c6301", + TASK_ID, + ATTEMPT_ID, + upload.kind, + upload.privacy_tier, + upload.sha256, + len(upload.content), + "image/png", + 1, + 1, + upload.captured_at, + ) + self.store.commit_evidence_success(PROFILE, upload, receipt) + connection = sqlite3.connect(self.database) + try: + wrong = dict(receipt.__dict__) + wrong["asset_id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301" + with self.assertRaises(sqlite3.IntegrityError): + connection.execute("UPDATE evidence_slots SET receipt_json=?", (json.dumps(wrong),)) + connection.rollback() + connection.execute("DROP TRIGGER evidence_slots_receipt_immutable") + connection.execute("UPDATE evidence_slots SET receipt_json=?", (json.dumps(wrong),)) + connection.commit() + finally: + connection.close() + with self.assertRaisesRegex(StateError, "evidence_receipt_mismatch"): + self.store.prepare_or_resume_evidence(PROFILE, asset) + + def test_marker_and_slot_append_only_triggers_prevent_erasing_history(self) -> None: + self._claim_active() + image = Path(self.directory.name) / "append-only.png" + image.write_bytes(PNG) + asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z") + self.store.prepare_or_resume_evidence(PROFILE, asset) + connection = sqlite3.connect(self.database) + try: + with self.assertRaises(sqlite3.IntegrityError): + connection.execute("DELETE FROM evidence_slot_markers") + with self.assertRaises(sqlite3.IntegrityError): + connection.execute("UPDATE evidence_slot_markers SET upload_key=?", ("83c9f507-7473-4fa6-8d71-8786c34c6301",)) + with self.assertRaises(sqlite3.IntegrityError): + connection.execute("DELETE FROM evidence_slots") + finally: + connection.close() + + def test_capped_equal_renew_is_success_but_authorization_overrun_is_rejected(self) -> None: + self._claim_active() + request = self.store.prepare_renew(PROFILE) + equal = RenewResult(TASK_ID, ATTEMPT_ID, 1, request.expected_lease_expires_at) + self.store.commit_renew_success(PROFILE, request, equal) + self.assertEqual(self.store.active_claim(PROFILE).attempt.lease_expires_at, request.expected_lease_expires_at) + next_request = self.store.prepare_renew(PROFILE) + beyond = RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T10:00:00.000000001Z") + with self.assertRaisesRegex(StateError, "renew_response_mismatch"): + self.store.commit_renew_success(PROFILE, next_request, beyond) + + def test_sqlite_database_wal_and_shm_never_contain_plaintext_tokens(self) -> None: + self._claim_active() + # 强制 checkpoint 后同时扫描主文件和可能存在的 WAL/SHM。 + connection = sqlite3.connect(self.database) + connection.execute("PRAGMA wal_checkpoint(FULL)") + connection.close() + for path in (self.database, Path(str(self.database) + "-wal"), Path(str(self.database) + "-shm")): + if path.exists(): + raw = path.read_bytes() + self.assertNotIn(DEVICE_TOKEN.encode(), raw) + self.assertNotIn(bytes.fromhex(DEVICE_TOKEN), raw) + self.assertNotIn(TOKEN.encode(), raw) + self.assertNotIn(bytes.fromhex(TOKEN), raw) + + def test_concurrent_stop_and_claim_commit_never_loses_result(self) -> None: + self.store.start_or_resume_polling(PROFILE) + request = self.store.prepare_claim(PROFILE) + barrier = threading.Barrier(2) + failures: list[BaseException] = [] + + def stop() -> None: + try: + barrier.wait() + self.store.request_stop(PROFILE) + except BaseException as error: + failures.append(error) + + thread = threading.Thread(target=stop) + thread.start() + barrier.wait() + self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire())) + thread.join() + self.assertEqual(failures, []) + snapshot = self.store.recovery_snapshot(PROFILE) + self.assertFalse(snapshot.session.accept_new) + self.assertIsNotNone(snapshot.active_claim) + + def test_recovery_snapshot_uses_one_sqlite_read_snapshot(self) -> None: + self.store.start_or_resume_polling(PROFILE) + writer = self.new_store() + failures: list[BaseException] = [] + + class PausingStore(LocalStateStore): + armed = False + + def _connect(inner_self): + connection = super(PausingStore, inner_self)._connect() + if inner_self.armed: + def trace(statement: str) -> None: + if inner_self.armed and "FROM claim_requests" in statement: + inner_self.armed = False + thread = threading.Thread(target=do_stop) + thread.start() + thread.join() + + connection.set_trace_callback(trace) + return connection + + def do_stop() -> None: + try: + writer.request_stop(PROFILE) + except BaseException as error: + failures.append(error) + + reader = PausingStore(self.database, FakeProtector(), now=lambda: self.clock[0]) + reader.armed = True + snapshot = reader.recovery_snapshot(PROFILE) + self.assertEqual(failures, []) + self.assertTrue(snapshot.session.accept_new) + self.assertFalse(writer.recovery_snapshot(PROFILE).session.accept_new) + + def test_business_snapshot_is_immutable_hashed_and_renew_never_rewrites_it(self) -> None: + self._claim_active() + connection = sqlite3.connect(self.database) + try: + raw, digest, initial_lease, current_lease = connection.execute( + """SELECT snapshot_json,snapshot_digest,initial_lease_expires_at,lease_expires_at + FROM active_claims WHERE closed_at IS NULL""" + ).fetchone() + with self.assertRaises(sqlite3.IntegrityError): + connection.execute("UPDATE active_claims SET snapshot_json='{}' WHERE closed_at IS NULL") + connection.rollback() + connection.execute("DROP TRIGGER active_claims_identity_immutable") + for field, changed in ( + ("sku_color", "白色"), + ("sku_size", "XL"), + ("quantity", 99), + ("max_total_price", "999.00"), + ): + payload = json.loads(raw) + payload["task"][field] = changed + tampered = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + connection.execute("UPDATE active_claims SET snapshot_json=? WHERE closed_at IS NULL", (tampered,)) + connection.commit() + with self.subTest(field=field), self.assertRaisesRegex(StateError, "active_claim_snapshot_mismatch"): + self.store.active_claim(PROFILE) + connection.execute( + "UPDATE active_claims SET snapshot_json=?,snapshot_digest=? WHERE closed_at IS NULL", (raw, digest) + ) + connection.commit() + connection.execute( + """UPDATE active_claims SET initial_lease_expires_at='2026-08-04T09:59:00Z', + lease_expires_at='2026-08-04T09:59:00Z' WHERE closed_at IS NULL""" + ) + connection.commit() + with self.assertRaisesRegex(StateError, "invalid_stored_claim"): + self.store.active_claim(PROFILE) + connection.execute( + """UPDATE active_claims SET initial_lease_expires_at=?,lease_expires_at=? + WHERE closed_at IS NULL""", + (initial_lease, current_lease), + ) + connection.commit() + finally: + connection.close() + + renew = self.store.prepare_renew(PROFILE) + self.store.commit_renew_success(PROFILE, renew, RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z")) + connection = sqlite3.connect(self.database) + try: + self.assertEqual( + connection.execute("SELECT snapshot_json,snapshot_digest FROM active_claims WHERE closed_at IS NULL").fetchone(), + (raw, digest), + ) + finally: + connection.close() + + def test_open_session_detects_tampering_of_every_non_token_profile_setting(self) -> None: + self._claim_active() + changed_values = { + "service_url": "http://127.0.0.1:9999", + "device_id": "f3c9f507-7473-4fa6-8d71-8786c34c6301", + "adb_path": "D:/other/adb.exe", + "adb_serial": "usb-other", + "transport": "usb", + "poll_interval_seconds": 16, + "failure_threshold": 4, + "http_timeout_seconds": 11, + "step_timeout_seconds": 46, + } + connection = sqlite3.connect(self.database) + try: + for field, changed in changed_values.items(): + original = connection.execute(f"SELECT {field} FROM profiles WHERE profile_id=?", (PROFILE,)).fetchone()[0] + connection.execute(f"UPDATE profiles SET {field}=? WHERE profile_id=?", (changed, PROFILE)) + connection.commit() + with self.subTest(field=field), self.assertRaisesRegex(StateError, "polling_identity_mismatch"): + self.store.active_claim(PROFILE) + connection.execute(f"UPDATE profiles SET {field}=? WHERE profile_id=?", (original, PROFILE)) + connection.commit() + finally: + connection.close() + + def test_closed_attempt_evidence_is_history_not_current_recovery_work(self) -> None: + self._claim_active() + image = Path(self.directory.name) / "old-manual.png" + image.write_bytes(PNG) + asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z") + upload = self.store.prepare_or_resume_evidence(PROFILE, asset) + self.store.mark_evidence_terminal(PROFILE, upload, "MANUAL") + connection = sqlite3.connect(self.database) + connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,)) + connection.commit() + connection.close() + + next_request = self.store.prepare_claim(PROFILE) + self.assertIsNotNone(next_request.claim_request_id) + self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_evidence, ()) + + def test_succeeded_evidence_history_does_not_block_next_claim(self) -> None: + self._claim_active() + image = Path(self.directory.name) / "old-success.png" + image.write_bytes(PNG) + asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z") + upload = self.store.prepare_or_resume_evidence(PROFILE, asset) + receipt = AssetReceipt( + "63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind, + upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 1, 1, upload.captured_at, + ) + self.store.commit_evidence_success(PROFILE, upload, receipt) + connection = sqlite3.connect(self.database) + connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,)) + connection.commit() + connection.close() + self.assertIsNotNone(self.store.prepare_claim(PROFILE)) + + def test_evidence_slot_without_corresponding_claim_history_fails_closed(self) -> None: + self._claim_active() + image = Path(self.directory.name) / "orphan.png" + image.write_bytes(PNG) + asset = ScreenshotAsset(image, TASK_ID, ATTEMPT_ID, "2026-08-04T09:01:00Z") + upload = self.store.prepare_or_resume_evidence(PROFILE, asset) + receipt = AssetReceipt( + "63c9f507-7473-4fa6-8d71-8786c34c6301", TASK_ID, ATTEMPT_ID, upload.kind, + upload.privacy_tier, upload.sha256, len(upload.content), "image/png", 1, 1, upload.captured_at, + ) + self.store.commit_evidence_success(PROFILE, upload, receipt) + orphan = "a3c9f507-7473-4fa6-8d71-8786c34c6301" + connection = sqlite3.connect(self.database) + try: + connection.execute("DROP TRIGGER evidence_slot_markers_immutable") + connection.execute("DROP TRIGGER evidence_slots_identity_immutable") + connection.execute("UPDATE evidence_slot_markers SET attempt_id=?", (orphan,)) + connection.execute("UPDATE evidence_slots SET attempt_id=?", (orphan,)) + connection.commit() + finally: + connection.close() + with self.assertRaisesRegex(StateError, "evidence_claim_history_mismatch"): + self.store.recovery_snapshot(PROFILE) + + def test_terminal_renew_is_scoped_to_old_closed_attempt(self) -> None: + self._claim_active() + old_renew = self.store.prepare_renew(PROFILE) + self.store.mark_renew_terminal(PROFILE, old_renew, "MANUAL") + connection = sqlite3.connect(self.database) + connection.execute("UPDATE active_claims SET closed_at='2026-08-04T09:02:00Z' WHERE attempt_id=?", (ATTEMPT_ID,)) + connection.commit() + connection.close() + + request = self.store.prepare_claim(PROFILE) + wire = claim_wire() + wire["task"]["id"] = "83c9f507-7473-4fa6-8d71-8786c34c6301" + wire["authorization"]["id"] = "93c9f507-7473-4fa6-8d71-8786c34c6301" + wire["attempt"]["id"] = "a3c9f507-7473-4fa6-8d71-8786c34c6301" + self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(wire)) + next_renew = self.store.prepare_renew(PROFILE) + self.assertNotEqual(next_renew.renew_request_id, old_renew.renew_request_id) + self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_renew, next_renew) + + def test_successful_renew_response_is_write_once_and_digest_checked(self) -> None: + self._claim_active() + request = self.store.prepare_renew(PROFILE) + self.store.commit_renew_success(PROFILE, request, RenewResult(TASK_ID, ATTEMPT_ID, 1, "2026-08-04T09:06:00Z")) + connection = sqlite3.connect(self.database) + try: + with self.assertRaises(sqlite3.IntegrityError): + connection.execute("UPDATE renew_requests SET response_json='{}' WHERE renew_request_id=?", (request.renew_request_id,)) + connection.rollback() + connection.execute("DROP TRIGGER renew_requests_response_immutable") + connection.execute("UPDATE renew_requests SET response_json='{}' WHERE renew_request_id=?", (request.renew_request_id,)) + connection.commit() + finally: + connection.close() + with self.assertRaisesRegex(StateError, "renew_response_mismatch"): + self.store.active_claim(PROFILE) + + def test_invalid_or_reversed_session_and_claim_timestamps_fail_closed(self) -> None: + self._claim_active() + connection = sqlite3.connect(self.database) + try: + connection.execute("UPDATE active_claims SET closed_at='2026-08-04T08:59:00Z' WHERE attempt_id=?", (ATTEMPT_ID,)) + connection.commit() + finally: + connection.close() + with self.assertRaisesRegex(StateError, "invalid_claim_timeline"): + self.store.recovery_snapshot(PROFILE) + + def test_invalid_session_closed_at_fails_closed(self) -> None: + self.store.start_or_resume_polling(PROFILE) + connection = sqlite3.connect(self.database) + try: + connection.execute("UPDATE polling_sessions SET closed_at='not-a-time' WHERE profile_id=?", (PROFILE,)) + connection.commit() + finally: + connection.close() + with self.assertRaises(StateError): + self.store.recovery_snapshot(PROFILE) + + def test_store_rejects_forged_claim_whose_lease_exceeds_authorization(self) -> None: + self.store.start_or_resume_polling(PROFILE) + request = self.store.prepare_claim(PROFILE) + claimed = ClaimedTask.from_wire(claim_wire()) + object.__setattr__(claimed.attempt, "lease_expires_at", "2026-08-04T10:00:00.000000001Z") + with self.assertRaisesRegex(StateError, "claim_lease_exceeds_authorization"): + self.store.commit_claim_success(PROFILE, request, claimed) + self.assertEqual(self.store.recovery_snapshot(PROFILE).pending_claim, request) + + def _claim_active(self) -> None: + self.store.start_or_resume_polling(PROFILE) + request = self.store.prepare_claim(PROFILE) + self.store.commit_claim_success(PROFILE, request, ClaimedTask.from_wire(claim_wire())) diff --git a/client/tests/remote/__init__.py b/client/tests/remote/__init__.py new file mode 100644 index 0000000..bef3937 --- /dev/null +++ b/client/tests/remote/__init__.py @@ -0,0 +1 @@ +"""remote tests。""" diff --git a/client/tests/remote/test_evidence_sink.py b/client/tests/remote/test_evidence_sink.py new file mode 100644 index 0000000..02564d2 --- /dev/null +++ b/client/tests/remote/test_evidence_sink.py @@ -0,0 +1,167 @@ +from __future__ import annotations + +import hashlib +import json +import base64 +import re +import unittest + +from cmbuyer_client.core.models import DeviceCredentials, EvidenceUpload, SecretToken +from cmbuyer_client.core.errors import AmbiguousRemoteError, ValidationError +from cmbuyer_client.remote.evidence_sink import HttpEvidenceSink +from cmbuyer_client.remote.http_transport import HttpResponse +from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN +from tests.remote.test_task_source import DEVICE_ID, FakeTransport + + +UPLOAD_ID = "43c9f507-7473-4fa6-8d71-8786c34c6301" +ASSET_ID = "63c9f507-7473-4fa6-8d71-8786c34c6301" +PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" +) + + +class EvidenceSinkTests(unittest.TestCase): + def test_upload_has_fixed_fields_and_never_contains_local_path(self) -> None: + digest = hashlib.sha256(PNG).hexdigest() + upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG) + payload = { + "asset_id": ASSET_ID, + "task_id": TASK_ID, + "attempt_id": ATTEMPT_ID, + "kind": "SKU_PANEL_GATE_1", + "privacy_tier": "INTERNAL_RAW", + "sha256": digest, + "byte_size": len(PNG), + "content_type": "image/png", + "width_px": 1, + "height_px": 1, + "captured_at": "2026-08-04T09:01:00Z", + } + raw = json.dumps(payload, separators=(",", ":")).encode() + transport = FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw)) + receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload) + self.assertEqual(receipt.asset_id, ASSET_ID) + self.assertEqual(len(transport.calls), 1) + body = transport.calls[0][3] + self.assertIn(b'filename="evidence.png"', body) + self.assertNotIn(b"C:\\", body) + self.assertNotIn(b"manifest", body) + self.assertNotIn(b".xml", body) + names = re.findall(br'Content-Disposition: form-data; name="([^"]+)"', body) + self.assertEqual( + names, + [b"upload_key", b"attempt_id", b"kind", b"privacy_tier", b"sha256", b"captured_at", b"file"], + ) + self.assertEqual(body.count(b'filename="evidence.png"'), 1) + self.assertNotIn(b"claim_token", body) + self.assertNotIn(b"session_id", body) + boundary = dict(transport.calls[0][2])["Content-Type"].split("boundary=", 1)[1] + self.assertEqual(boundary, "cmbuyer-" + UPLOAD_ID.replace("-", "")) + + def test_captured_at_equivalent_trailing_zeros_are_accepted(self) -> None: + digest = hashlib.sha256(PNG).hexdigest() + upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00.120000Z", PNG) + payload = { + "asset_id": ASSET_ID, + "task_id": TASK_ID, + "attempt_id": ATTEMPT_ID, + "kind": "SKU_PANEL_GATE_1", + "privacy_tier": "INTERNAL_RAW", + "sha256": digest, + "byte_size": len(PNG), + "content_type": "image/png", + "width_px": 1, + "height_px": 1, + "captured_at": "2026-08-04T09:01:00.12Z", + } + raw = json.dumps(payload, separators=(",", ":")).encode() + for status in (200, 201): + with self.subTest(status=status): + transport = FakeTransport(HttpResponse(status, (("Content-Type", "application/json"),), raw)) + receipt = HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload) + self.assertEqual(receipt.captured_at, "2026-08-04T09:01:00.12Z") + + def test_wrong_content_hash_fails_before_http_object_can_be_built(self) -> None: + transport = FakeTransport(HttpResponse(500, (), b"")) + with self.assertRaises(ValidationError): + upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, "0" * 64, "2026-08-04T09:01:00Z", PNG) + HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload) + self.assertEqual(transport.calls, []) + + def test_unknown_2xx_is_ambiguous(self) -> None: + digest = hashlib.sha256(PNG).hexdigest() + upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG) + for status in (202, 204, 206): + with self.subTest(status=status): + transport = FakeTransport(HttpResponse(status, (), b"")) + with self.assertRaises(AmbiguousRemoteError): + HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload) + self.assertEqual(len(transport.calls), 1) + + def test_boundary_collision_and_receipt_mismatch_fail_closed(self) -> None: + marker = ("--cmbuyer-" + UPLOAD_ID.replace("-", "")).encode() + collision_content = PNG + marker + collision = EvidenceUpload( + TASK_ID, + UPLOAD_ID, + ATTEMPT_ID, + hashlib.sha256(collision_content).hexdigest(), + "2026-08-04T09:01:00Z", + collision_content, + ) + transport = FakeTransport(HttpResponse(500, (), b"")) + from cmbuyer_client.core.errors import ProtocolRemoteError + with self.assertRaises(ProtocolRemoteError): + HttpEvidenceSink(transport).upload(DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), collision) + self.assertEqual(transport.calls, []) + + digest = hashlib.sha256(PNG).hexdigest() + upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG) + mismatch = { + "asset_id": ASSET_ID, + "task_id": TASK_ID, + "attempt_id": ATTEMPT_ID, + "kind": "SKU_PANEL_GATE_1", + "privacy_tier": "INTERNAL_RAW", + "sha256": "f" * 64, + "byte_size": len(PNG), + "content_type": "image/png", + "width_px": 1, + "height_px": 1, + "captured_at": "2026-08-04T09:01:00Z", + } + raw = json.dumps(mismatch, separators=(",", ":")).encode() + with self.assertRaises(AmbiguousRemoteError): + HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload( + DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload + ) + + dimension_mismatch = dict(mismatch) + dimension_mismatch["sha256"] = digest + dimension_mismatch["width_px"] = 2 + raw = json.dumps(dimension_mismatch, separators=(",", ":")).encode() + with self.assertRaises(AmbiguousRemoteError): + HttpEvidenceSink(FakeTransport(HttpResponse(201, (("Content-Type", "application/json"),), raw))).upload( + DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)), upload + ) + + def test_evidence_error_status_matrix(self) -> None: + digest = hashlib.sha256(PNG).hexdigest() + upload = EvidenceUpload(TASK_ID, UPLOAD_ID, ATTEMPT_ID, digest, "2026-08-04T09:01:00Z", PNG) + credentials = DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)) + from cmbuyer_client.core.errors import CredentialRemoteError, ManualRemoteError, ProtocolRemoteError + + cases = ( + (401, CredentialRemoteError), + (400, ProtocolRemoteError), + (403, ProtocolRemoteError), + (409, ManualRemoteError), + (413, ProtocolRemoteError), + (415, ProtocolRemoteError), + (500, AmbiguousRemoteError), + (503, AmbiguousRemoteError), + ) + for status, expected in cases: + with self.subTest(status=status), self.assertRaises(expected): + HttpEvidenceSink(FakeTransport(HttpResponse(status, (), b""))).upload(credentials, upload) diff --git a/client/tests/remote/test_http_transport.py b/client/tests/remote/test_http_transport.py new file mode 100644 index 0000000..3a61a18 --- /dev/null +++ b/client/tests/remote/test_http_transport.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import os +import http.client +import unittest +from unittest import mock + +from cmbuyer_client.core.errors import AmbiguousRemoteError, ProtocolRemoteError +from cmbuyer_client.remote.http_transport import HttpTransport + + +class FakeResponse: + status = 200 + + def __init__(self, body: bytes = b"{}", headers: list[tuple[str, str]] | None = None) -> None: + self.body = body + self.headers = headers or [("Content-Type", "application/json")] + + def getheader(self, name: str) -> str | None: + return str(len(self.body)) if name == "Content-Length" else None + + def getheaders(self) -> list[tuple[str, str]]: + return self.headers + + def read(self, maximum: int) -> bytes: + return self.body[:maximum] + + +class FakeConnection: + def __init__(self, host: str, port: int, timeout: int) -> None: + self.created = (host, port, timeout) + self.calls = 0 + self.closed = False + self.sent_headers: dict[str, str] = {} + self.response = FakeResponse() + + def request(self, method: str, path: str, body: bytes, headers: dict[str, str]) -> None: + self.calls += 1 + self.sent_headers = headers + + def getresponse(self) -> FakeResponse: + return self.response + + def close(self) -> None: + self.closed = True + + +class HttpTransportTests(unittest.TestCase): + def test_exact_loopback_and_proxy_environment_is_irrelevant(self) -> None: + made: list[FakeConnection] = [] + + def factory(*args: object, **kwargs: object) -> FakeConnection: + connection = FakeConnection(*args, **kwargs) + made.append(connection) + return connection + + with mock.patch.dict(os.environ, {"HTTP_PROXY": "http://example.invalid:9999"}): + result = HttpTransport(connection_factory=factory).request( + "POST", "/api/v1/tasks/claim-next", (("Content-Type", "application/json"),), b"{}", response_limit=10 + ) + self.assertEqual(result.status, 200) + self.assertEqual(made[0].created, ("127.0.0.1", 8080, 10)) + self.assertEqual(made[0].calls, 1) + self.assertTrue(made[0].closed) + + for url in ("http://localhost:8080", "http://127.0.0.1:8081", "http://127.0.0.1:8080/", "https://127.0.0.1:8080"): + with self.subTest(url=url), self.assertRaises(ProtocolRemoteError): + HttpTransport(url) + + def test_network_failure_is_ambiguous_without_retry(self) -> None: + class Broken(FakeConnection): + def getresponse(self) -> FakeResponse: + raise OSError("offline") + + made: list[Broken] = [] + + def factory(*args: object, **kwargs: object) -> Broken: + connection = Broken(*args, **kwargs) + made.append(connection) + return connection + + with self.assertRaises(AmbiguousRemoteError): + HttpTransport(connection_factory=factory).request( + "POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=10 + ) + self.assertEqual(made[0].calls, 1) + + def test_generator_headers_and_content_length_framing(self) -> None: + made: list[FakeConnection] = [] + + def factory(*args: object, **kwargs: object) -> FakeConnection: + connection = FakeConnection(*args, **kwargs) + made.append(connection) + return connection + + headers = ((name, value) for name, value in (("Content-Type", "application/json"), ("Accept", "application/json"))) + HttpTransport(connection_factory=factory).request( + "POST", "/api/v1/tasks/claim-next", headers, b"{}", response_limit=8 + ) + self.assertEqual(made[0].sent_headers["Content-Type"], "application/json") + self.assertEqual(made[0].sent_headers["Accept"], "application/json") + + legal = FakeConnection("127.0.0.1", 8080, 10) + legal.response = FakeResponse(b"{}", [("Content-Length", "2")]) + accepted = HttpTransport(connection_factory=lambda *args, **kwargs: legal).request( + "POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8 + ) + self.assertEqual(accepted.body, b"{}") + chunked = FakeConnection("127.0.0.1", 8080, 10) + chunked.response = FakeResponse(b"{}", [("Transfer-Encoding", "Chunked")]) + accepted_chunked = HttpTransport(connection_factory=lambda *args, **kwargs: chunked).request( + "POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8 + ) + self.assertEqual(accepted_chunked.body, b"{}") + + cases = ( + ([('Transfer-Encoding', 'chunked'), ('Content-Length', '2')], b'{}'), + ([('Transfer-Encoding', 'gzip')], b'{}'), + ([('Transfer-Encoding', ' chunked ')], b'{}'), + ([('Transfer-Encoding', 'chunked,gzip')], b'{}'), + ([('Transfer-Encoding', 'chunked'), ('Transfer-Encoding', 'chunked')], b'{}'), + ([("Content-Length", "2"), ("Content-Length", "2")], b"{}"), + ([("Content-Length", "+2")], b"{}"), + ([("Content-Length", "-0")], b""), + ([("Content-Length", "2x")], b"{}"), + ([("Content-Length", "3")], b"{}"), + ([("Content-Length", "1")], b"{}"), + ([("Content-Length", "999")], b"{}"), + ([], b"0123456789"), + ) + for response_headers, body in cases: + with self.subTest(headers=response_headers, body=body): + connection = FakeConnection("127.0.0.1", 8080, 10) + connection.response = FakeResponse(body, response_headers) + with self.assertRaises(AmbiguousRemoteError): + HttpTransport(connection_factory=lambda *args, value=connection, **kwargs: value).request( + "POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8 + ) + + def test_timeout_incomplete_read_and_close_do_not_expose_partial_body(self) -> None: + token = ("a" * 64).encode() + + class Incomplete(FakeResponse): + def read(self, maximum: int) -> bytes: + raise http.client.IncompleteRead(token, 1) + + class Connection(FakeConnection): + def getresponse(self) -> FakeResponse: + return Incomplete() + + def close(self) -> None: + self.closed = True + raise OSError("close failed") + + with self.assertRaises(AmbiguousRemoteError) as captured: + HttpTransport(connection_factory=Connection).request( + "POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=128 + ) + self.assertNotIn(token.decode(), _exception_graph(captured.exception)) + + class Timeout(FakeConnection): + def getresponse(self) -> FakeResponse: + raise TimeoutError("timed out") + + with self.assertRaises(AmbiguousRemoteError): + HttpTransport(connection_factory=Timeout).request( + "POST", "/api/v1/tasks/claim-next", (), b"{}", response_limit=8 + ) + + +def _exception_graph(error: BaseException) -> str: + seen: set[int] = set() + values: list[str] = [] + pending: list[object] = [error] + while pending: + value = pending.pop() + if id(value) in seen: + continue + seen.add(id(value)) + values.append(repr(value)) + if isinstance(value, BaseException): + pending.extend(item for item in (value.__cause__, value.__context__) if item is not None) + pending.extend(value.__dict__.values()) + return "\n".join(values) diff --git a/client/tests/remote/test_task_source.py b/client/tests/remote/test_task_source.py new file mode 100644 index 0000000..4708695 --- /dev/null +++ b/client/tests/remote/test_task_source.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import json +import unittest + +from cmbuyer_client.core.errors import AmbiguousRemoteError, CredentialRemoteError, ManualRemoteError, ProtocolRemoteError +from cmbuyer_client.core.models import ClaimRequest, DeviceCredentials, RenewRequest, SecretToken +from cmbuyer_client.remote.http_transport import HttpResponse +from cmbuyer_client.remote.task_source import HttpTaskSource +from tests.core.test_models import ATTEMPT_ID, TASK_ID, TOKEN, claim_wire +from tests.remote.test_http_transport import _exception_graph + + +DEVICE_ID = "e3c9f507-7473-4fa6-8d71-8786c34c6301" +SESSION_ID = "23c9f507-7473-4fa6-8d71-8786c34c6301" +REQUEST_ID = "33c9f507-7473-4fa6-8d71-8786c34c6301" +RENEW_ID = "43c9f507-7473-4fa6-8d71-8786c34c6301" + + +class FakeTransport: + def __init__(self, response: HttpResponse) -> None: + self.response = response + self.calls: list[tuple[object, ...]] = [] + + def request(self, *args: object, **kwargs: object) -> HttpResponse: + self.calls.append(args + (kwargs,)) + return self.response + + +def response(status: int, value: object | None = None) -> HttpResponse: + body = b"" if value is None else json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode() + headers = () if value is None else (("Content-Type", "application/json; charset=utf-8"),) + return HttpResponse(status, headers, body) + + +class TaskSourceTests(unittest.TestCase): + def setUp(self) -> None: + self.credentials = DeviceCredentials(DEVICE_ID, SecretToken(TOKEN)) + + def test_claim_success_and_empty_each_send_once_with_exact_headers(self) -> None: + transport = FakeTransport(response(200, claim_wire())) + claimed = HttpTaskSource(transport).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)) + self.assertEqual(claimed.task.id, TASK_ID) + self.assertEqual(len(transport.calls), 1) + args = transport.calls[0] + self.assertEqual(args[1], "/api/v1/tasks/claim-next") + headers = dict(args[2]) + self.assertEqual(headers["Authorization"], "Bearer " + TOKEN) + self.assertEqual(headers["X-CMBuyer-Device-ID"], DEVICE_ID) + + empty = FakeTransport(response(204)) + self.assertIsNone(HttpTaskSource(empty).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID))) + self.assertEqual(len(empty.calls), 1) + + def test_invalid_2xx_is_unknown_and_redirect_is_not_followed(self) -> None: + malformed = FakeTransport(HttpResponse(200, (("Content-Type", "application/json"),), b'{"task":')) + with self.assertRaises(AmbiguousRemoteError): + HttpTaskSource(malformed).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)) + self.assertEqual(len(malformed.calls), 1) + + secret_body = b'{"claim_token":"' + TOKEN.encode() + leaking = FakeTransport(HttpResponse(200, (("Content-Type", "application/json"),), secret_body)) + with self.assertRaises(AmbiguousRemoteError) as captured: + HttpTaskSource(leaking).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)) + self.assertNotIn(TOKEN, _exception_graph(captured.exception)) + + for status in (201, 202, 206): + with self.subTest(status=status), self.assertRaises(AmbiguousRemoteError): + HttpTaskSource(FakeTransport(response(status, claim_wire()))).claim_next( + self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID) + ) + + redirect = FakeTransport(HttpResponse(302, (("Location", "http://example.invalid"),), b"")) + with self.assertRaises(ProtocolRemoteError): + HttpTaskSource(redirect).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)) + self.assertEqual(len(redirect.calls), 1) + + def test_fixed_conflict_and_renew_cas(self) -> None: + conflict = FakeTransport(response(409, {"error": "claim_requires_manual"})) + with self.assertRaises(ManualRemoteError): + HttpTaskSource(conflict).claim_next(self.credentials, ClaimRequest(SESSION_ID, REQUEST_ID)) + + request = RenewRequest(TASK_ID, RENEW_ID, SESSION_ID, ATTEMPT_ID, 1, SecretToken(TOKEN), "2026-08-04T09:05:00Z", "2026-08-04T10:00:00Z") + renewed = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T09:06:00Z"}) + result = HttpTaskSource(FakeTransport(renewed)).renew(self.credentials, request) + self.assertEqual(result.claim_generation, 1) + + capped = RenewRequest(TASK_ID, RENEW_ID, SESSION_ID, ATTEMPT_ID, 1, SecretToken(TOKEN), "2026-08-04T10:00:00.000000000Z", "2026-08-04T10:00:00Z") + capped_result = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T10:00:00Z"}) + self.assertEqual(HttpTaskSource(FakeTransport(capped_result)).renew(self.credentials, capped).lease_expires_at, "2026-08-04T10:00:00Z") + + beyond_cap = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T10:00:00.000000001Z"}) + with self.assertRaises(AmbiguousRemoteError): + HttpTaskSource(FakeTransport(beyond_cap)).renew(self.credentials, request) + + stale = response(200, {"task_id": TASK_ID, "attempt_id": ATTEMPT_ID, "claim_generation": 1, "lease_expires_at": "2026-08-04T09:04:00Z"}) + with self.assertRaises(AmbiguousRemoteError): + HttpTaskSource(FakeTransport(stale)).renew(self.credentials, request) + for status in (201, 204): + with self.subTest(status=status), self.assertRaises(AmbiguousRemoteError): + HttpTaskSource(FakeTransport(response(status, None if status == 204 else { + "task_id": TASK_ID, + "attempt_id": ATTEMPT_ID, + "claim_generation": 1, + "lease_expires_at": "2026-08-04T09:06:00Z", + }))).renew(self.credentials, request) + + def test_claim_and_renew_error_status_matrix(self) -> None: + claim_request = ClaimRequest(SESSION_ID, REQUEST_ID) + claim_cases = ( + (HttpResponse(401, (), b""), CredentialRemoteError), + (response(400, {"error": "invalid_request"}), ProtocolRemoteError), + (HttpResponse(403, (), b""), ProtocolRemoteError), + (response(413, {"error": "request_too_large"}), ProtocolRemoteError), + (response(415, {"error": "unsupported_media_type"}), ProtocolRemoteError), + (HttpResponse(500, (), b""), AmbiguousRemoteError), + (HttpResponse(503, (), b""), AmbiguousRemoteError), + (HttpResponse(418, (), b""), ProtocolRemoteError), + ) + for wire_response, expected in claim_cases: + with self.subTest(status=wire_response.status), self.assertRaises(expected): + HttpTaskSource(FakeTransport(wire_response)).claim_next(self.credentials, claim_request) + + renew_request = RenewRequest( + TASK_ID, + RENEW_ID, + SESSION_ID, + ATTEMPT_ID, + 1, + SecretToken(TOKEN), + "2026-08-04T09:05:00Z", + "2026-08-04T10:00:00Z", + ) + for code in ("idempotency_conflict", "claim_not_current"): + with self.subTest(code=code), self.assertRaises(ManualRemoteError): + HttpTaskSource(FakeTransport(response(409, {"error": code}))).renew(self.credentials, renew_request) diff --git a/client/tests/test_logging_policy.py b/client/tests/test_logging_policy.py index ec72396..d3bbb16 100644 --- a/client/tests/test_logging_policy.py +++ b/client/tests/test_logging_policy.py @@ -47,3 +47,24 @@ class LoggingPolicyTests(unittest.TestCase): self.assertNotIn("not-for-log", content) self.assertNotIn("13900139000", content) self.assertIn("[已隐藏]", content) + + def test_bearer_bare_token_and_traceback_are_redacted(self) -> None: + token = "a" * 64 + with tempfile.TemporaryDirectory() as directory: + paths = RuntimePaths.from_root(Path(directory)) + logger = configure_application_logger(paths) + try: + try: + raise RuntimeError("credential=" + token) + except RuntimeError: + logger.exception("Authorization: Bearer %s bare=%s", token, token) + for handler in logger.handlers: + handler.flush() + content = (paths.logs / "client.log").read_text(encoding="utf-8") + finally: + for handler in tuple(logger.handlers): + logger.removeHandler(handler) + handler.close() + + self.assertNotIn(token, content) + self.assertIn("[已隐藏]", content) diff --git a/client/tests/test_runtime.py b/client/tests/test_runtime.py index d157ae0..f772051 100644 --- a/client/tests/test_runtime.py +++ b/client/tests/test_runtime.py @@ -2,16 +2,18 @@ from __future__ import annotations +import os import sys from pathlib import Path import tempfile import unittest +from unittest import mock CLIENT_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(CLIENT_ROOT / "src")) -from cmbuyer_client.runtime import RuntimePaths +from cmbuyer_client.runtime import LocalStateRuntime, RuntimePaths class RuntimePathsTests(unittest.TestCase): @@ -23,3 +25,58 @@ class RuntimePathsTests(unittest.TestCase): self.assertTrue(paths.logs.is_dir()) self.assertTrue(paths.artifacts.is_dir()) + self.assertTrue(paths.state.is_dir()) + self.assertEqual(paths.database, paths.state / "client-state.sqlite3") + + def test_localstate_runtime_acquires_mutex_before_protector_and_store(self) -> None: + events: list[str] = [] + + class Mutex: + def __init__(self, path: Path) -> None: + events.append("mutex") + + def close(self) -> None: + events.append("close") + + with tempfile.TemporaryDirectory() as directory: + runtime = LocalStateRuntime.open( + RuntimePaths.from_root(Path(directory)), + mutex_factory=Mutex, + protector_factory=lambda: events.append("protector") or object(), + store_factory=lambda path, protector: events.append("store") or object(), + ) + runtime.close() + self.assertEqual(events, ["mutex", "protector", "store", "close"]) + + def test_localstate_runtime_releases_mutex_if_open_fails(self) -> None: + events: list[str] = [] + + class Mutex: + def __init__(self, path: Path) -> None: + events.append("mutex") + + def close(self) -> None: + events.append("close") + + def fail() -> object: + raise RuntimeError("failed") + + with tempfile.TemporaryDirectory() as directory, self.assertRaises(RuntimeError): + LocalStateRuntime.open( + RuntimePaths.from_root(Path(directory)), + mutex_factory=Mutex, + protector_factory=fail, + ) + self.assertEqual(events, ["mutex", "close"]) + + def test_windows_without_localappdata_fails_instead_of_creating_second_database(self) -> None: + with mock.patch("cmbuyer_client.runtime.os.name", "nt"), mock.patch.dict(os.environ, {}, clear=True): + with self.assertRaisesRegex(RuntimeError, "local_app_data_required"): + RuntimePaths.default() + + def test_runtime_root_is_frozen_absolute_and_relative_localappdata_is_rejected(self) -> None: + paths = RuntimePaths.from_root(Path("relative-runtime")) + self.assertTrue(paths.root.is_absolute()) + with mock.patch.dict(os.environ, {"LOCALAPPDATA": "relative-local-app-data"}, clear=True): + with self.assertRaisesRegex(RuntimeError, "local_app_data_must_be_absolute"): + RuntimePaths.default() diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 8141589..b84372f 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -31,7 +31,10 @@ | 传输 | ADB(USB 或 WiFi) | 已定 | `uiautomator2` 3.x 走 adb 通道,`ip:port` 与 USB serial 同等对待 | | 桌面 GUI | `PySide6` | 已定 | 前序项目已验证;执行员需要看设备状态和批次进度 | | 截图处理 | `Pillow` | 已定 | 判断页面是否渲染完成,避免保存白屏壳层 | -| HTTP 客户端 | 标准库 `urllib` 或 `httpx` | **待定** | 先用标准库;确有重试/连接池需求再评估 | +| HTTP 客户端 | 标准库 `http.client` | 已定 | 只直连 `127.0.0.1:8080`;不读代理、不跟随重定向、不做隐藏重试或连接池 | +| 可恢复状态 | 标准库 `sqlite3`(WAL / FULL) | 已定 | request/slot 先落库再 HTTP;每个操作独立连接,恢复读取使用单一事务快照 | +| 秘密保护 | Windows Current User DPAPI | 已定 | token 原始 32 字节只以绑定 profile+device/attempt context 的密文 BLOB 入库;非 Windows 不降级 | +| 单实例 | Windows `Global\` named mutex | 已定 | 以规范数据库路径 hash 命名,先于 DPAPI/SQLite 取得,覆盖同用户跨 session | | Excel | 不引入 | 已定 | Excel 解析移到采购服务;采购工具不再直接读表 | | 测试 | `unittest`(标准库) | 已定 | 前序项目 171 项测试均用标准库,无需 pytest | | 打包 | `pyinstaller` | 已定 | 交付给运营电脑;开发期依赖 | @@ -55,6 +58,10 @@ Go 侧重写表头校验和行级报错,不能直接复用前序项目的 Python 实现。 - **采购工具不持有业务权威。** 金额上限、授权有效性、任务状态流转的判定权在采购服务; 采购工具本地校验只作为第二道防线,两边不一致时一律转人工。 +- **HTTP 不做自动重试。** claim/renew/evidence 的重放权属于持有 durable 幂等槽的恢复门面;底层每次 + 方法最多一个请求。401 修复 Bearer 后、结果不明或重启恢复都必须复用原 key 与原 body/file。 +- **客户端 SQLite 只保存恢复事实。** append-only session/claim/renew/evidence history 不是服务端任务 + 权威;它的作用是阻止崩溃、并发或状态损坏导致第二次领取、换图或换 key。 - **不引入 pytest / 不引入 ORM。** 同一职责不并存两套方案。 ## 五、构建与运行命令 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 792c55b..5ea68e9 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -381,6 +381,24 @@ DRAFT / PENDING / NEEDS_MANUAL ─管理员取消(围栏前)→ CANCELED - 围栏建立后即使租约过期也只恢复同一 `order_submission` 的调和,不能回到可领取队列。 - 每种非终态都必须给出安全下一步,不能出现隐藏表单导致任务永久锁死。 +#### 采购工具本地恢复状态 + +- 生产入口先取得基于规范数据库路径的 Windows `Global\` named mutex,再构造 DPAPI 和 SQLite;不能 + 以服务端“单设备最多一个 claim”替代本机单实例。 +- profile、polling session、claim/renew request、open/historical claim、evidence marker/slot 使用 WAL、 + `synchronous=FULL` 与 append-only/单调关闭约束。当前 session/claim 由 `closed_at IS NULL` partial + unique 保证唯一,历史关闭后不阻塞下一条,但不得删除或复活。 +- `DurableClientGateway` 是轮询和截图接入的唯一顺序入口:先 durable prepare,再一次 HTTP,最后原子 + commit。停止只把当前 session 的 `accept_new` 设为 false;飞行中响应仍提交,pending/open 不清除。 +- 每次发送前校验 profile/session/request/active immutable business snapshot、renew history、evidence marker/slot/ + receipt 的完整状态图;snapshot、renew response 与 evidence receipt 还保存不可变摘要。任一冗余事实不一致、 + DPAPI context 不匹配、数据库缺行或文件 identity 变化时零 HTTP 停止,不能自行“修复”。 +- 设备 Bearer 的 401 发生在服务端读取 body/写幂等事实前,因此槽保持 `PENDING`;只允许同 device id + 更新 Bearer,并在用户再次开始后用原 key/body/file 尝试。claim token 和其他 frozen 配置不变。 +- 原始 token 以 DPAPI current-user context 密文保存:device token 绑定 profile+device,claim/renew token + 绑定 profile+attempt,密文不能跨行复用;日志 formatter 对 Bearer、裸 64 位 token 和 + traceback 做最终脱敏。异常对象也只保留固定 reason,不挂接含响应 body/partial/path 的异常上下文。 + ### 5.4 证据分层 | 数据 | 位置 | 边界 | @@ -458,7 +476,8 @@ cmbuyer/ └── scripts/ ``` -执行器依赖 `TaskSource` / `ResultSink`,不直接读取 Excel 或拼接 HTTP。来源变化不得改变安全执行器。 +执行器依赖核心端口,不直接读取 Excel 或拼接 HTTP。T-303 只提供 `TaskSource` / `EvidenceSink`;完整 +`ResultSink` 在服务端 events/fail/fence/result 契约落地后分阶段组合,来源变化不得改变安全执行器。 ## 九、架构纪律 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index e5ddade..0282eec 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -90,7 +90,7 @@ T-106 / T-107 是发布前只读验证,不是业务任务的第一趟。首次 | --- | --- | --- | --- | | T-301 | 设备凭据与身份隔离(F-013) | T-201 | Bearer 不能建单/授权;管理会话不能领任务;凭据可撤销 | | T-302 | 已授权任务原子领取与租约(F-005) | T-301, T-203 | 只领 PENDING+有效授权;并发唯一;重放同一 attempt;claim token/generation 有效 | -| T-303 | HTTP 任务源、证据 sink 与可恢复本地状态 | T-002, T-204, T-302 | 严格 claim/renew/evidence HTTP、DPAPI/SQLite、单实例和原子恢复槽;不伪造完整 ResultSink | +| T-303 | HTTP 任务源、证据 sink 与可恢复本地状态 | T-002, T-204, T-302 | `http.client` 单次回环请求、DPAPI/SQLite append-only 状态图、Global 单实例和 durable gateway;不伪造完整 ResultSink | | T-304 | 定时轮询与会话边界 | T-303, T-006 | 人启动后轮询;停止只阻止下次领取;连续失败停;当前任务/记录详情双视图 | | T-306 | 规格面板原始截图与可靠证据上传 | T-104, T-303 | 只发布 Gate1 的显式原始 PNG;唯一恢复槽;可含页面地址/手机号;不上传 XML/路径或扩 kind | | T-307 | 客户端 attempt 事件与失败 sink | T-205, T-303 | 窄 events/fail HTTP 与同键恢复;不实现 ResultSink、围栏、结果、PDD 或 UI | diff --git a/docs/api.md b/docs/api.md index c2bb03b..81c9a95 100644 --- a/docs/api.md +++ b/docs/api.md @@ -372,23 +372,48 @@ claim/renew 的格式错误固定为 `400 {"error":"invalid_request"}`,超限 ## 三、采购工具本地模块合约 -### `TaskSource` / `ResultSink` +### T-303 已实现端口 ```python class TaskSource(Protocol): - def claim_next(self, session: Session) -> ClaimedPurchase | None: ... - def renew_lease(self, claim: Claim) -> Lease: ... + def claim_next(self, credentials: DeviceCredentials, request: ClaimRequest) -> ClaimedTask | None: ... + def renew(self, credentials: DeviceCredentials, request: RenewRequest) -> RenewResult: ... -class ResultSink(Protocol): - def append_events(self, claim: Claim, events: list[TaskEvent]) -> None: ... - def upload_screenshot(self, claim: Claim, asset: ScreenshotAsset) -> AssetRef: ... - def fail_attempt(self, claim: Claim, failure: AttemptFailure) -> None: ... - def create_submission_fence(self, claim: Claim, proof: SubmissionProof) -> SubmissionPermit: ... - def report_submission_result(self, permit: SubmissionPermit, result: SubmissionResult) -> None: ... +class EvidenceSink(Protocol): + def upload(self, credentials: DeviceCredentials, evidence: EvidenceUpload) -> AssetReceipt: ... ``` -执行器不能依赖具体 HTTP 或 Excel 实现。`SubmissionPermit` 只能由 `ResultSink` 的服务端成功响应构造, -业务代码不能手工 new 一个许可。 +T-303 只实现 claim/renew 与 `SKU_PANEL_GATE_1` 单张 PNG;不得用运行时 `NotImplementedError` 伪造 +events/fail/fence/result 或完整 `ResultSink`。T-304/T-306 必须通过 `DurableClientGateway` 调用:它先把 +同一个 request/upload key 与完整载荷写入 SQLite,再最多发送一次 HTTP;401 保留 `PENDING`,由用户修复 +同一 device id 的 Bearer 后显式重放;网络、超时、503、截断、非法/未知 2xx 同样只保留原槽。协议/409 +终止槽但不换 key。成功响应落库失败时,重启仍用原 key 向服务端恢复事实。 + +金额按服务端合法域接受规范 ASCII 十进制正数字符串(最低 `0.01`,恰好两位小数、无前导零);wire +整数为正 int64,拒绝 bool。claim 成功响应总上限 32 KiB;因此客户端不额外发明 goods/title/SKU/金额 +的单字段业务上限。RFC3339Nano 按 0--9 位小数的纳秒时间轴比较,不能用 Python 微秒精度截断。 + +### 本地恢复合约 + +- 数据库固定为 `%LOCALAPPDATA%\cmbuyer\state\client-state.sqlite3`,WAL + `synchronous=FULL`;Windows + 缺少 `LOCALAPPDATA` 时失败,不回退到 home 创建第二套状态。 +- device token 与 claim token 的原始 32 字节只以当前用户 DPAPI 密文 BLOB 入库;前者 context 绑定 + profile+device,后者绑定 profile+attempt,跨行交换密文会解密失败。claim token 不可更换;有 + pending/open 状态时冻结 service/device/ADB/transport/轮询与超时配置,仅允许同 device id 修复 Bearer; + idle 时切换 device id 也必须同时提供新 token。 +- `Global\cmbuyer-` named mutex 在任何 SQLite/DPAPI 打开前取得;同一状态库跨 Windows + session 只允许一个采购工具进程。 +- 运行根目录与数据库路径在构造时固化为绝对路径;之后 cwd 改变不得打开第二套库或绕过原 mutex。 +- polling session、claim history、renew request 和 evidence marker/slot 都是 append-only 历史;当前行用 + `closed_at IS NULL` partial unique 表示。恢复或发送前在同一 SQLite 读快照校验整张状态图,冗余列、 + snapshot、request、claim token、marker、slot、receipt 任一不一致都零 HTTP 失败闭合。 +- evidence 槽唯一键是 `(attempt_id, kind)`。首次发送前固定显式路径的 regular/non-reparse 文件 identity、 + size、mtime、SHA-256、IHDR 尺寸与字节;pending 时变化即停,并始终用首次保存的 exact metadata 重放 + 相同 multipart。成功后 receipt 成为事实,源文件变化或删除只返回原 receipt,不再次上传;receipt + 尺寸必须与本地 IHDR 一致。客户端做签名/IHDR/尺寸/hash 防御;采购服务仍负责完整 PNG 解码权威校验。 + +完整 `ResultSink` 只有在 T-205/T-208 服务端契约完成后才由后续任务组合;`SubmissionPermit` 只能由 +服务端首次明确成功响应构造,业务代码不能手工创建。 ### 真机能力分层 diff --git a/docs/tasks/T-303.md b/docs/tasks/T-303.md index 8e071c2..19bff75 100644 --- a/docs/tasks/T-303.md +++ b/docs/tasks/T-303.md @@ -28,7 +28,7 @@ write_paths: - docs/06-tasks.md --- - + ## 问题 / 背景 T-302 完成后,采购工具需要真实、安全、可恢复地领取/续租任务;T-204 已提供受控截图上传接口。但 client 当前只有真机能力和最小窗口,没有 HTTP 抽象、设备凭据存储或断网/重启幂等状态。T-303 建立 `HttpTaskSource`、窄 `HttpEvidenceSink` 与 Windows 本地恢复底座;事件/fail/fence/result 尚分别依赖 T-205/T-208,本任务不伪造完整 `HttpResultSink` 或占位请求。 @@ -78,6 +78,10 @@ T-302 完成后,采购工具需要真实、安全、可恢复地领取/续租 ### 2026-08-04T15:25:33Z · ila 2026-08-04 开始 T-303:基于 main@3a0a41d,在独立 worktree 实现客户端 HTTP 任务源、证据 sink 与可恢复本地状态;严格限制为 localhost 服务端契约,不触碰真机、页面操作、提交订单或付款能力。 + +### 2026-08-04T16:54:36Z · ila + +2026-08-04 T-303 实现完成并进入审阅冻结:已落地严格 localhost HttpTaskSource/HttpEvidenceSink、durable gateway、DPAPI 身份绑定密文、SQLite append-only 状态图与 Global named mutex。claim/renew/evidence 均先持久化后最多一次 HTTP;business snapshot、renew response、evidence receipt 使用不可变约束与摘要,renew 从初始租约锚重放;证据绑定 PNG IHDR 尺寸并精确重放首次 metadata。独立对抗审计已通过。验证:client unittest 189/189,compileall、diff-check、agent-context 通过;根 init.ps1 通过 admin test/vet/build、client install/test/compile 与上下文门禁。任务保持 DOING,等待大脑固定提交复审,不标 DONE。 ## 边界