From 7905fa0b70273d4b88654eba3238cb5f50315f52 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Tue, 4 Aug 2026 08:51:00 +0800 Subject: [PATCH] fix(client): accept whitespace in screenshot base64 --- client/src/cmbuyer_client/device/baseline.py | 13 ++-- client/tests/device/test_baseline.py | 74 +++++++++++++++++++- docs/03-tech-stack.md | 6 +- docs/current-state.md | 7 +- docs/tasks/T-101.md | 10 ++- 5 files changed, 97 insertions(+), 13 deletions(-) diff --git a/client/src/cmbuyer_client/device/baseline.py b/client/src/cmbuyer_client/device/baseline.py index b6e5529..a177b49 100644 --- a/client/src/cmbuyer_client/device/baseline.py +++ b/client/src/cmbuyer_client/device/baseline.py @@ -27,6 +27,7 @@ from .adb import AdbClient, DeviceInspection PDD_PACKAGE = "com.xunmeng.pinduoduo" SCREENSHOT_PARAMS = [1, 80] HIERARCHY_PARAMS = [False, 50] +_BASE64_ASCII_WHITESPACE = " \t\r\n" class BaselineCaptureError(RuntimeError): @@ -172,17 +173,21 @@ def _extract_version(app_info: dict[str, Any]) -> str: def _save_base64_screenshot(value: Any, target: Path) -> None: - """严格解码 RPC 截图,并用 Pillow 验证后保存 PNG;没有 adb fallback。""" + """规范化常见 ASCII Base64 空白后严格解码;没有 adb screenshot fallback。""" if not isinstance(value, str) or not value: raise BaselineCaptureError("截图 RPC 未返回 base64 数据,拒绝发布不完整取证。") try: - raw_image = base64.b64decode(value.encode("ascii"), validate=True) + normalized = value.translate({ord(character): None for character in _BASE64_ASCII_WHITESPACE}) + raw_image = base64.b64decode(normalized.encode("ascii"), validate=True) + except (UnicodeEncodeError, ValueError, binascii.Error) as error: + raise BaselineCaptureError("截图 RPC Base64 语法无效,拒绝发布不完整取证。") from error + try: with Image.open(BytesIO(raw_image)) as image: image.load() image.save(target, format="PNG") - except (UnicodeEncodeError, ValueError, binascii.Error, UnidentifiedImageError, OSError) as error: - raise BaselineCaptureError("截图 RPC 返回的数据不是有效图像,拒绝发布不完整取证。") from error + except (UnidentifiedImageError, OSError) as error: + raise BaselineCaptureError("截图 RPC 图像数据无效,拒绝发布不完整取证。") from error def _validate_hierarchy(value: Any) -> None: diff --git a/client/tests/device/test_baseline.py b/client/tests/device/test_baseline.py index 9505581..62a221d 100644 --- a/client/tests/device/test_baseline.py +++ b/client/tests/device/test_baseline.py @@ -153,22 +153,90 @@ class BaselineCaptureTests(unittest.TestCase): self.assertFalse(output.exists()) self.assertEqual(list(Path(directory).iterdir()), []) - def test_invalid_screenshot_base64_fails_closed_without_partial_output(self) -> None: + def test_invalid_screenshot_base64_syntax_fails_closed_without_partial_output(self) -> None: class InvalidScreenshotDevice(FakeUiDevice): def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: if method == "takeScreenshot": - return "not-valid-base64" + valid = super().jsonrpc_call(method, params, timeout) + return valid[:12] + "!" + valid[12:] return super().jsonrpc_call(method, params, timeout) capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: InvalidScreenshotDevice(), timeout_seconds=5) with tempfile.TemporaryDirectory() as directory: output = Path(directory) / "baseline" - with self.assertRaises(BaselineCaptureError): + with self.assertRaises(BaselineCaptureError) as raised: capturer.capture(SERIAL, output) + self.assertIn("Base64 语法无效", str(raised.exception)) + self.assertNotIn("!", str(raised.exception)) self.assertFalse(output.exists()) self.assertEqual(list(Path(directory).iterdir()), []) + def test_invalid_padding_and_unapproved_ascii_whitespace_fail_closed(self) -> None: + invalid_insertions = { + "padding": lambda value: value[:-1], + "vertical-tab": lambda value: value[:12] + "\v" + value[12:], + "form-feed": lambda value: value[:12] + "\f" + value[12:], + } + + for name, make_invalid in invalid_insertions.items(): + with self.subTest(name=name), tempfile.TemporaryDirectory() as directory: + class InvalidScreenshotDevice(FakeUiDevice): + def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: + value = super().jsonrpc_call(method, params, timeout) + if method == "takeScreenshot": + return make_invalid(value) + return value + + output = Path(directory) / "baseline" + capturer = DeviceBaselineCapturer( + StaticAdbClient(), + lambda serial: InvalidScreenshotDevice(), + timeout_seconds=5, + ) + with self.assertRaises(BaselineCaptureError) as raised: + capturer.capture(SERIAL, output) + + self.assertIn("Base64 语法无效", str(raised.exception)) + self.assertNotIn(SERIAL, str(raised.exception)) + self.assertFalse(output.exists()) + self.assertEqual(list(Path(directory).iterdir()), []) + + def test_base64_decoded_nonimage_fails_closed_without_partial_output(self) -> None: + class NonImageScreenshotDevice(FakeUiDevice): + def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: + if method == "takeScreenshot": + return base64.b64encode(b"not an image").decode("ascii") + return super().jsonrpc_call(method, params, timeout) + + capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: NonImageScreenshotDevice(), timeout_seconds=5) + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "baseline" + with self.assertRaises(BaselineCaptureError) as raised: + capturer.capture(SERIAL, output) + + self.assertIn("图像数据无效", str(raised.exception)) + self.assertNotIn("not an image", str(raised.exception)) + self.assertFalse(output.exists()) + self.assertEqual(list(Path(directory).iterdir()), []) + + def test_ascii_base64_whitespace_is_normalized_before_strict_decode(self) -> None: + class WhitespaceScreenshotDevice(FakeUiDevice): + def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: + value = super().jsonrpc_call(method, params, timeout) + if method == "takeScreenshot": + return value[:10] + " \t\r\n" + value[10:30] + "\n" + value[30:] + return value + + capturer = DeviceBaselineCapturer(StaticAdbClient(), lambda serial: WhitespaceScreenshotDevice(), timeout_seconds=5) + with tempfile.TemporaryDirectory() as directory: + output = Path(directory) / "baseline" + result = capturer.capture(SERIAL, output) + + self.assertTrue(result.screenshot_path.is_file()) + with Image.open(result.screenshot_path) as image: + self.assertEqual(image.size, (1, 1)) + def test_invalid_or_non_hierarchy_xml_fails_closed_without_partial_output(self) -> None: class InvalidHierarchyDevice(FakeUiDevice): def jsonrpc_call(self, method: str, params: object = None, timeout: float = 10) -> str: diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 3976761..dbf0eaa 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -98,7 +98,8 @@ D:\Portable\adb\adb.exe devices -l 成功时输出目录仅包含截图、完整 XML 与不含页面正文的 `manifest.json`(设备元数据、通道、时间、 文件 SHA-256 和 serial 哈希)。`--timeout` 约束 ADB 命令、ADB socket 及 `takeScreenshot` / `dumpWindowHierarchy(compressed=False, max_depth=50)` 的公开 JSON-RPC 调用;uiautomator2 初始化仍有 -上游固定启动上限。XML 仅留在本机明确指定的证据目录;人工必须先在本地检查截图/XML,再只记录路径 +上游固定启动上限。截图 Base64 仅兼容 RPC 返回值中的空格、TAB、CR、LF,其余字符 +仍严格拒绝。XML 仅留在本机明确指定的证据目录;人工必须先在本地检查截图/XML,再只记录路径 和哈希,不得把原始证据提交 Git;之后把设备型号、Android、拼多多版本及 USB/WiFi 结论记录到 T-101, 才能完成该任务。 @@ -115,7 +116,8 @@ compileall。既有 `client/.venv` 若不是 Python 3.11+ 会明确失败,不 当前 Windows 默认 `python` 仍可能指向 Python 3.10,不满足采购工具的 Python 3.11+ 下限; 不得把未加版本选择器的 `python` 当作采购工具命令。统一入口优先使用既有合规 venv,仅在需要创建时 -自动选择 Launcher 中最高的合规版本;本机现有 venv 实际为 Python 3.12。桌面 GUI 与真机流程不属于 T-003 验收范围。 +自动选择 Launcher 中最高的合规版本;本机 Python 3.12 与 3.14 均已验证,主工作区当前选择 Python +3.14。桌面 GUI 与真机流程不属于 T-003 验收范围。 Windows PowerShell 差异: diff --git a/docs/current-state.md b/docs/current-state.md index d543215..3f48c1d 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -20,7 +20,7 @@ - 生产代码:`admin/` 已有最小 Go 服务、健康检查、核心领域模型、SQLite 迁移与任务状态机; `client/` 已有 Python 包、PySide6 最小入口、运行目录与日志脱敏策略,以及显式 serial 的 ADB 连接边界与本地基线取证 CLI;尚无真机采购流程 -- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 27 项离线单元测试 +- 测试:采购服务已覆盖健康检查、核心模型、迁移与状态机等离线包级测试;采购工具 30 项离线单元测试 (全部 mock,不连接真机) - 数据:SQLite 核心表与迁移已落成;无业务实例数据 - 标准启动路径:Windows PowerShell 运行 `./init.ps1`,Unix shell 运行 `./init.sh`。Windows 入口 @@ -94,9 +94,10 @@ cd client ``` `client/requirements.txt` 是唯一依赖来源,`client/pyproject.toml` 动态读取它生成 wheel 的 -`Requires-Dist`。Python 3.12 已验证 27 项离线测试、编译与 wheel 元数据;完整运行时依赖安装 +`Requires-Dist`。Python 3.12 与 3.14 均已验证离线测试、编译与 wheel 元数据;完整运行时依赖安装 (`pip install -e .`)已通过。`init.ps1` 优先使用合规既有 venv,缺失时自动选择最高的 Python 3.11+; -本机现有 venv 实际验证为 Python 3.12。桌面 GUI 与真机流程未作为 T-003 验收执行。 +本机 Python 3.12 与 3.14 均已验证,主工作区当前选择 Python 3.14。桌面 GUI 与真机流程未作为 T-003 +验收执行。 T-101 的人工真机验收命令(先把手机切到不含收货地址、手机号、支付信息或其他无关隐私的安全页面; 必须从 `adb devices -l` 手工复制在线 serial,不能留空或自动选择): diff --git a/docs/tasks/T-101.md b/docs/tasks/T-101.md index a790e47..95ecbbb 100644 --- a/docs/tasks/T-101.md +++ b/docs/tasks/T-101.md @@ -23,7 +23,7 @@ write_paths: - docs/current-state.md --- - + ## 问题 / 背景 T-002 已建立采购工具骨架,但本项目还没有对实际 Android 手机、ADB 通道或 uiautomator2 做过取证。后续所有拼多多页面判据都依赖稳定、显式且可审计的设备连接;若复用前序项目结论或自动猜设备,会把错误设备和旧页面事实带入生产流程。 @@ -63,6 +63,14 @@ T-101 已领取,Git 状态将提交为 DOING。只读环境盘点:adb 位于 ### 2026-08-04T00:36:03Z · ila 2026-08-04 人工进展:用户确认 WiFi ADB 已连接,serial 为 192.168.0.173:5555。主 agent 随后仅执行 `adb devices -l` 复核,WiFi 与 USB 3B65BD02H7F00000 当前均为 device,product/model/device 均显示 PKG110/PKG110/OP5D2BL1;本机尚无 `%LOCALAPPDATA%\cmbuyer\artifacts\T-101` 取证产物。该结果只证明 WiFi 通道在线,不是 T-101 完整人工验收。按 fail-closed 边界,人工进行 WiFi 取证前须先断开 USB,只保留 WiFi;USB 取证时须断开 WiFi,只保留 USB。仍等待两次截图、compressed=false XML 与 manifest 路径/SHA-256,任务保持 DOING。 + +### 2026-08-04T00:43:38Z · ila + +2026-08-04 人工 WiFi 取证失败:用户在仅 WiFi serial 192.168.0.173:5555 在线时运行基线 CLI,工具返回“截图 RPC 返回的数据不是有效图像,拒绝发布不完整取证”。主 agent 只读复核:ADB 仅列出该 WiFi 通道为 device,T-101 产物目录为空,说明暂存清理与 fail-closed 生效。核对本机 uiautomator2 3.7.0 源码后发现其高层 screenshot 使用允许 Base64 空白的 `base64.b64decode`,而当前实现直接 `validate=True`,可能错误拒绝真实 RPC 返回中的 CR/LF/空格。已退回 client agent 修复为“只规范化 ASCII Base64 空白,再严格 validate=True;其他非法字符与非图像继续拒绝”,并补回归测试。该失败不是验收通过,T-101 继续 DOING;修复、主审、提交后由人重新执行 WiFi 取证。 + +### 2026-08-04T00:49:31Z · ila + +2026-08-04 WiFi 截图兼容性修复已完成主审:实现只删除 RPC Base64 返回值中的空格、TAB、CR、LF,再使用 `validate=True` 严格解码;非法字符、错误 padding、vertical-tab、form-feed、data URI 与解码后非图像仍 fail closed,不增加 adb screenshot fallback。错误安全地区分为“Base64 语法无效”和“图像数据无效”,均不输出原始内容,失败继续清理暂存目录。新增真实 PNG Base64 空白兼容、非法字符、错误 padding、未白名单空白及非图像测试;client 共 30 项离线测试。主 agent 独立复核并运行 client 测试/compileall、admin test/vet/build、完整 init.ps1、validator、bash 语法与 diff check,均通过;Python 3.12 和主工作区 3.14 均已验证。未由 agent 连接或截图真机,T-101 保持 DOING,等待人工重新执行 WiFi 取证确认现场问题是否解决。 ## 边界