feat(client): add durable HTTP task state

This commit is contained in:
QiuSW
2026-08-05 00:59:32 +08:00
parent 488005ac93
commit 31f07ac245
37 changed files with 4654 additions and 20 deletions
@@ -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",
]
+47
View File
@@ -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):
"""服务端要求人工处理的确定性冲突。"""
+327
View File
@@ -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=[已隐藏])"
+17
View File
@@ -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: ...
@@ -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
@@ -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",
]
@@ -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
@@ -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)
@@ -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")
@@ -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()
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -23,6 +23,8 @@ _KEY_VALUE_PATTERN = re.compile(
flags=re.IGNORECASE,
)
_PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
_BEARER_PATTERN = re.compile(r"(?i)\bBearer\s+[0-9a-f]{64}\b")
_BARE_TOKEN_PATTERN = re.compile(r"(?<![0-9a-fA-F])[0-9a-fA-F]{64}(?![0-9a-fA-F])")
def redact_text(message: str) -> 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
@@ -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"]
@@ -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)
@@ -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
@@ -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
+106
View File
@@ -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")
+62 -2
View File
@@ -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()