fix(client): accept whitespace in screenshot base64
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 差异:
|
||||
|
||||
|
||||
@@ -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
@@ -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 -->
|
||||
|
||||
## 边界
|
||||
|
||||
Reference in New Issue
Block a user