feat(client): capture T-104 safe exit evidence
This commit is contained in:
@@ -21,7 +21,13 @@ from adbutils.errors import AdbTimeout
|
||||
from uiautomator2.exceptions import HTTPTimeoutError
|
||||
|
||||
from ..device.adb import AdbClient, DeviceConnectionError, DeviceInspection
|
||||
from ..device.baseline import PDD_PACKAGE, SCREENSHOT_PARAMS, _save_base64_screenshot, _sha256_file
|
||||
from ..device.baseline import (
|
||||
PDD_PACKAGE,
|
||||
SCREENSHOT_PARAMS,
|
||||
_save_base64_screenshot,
|
||||
_sha256_file,
|
||||
_validate_hierarchy,
|
||||
)
|
||||
from .product_open import EXPECTED_PDD_VERSION
|
||||
from .product_url import ProductUrl, ProductUrlError, parse_product_url
|
||||
from .sku_selection import (
|
||||
@@ -37,7 +43,9 @@ from .sku_selection import (
|
||||
_TARGET_SIZE_BOUNDS,
|
||||
_action_bounds,
|
||||
_annotate_sku_entry_failure,
|
||||
_parse_nodes,
|
||||
_safe_sku_entry_failure_stage,
|
||||
_unit_price,
|
||||
resolve_task_selection,
|
||||
)
|
||||
|
||||
@@ -86,6 +94,10 @@ class SkuSelectionDeviceAdapterError(SkuSelectionRunError):
|
||||
"""第三方设备接口失败的脱敏映射。"""
|
||||
|
||||
|
||||
class SkuExitSpikeError(SkuSelectionRunError):
|
||||
"""T-104 阶段 A 未形成完整的本机退出证据。"""
|
||||
|
||||
|
||||
def safe_failure_stage(error: BaseException) -> str:
|
||||
"""返回允许公开的固定阶段码,绝不回显异常正文。"""
|
||||
|
||||
@@ -138,6 +150,14 @@ class SkuSelectionRunResult:
|
||||
unit_price: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkuExitSpikeResult:
|
||||
"""已原子发布、仍需人工判断页面身份的 T-104 证据。"""
|
||||
|
||||
output_directory: Path
|
||||
manifest_path: Path
|
||||
|
||||
|
||||
class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
"""把 uiautomator2 缩为 T-103 所需的读取与四种命名操作。
|
||||
|
||||
@@ -292,6 +312,210 @@ class UiautomatorSkuPanelAdapter(SkuPanelDevice):
|
||||
raise SkuSelectionDeviceAdapterError("规格面板设备操作失败,已停止操作。") from error
|
||||
|
||||
|
||||
class UiautomatorSkuExitAdapter:
|
||||
"""T-104 阶段 A 的窄设备边界:只读能力加唯一一次命名 Back。
|
||||
|
||||
取证脚本不能取得 T-103 的入口、规格选项或 reveal 方法。Back 的唯一机会在
|
||||
JSON-RPC 前封存,因为超时无法证明事件没有送达,任何结果不明都不得重发。
|
||||
"""
|
||||
|
||||
def __init__(self, device: Any, timeout_seconds: float) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._device = device
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._back_attempted = False
|
||||
self._back_rpc_outcome = "not_attempted"
|
||||
|
||||
@property
|
||||
def back_attempts(self) -> int:
|
||||
return int(self._back_attempted)
|
||||
|
||||
@property
|
||||
def back_rpc_outcome(self) -> str:
|
||||
return self._back_rpc_outcome
|
||||
|
||||
def app_info(self, package_name: str) -> dict[str, Any]:
|
||||
value = self._call("app_info", package_name)
|
||||
if not isinstance(value, dict):
|
||||
raise SkuSelectionDeviceAdapterError("无法读取应用版本,已停止取证。")
|
||||
return value
|
||||
|
||||
def app_current(self) -> dict[str, Any]:
|
||||
value = self._call("app_current")
|
||||
if not isinstance(value, dict):
|
||||
raise SkuSelectionDeviceAdapterError("无法读取前台应用,已停止取证。")
|
||||
return value
|
||||
|
||||
def dump_window_hierarchy(self) -> str:
|
||||
value = self._call(
|
||||
"jsonrpc_call",
|
||||
"dumpWindowHierarchy",
|
||||
[False, 50],
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
if not isinstance(value, str):
|
||||
raise SkuSelectionDeviceAdapterError("节点树读取失败,已停止取证。")
|
||||
return value
|
||||
|
||||
def capture_screenshot(self) -> str:
|
||||
value = self._call(
|
||||
"jsonrpc_call",
|
||||
"takeScreenshot",
|
||||
SCREENSHOT_PARAMS,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
if not isinstance(value, str):
|
||||
raise SkuSelectionScreenshotError("退出后截图读取失败,未发布任何证据产物。")
|
||||
return value
|
||||
|
||||
def display_size(self) -> tuple[int, int]:
|
||||
value = self._call("window_size")
|
||||
if (
|
||||
not isinstance(value, tuple)
|
||||
or len(value) != 2
|
||||
or any(not isinstance(item, int) for item in value)
|
||||
):
|
||||
raise SkuSelectionDeviceAdapterError("无法读取屏幕坐标空间,已停止取证。")
|
||||
return value
|
||||
|
||||
def leave_sku_panel(self) -> None:
|
||||
if self._back_attempted:
|
||||
raise SkuSelectionDeviceAdapterError("本次取证已经尝试过返回,拒绝重试。")
|
||||
self._back_attempted = True
|
||||
self._back_rpc_outcome = "ambiguous"
|
||||
self._call(
|
||||
"jsonrpc_call",
|
||||
"pressKey",
|
||||
["back"],
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
self._back_rpc_outcome = "completed"
|
||||
|
||||
def _call(self, method: str, *args: Any, **kwargs: Any) -> Any:
|
||||
try:
|
||||
operation = getattr(self._device, method)
|
||||
return operation(*args, **kwargs)
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
raise SkuSelectionRunTimeoutError("T-104 设备操作超时,已停止操作。") from error
|
||||
except SkuSelectionRunError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise SkuSelectionDeviceAdapterError("T-104 设备操作失败,已停止操作。") from error
|
||||
|
||||
|
||||
class SkuExitSpikeCapturer:
|
||||
"""从人工停驻的已验证目标面板执行一次 Back,再只读采集阶段 A 证据。"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adb_client: AdbClient,
|
||||
connector: Callable[[str], Any],
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
if not _is_positive_finite(timeout_seconds):
|
||||
raise ValueError("timeout_seconds 必须是大于 0 的有限数值")
|
||||
self._adb_client = adb_client
|
||||
self._connector = connector
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._started = False
|
||||
|
||||
def capture(self, serial: str, output_directory: Path) -> SkuExitSpikeResult:
|
||||
if self._started:
|
||||
raise SkuExitSpikeError("同一取证器不可重复调用。")
|
||||
self._started = True
|
||||
|
||||
staging: Path | None = None
|
||||
adapter: UiautomatorSkuExitAdapter | None = None
|
||||
try:
|
||||
if type(serial) is not str or not serial.strip():
|
||||
raise SkuExitSpikeError("必须显式提供非空设备通道。")
|
||||
target = Path(output_directory)
|
||||
_validate_new_target(target)
|
||||
staging = _prepare_staging(target)
|
||||
|
||||
inspection = self._adb_client.inspect(serial)
|
||||
_require_expected_device(inspection)
|
||||
adapter = UiautomatorSkuExitAdapter(
|
||||
self._connector(serial),
|
||||
self._timeout_seconds,
|
||||
)
|
||||
|
||||
# 第一次只读核验拒绝把任意页面带入 Back 边界;第二次紧邻 Back,覆盖核验期间漂移。
|
||||
_require_t104_exit_precondition(adapter)
|
||||
_require_t104_exit_precondition(adapter)
|
||||
|
||||
rpc_outcome = "completed"
|
||||
try:
|
||||
adapter.leave_sku_panel()
|
||||
except SkuSelectionRunError:
|
||||
if adapter.back_attempts != 1 or adapter.back_rpc_outcome != "ambiguous":
|
||||
raise
|
||||
# RPC 失败不能证明 Back 未送达。只读采集可供人调和,但绝不再发第二次动作。
|
||||
rpc_outcome = "ambiguous_reconciled"
|
||||
|
||||
if adapter.back_attempts != 1:
|
||||
raise SkuExitSpikeError("返回动作审计不完整,未发布任何证据产物。")
|
||||
if rpc_outcome == "completed" and adapter.back_rpc_outcome != "completed":
|
||||
raise SkuExitSpikeError("返回动作结果不完整,未发布任何证据产物。")
|
||||
|
||||
# Back 后先确认仍是已取证版本的 PDD;不能先把其他前台应用写进本地证据。
|
||||
initial_app = _require_t104_post_app(adapter)
|
||||
|
||||
screenshot_path = staging / "post_exit_screenshot.png"
|
||||
_save_base64_screenshot(adapter.capture_screenshot(), screenshot_path)
|
||||
_require_screenshot_size(screenshot_path)
|
||||
|
||||
hierarchy = adapter.dump_window_hierarchy()
|
||||
_validate_hierarchy(hierarchy)
|
||||
hierarchy_path = staging / "post_exit_hierarchy.xml"
|
||||
hierarchy_path.write_text(hierarchy, encoding="utf-8")
|
||||
|
||||
current_app = _require_t104_post_app(adapter)
|
||||
if current_app != initial_app:
|
||||
raise SkuExitSpikeError("退出后应用摘要在采集期间漂移,未发布任何证据产物。")
|
||||
app_path = staging / "post_exit_app.json"
|
||||
app_path.write_text(
|
||||
json.dumps(current_app, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manifest_path = staging / "manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(
|
||||
_t104_exit_manifest(
|
||||
inspection,
|
||||
serial,
|
||||
rpc_outcome,
|
||||
screenshot_path,
|
||||
hierarchy_path,
|
||||
app_path,
|
||||
),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.rename(staging, target)
|
||||
staging = None
|
||||
except (DeviceConnectionError, SkuSelectionError, SkuSelectionRunError):
|
||||
_clean_staging(staging)
|
||||
raise
|
||||
except (AdbTimeout, HTTPTimeoutError, TimeoutError) as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuExitSpikeError("T-104 取证超时,未发布任何证据产物。") from error
|
||||
except OSError as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuExitSpikeError("T-104 证据目录无法发布,未发布任何证据产物。") from error
|
||||
except Exception as error:
|
||||
_clean_staging(staging)
|
||||
raise SkuExitSpikeError("T-104 取证未完成,未发布任何证据产物。") from error
|
||||
|
||||
return SkuExitSpikeResult(target, target / "manifest.json")
|
||||
|
||||
|
||||
class SkuSelectionRunner:
|
||||
"""只运行 T-103 目标规格恢复、价格确认、原始截图和一次安全退出。"""
|
||||
|
||||
@@ -470,6 +694,87 @@ def _require_expected_device(inspection: DeviceInspection) -> None:
|
||||
raise SkuSelectionRunError("设备型号或 Android 版本不是已取证组合,已停止操作。")
|
||||
|
||||
|
||||
def _require_t104_exit_precondition(adapter: UiautomatorSkuExitAdapter) -> None:
|
||||
"""fresh 核验 T-103 已人验的唯一 Back 前置,不产生任何页面动作。"""
|
||||
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
current = adapter.app_current()
|
||||
if current.get("package") != PDD_PACKAGE:
|
||||
raise SkuExitSpikeError("Back 前拼多多不在前台,已停止取证。")
|
||||
if adapter.display_size() != EXPECTED_SCREEN_SIZE:
|
||||
raise SkuExitSpikeError("屏幕坐标空间不是已取证尺寸,已停止取证。")
|
||||
if _unit_price(_parse_nodes(adapter.dump_window_hierarchy())) != EXPECTED_UNIT_PRICE:
|
||||
raise SkuExitSpikeError("Back 前目标规格或当前价不匹配,已停止取证。")
|
||||
|
||||
|
||||
def _post_exit_app_evidence(value: object) -> dict[str, str]:
|
||||
"""只保留页面身份复核需要的应用字段,不把第三方返回整体写盘。"""
|
||||
|
||||
if not isinstance(value, dict):
|
||||
raise SkuExitSpikeError("退出后应用摘要无效,未发布任何证据产物。")
|
||||
package = value.get("package")
|
||||
activity = value.get("activity")
|
||||
if not isinstance(package, str) or not package.strip():
|
||||
raise SkuExitSpikeError("退出后应用包名缺失,未发布任何证据产物。")
|
||||
if not isinstance(activity, str) or not activity.strip():
|
||||
raise SkuExitSpikeError("退出后 Activity 缺失,未发布任何证据产物。")
|
||||
return {"package": package, "activity": activity}
|
||||
|
||||
|
||||
def _require_t104_post_app(adapter: UiautomatorSkuExitAdapter) -> dict[str, str]:
|
||||
current = _post_exit_app_evidence(adapter.app_current())
|
||||
if current["package"] != PDD_PACKAGE:
|
||||
raise SkuExitSpikeError("Back 后拼多多不在前台,未采集页面证据。")
|
||||
_require_expected_version(adapter.app_info(PDD_PACKAGE))
|
||||
return current
|
||||
|
||||
|
||||
def _t104_exit_manifest(
|
||||
inspection: DeviceInspection,
|
||||
serial: str,
|
||||
rpc_outcome: str,
|
||||
screenshot_path: Path,
|
||||
hierarchy_path: Path,
|
||||
app_path: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""阶段 A 只写事实与哈希;不声明退出成功,也不授予价格证据角色。"""
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"captured_at": datetime.now(UTC).isoformat(),
|
||||
"operation": "t104-sku-exit-evidence",
|
||||
"product": {"goods_id": EXPECTED_GOODS_ID},
|
||||
"channel": "wifi" if ":" in serial else "usb",
|
||||
"serial_sha256": sha256(serial.encode("utf-8")).hexdigest(),
|
||||
"device": {
|
||||
"model": inspection.model,
|
||||
"android_version": inspection.android_version,
|
||||
"pdd_package": PDD_PACKAGE,
|
||||
"pdd_version": EXPECTED_PDD_VERSION,
|
||||
},
|
||||
"back_attempts": 1,
|
||||
"rpc_outcome": rpc_outcome,
|
||||
"post_exit_status": "human_review_required",
|
||||
"artifacts": [
|
||||
{
|
||||
"path": screenshot_path.name,
|
||||
"role": "post_exit_human_review_only",
|
||||
"sha256": _sha256_file(screenshot_path),
|
||||
},
|
||||
{
|
||||
"path": hierarchy_path.name,
|
||||
"role": "post_exit_raw_hierarchy_local_only",
|
||||
"sha256": _sha256_file(hierarchy_path),
|
||||
},
|
||||
{
|
||||
"path": app_path.name,
|
||||
"role": "post_exit_app_identity_human_review_only",
|
||||
"sha256": _sha256_file(app_path),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _require_screenshot_size(screenshot_path: Path) -> None:
|
||||
try:
|
||||
with Image.open(screenshot_path) as image:
|
||||
|
||||
Reference in New Issue
Block a user