fix(client): accept whitespace in screenshot base64

This commit is contained in:
QiuSW
2026-08-04 08:51:00 +08:00
parent c4cf19fd55
commit 7905fa0b70
5 changed files with 97 additions and 13 deletions
+9 -4
View File
@@ -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:
+71 -3
View File
@@ -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:
+4 -2
View File
@@ -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 差异:
+4 -3
View File
@@ -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,不能留空或自动选择):
+9 -1
View File
@@ -23,7 +23,7 @@ write_paths:
- docs/current-state.md
---
<!-- BEGIN VIKUNJA EXPORT id=21 synced=2026-08-04T00:36:19Z sha256=bc3fcf9dfcb10728692a65f49d209cbfabd6da5aacbb37a341d46e56a4d4e3aa -->
<!-- BEGIN VIKUNJA EXPORT id=21 synced=2026-08-04T00:49:39Z sha256=6b341e688efab546409c06063595a3953d46947f554c1c022d1e5d92ab52d8fa -->
## 问题 / 背景
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 取证确认现场问题是否解决。
<!-- END VIKUNJA EXPORT -->
## 边界