diff --git a/.gitignore b/.gitignore index 24eecf2..fcf7134 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ npm/platforms/*/bin/ venv/ ENV/ +# Local feature worktrees +.worktrees/ + # IDE .idea/ .vscode/ diff --git a/README.md b/README.md index 1d877d2..bcfb91a 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ # WeChat CLI -**Query your local WeChat data from the command line.** +**Query local WeChat data and optionally send group text in the background.** [![npm version](https://img.shields.io/npm/v/@canghe_ai/wechat-cli.svg)](https://www.npmjs.com/package/@canghe_ai/wechat-cli) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey.svg)](https://github.com/freestylefly/wechat-cli) -Chat history · Contacts · Sessions · Favorites · Statistics · Export +Chat history · Contacts · Sessions · Favorites · Statistics · Export · Group send [中文文档](README_CN.md) @@ -19,9 +19,9 @@ Chat history · Contacts · Sessions · Favorites · Statistics · Export ## ✨ Highlights - **🚀 Zero-config install** — `npm install -g` and you're done, no Python needed -- **📦 11 commands** — sessions, history, search, contacts, members, stats, export, favorites, unread, new-messages, init +- **📦 12 commands** — all existing query commands plus exact-group background text sending with `send` - **🤖 AI-first** — JSON output by default, designed for LLM agent tool calls -- **🔒 Fully local** — on-the-fly SQLCipher decryption, data never leaves your machine +- **🔒 Local queries** — on-the-fly SQLCipher decryption; queried data stays on your machine - **📊 Rich analytics** — top senders, message type breakdown, 24-hour activity charts - **📝 Flexible export** — Markdown or plain text, with time range filtering @@ -141,12 +141,14 @@ sudo codesign --force --sign - --entitlements /dev/stdin /Applications/WeChat.ap com.apple.security.get-task-allow + com.apple.security.cs.disable-library-validation + EOF ``` -> **Heads up:** Re-signing WeChat is safe and will **not** cause account issues or bans. However, it may affect WeChat's auto-update mechanism. If you notice any feature not working properly, or want to update WeChat to the latest version, simply re-download and reinstall WeChat from the [official website](https://mac.weixin.qq.com/) — no need to re-run `init`, your existing config and keys will continue to work. +> Re-signing changes the app signature and may affect auto-update, app features, or account risk controls. Reinstall WeChat from its official site to restore the original app. ### Step 2 — Use It @@ -154,6 +156,7 @@ EOF wechat-cli sessions # Recent chats wechat-cli history "Alice" --limit 20 # Chat messages wechat-cli search "deadline" --chat "Team" # Search messages +wechat-cli send "Team" "Hello everyone" # Background group text ``` --- @@ -257,6 +260,35 @@ wechat-cli members "Team Group" # All members (JSON) wechat-cli members "Team Group" --format text ``` +### `send` — Background Group Text (Experimental) + +```bash +wechat-cli send "Exact Unique Group Name" "Message text" +wechat-cli send "54597320555@chatroom" $'Line one\nLine two' --timeout 15 --format json +``` + +`send` **only sends text messages to group chats**. It does not support direct messages, File Transfer, images, files, voice, video, stickers, or other message types. The group name must be a unique exact match, or a real `@chatroom` ID present in the contact database. Fuzzy names, duplicate group names, forged `@chatroom` IDs, and non-group contacts are rejected before submission. + +The send flow is: + +1. Strictly verify macOS ARM64, WeChat 4.1.8, process identity, the binary UUID, and pinned function signatures. +2. Capture the target group's local-message baseline, then load a locally authenticated in-process bridge on the first call. +3. On WeChat's main thread, the bridge invokes the pinned official message-task builder and submit chain. It does not focus WeChat, use the input box, or simulate keyboard or mouse input. +4. WeChat performs its normal network submission and writes its own local message record. The CLI never modifies the WeChat database directly; it polls the database read-only, so a confirmed message is visible on both mobile and the current Mac client. +5. Success is returned only after a post-baseline outgoing text row matches the target, sender, and exact text and has a positive `local_id`, positive `server_id`, text type, and sent status. + +The default JSON success result includes `success`, `status`, `request_id`, `group`, `username`, `local_id`, and `server_id`. `status: server_accepted` means the local database contains a server-assigned message ID. + +| Exit code | Meaning | Retry guidance | +|---|---|---| +| `0` | Server acceptance and Mac-local visibility confirmed | No retry needed | +| `1` | Target is missing, ambiguous, or not a group | Correct the target, then retry | +| `2` | Text or `timeout` is invalid | Correct the input, then retry | +| `3` | Environment, version, permission, or bridge failed before submission | Correct the environment, then retry | +| `4` | Submission boundary was crossed, but the final result is unknown | **Never retry automatically** | + +Submission is irreversible. Exit code `4` / `status: unknown` means the action may have happened but could not be confirmed; inspect the target group manually before deciding what to do. Unsupported builds, signing problems, and missing permissions are rejected before submission whenever they can be identified safely. + ### `stats` — Chat Statistics ```bash @@ -327,9 +359,9 @@ The `--type` option (on `history` and `search`): ## 💻 System Requirements - **macOS** ≥ 26.3.1 -- **WeChat for Mac** ≤ 4.1.8.100 +- **WeChat for Mac** 4.1.8 -> Older macOS versions or newer WeChat versions may not be compatible. +> `send` additionally validates a pinned WeChat 4.1.8 binary fingerprint. Other WeChat versions may not be compatible. --- @@ -342,6 +374,8 @@ The `--type` option (on `history` and `search`): | Windows | ✅ Supported | Reads Weixin.exe process memory | | Linux | ✅ Supported | Reads /proc/pid/mem, requires root | +`send` currently supports macOS ARM64 WeChat 4.1.8 and validates a pinned binary fingerprint at runtime. + --- ## 🔧 How It Works @@ -351,6 +385,7 @@ WeChat stores chat data in SQLCipher-encrypted SQLite databases locally. WeChat 1. **Extracts keys** — scans WeChat process memory for encryption keys (`init`) 2. **Decrypts on-the-fly** — transparent page-level AES-256-CBC decryption with caching 3. **Queries locally** — all data stays on your machine, no network access +4. **Optional background send** — `send` submits group text through the running desktop WeChat process; query commands remain read-only --- @@ -362,11 +397,12 @@ WeChat stores chat data in SQLCipher-encrypted SQLite databases locally. WeChat ## ⚖️ Disclaimer -This project is a local data query tool for personal use only. Please note: +This project is for personal management of local WeChat data and opt-in group text sending. Please note: -- **Read-only** — this tool only reads locally stored data, it does not send, modify, or delete any messages -- **No cloud transmission** — all data stays on your local machine, nothing is uploaded to any server -- **No WeChat ecosystem disruption** — this tool does not interfere with WeChat's normal operation, does not automate any actions, and does not violate WeChat's Terms of Service +- **Read-only queries** — query commands do not modify WeChat databases; `send` creates a real, irreversible external message +- **Local processing** — queried data is not uploaded to this tool's servers; sent text is submitted normally by the WeChat client +- **Version and account risk** — `send` supports WeChat 4.1.8 with a pinned binary fingerprint and process loading/re-signing, and may crash, stop working, affect updates, or trigger account controls +- **Never retry unknown results** — after a timeout or incomplete receipt, the message may already have been sent - **Use at your own risk** — this project is for personal learning and research purposes only. Users are responsible for ensuring compliance with local laws and regulations --- diff --git a/README_CN.md b/README_CN.md index 6483703..ba61d89 100644 --- a/README_CN.md +++ b/README_CN.md @@ -2,13 +2,13 @@ # WeChat CLI -**命令行查询本地微信数据,专为 AI 集成设计。** +**命令行查询本地微信数据,并按需后台发送群聊文本。** [![npm version](https://img.shields.io/npm/v/@canghe_ai/wechat-cli.svg)](https://www.npmjs.com/package/@canghe_ai/wechat-cli) [![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Windows%20%7C%20Linux-lightgrey.svg)](https://github.com/freestylefly/wechat-cli) -聊天记录 · 联系人 · 会话 · 收藏 · 统计 · 导出 +聊天记录 · 联系人 · 会话 · 收藏 · 统计 · 导出 · 群聊发送 [English](README.md) @@ -19,9 +19,9 @@ ## ✨ 功能亮点 - **🚀 开箱即用** — `npm install -g` 一键安装,无需 Python -- **📦 11 个命令** — sessions、history、search、contacts、members、stats、export、favorites、unread、new-messages、init +- **📦 12 个命令** — 原有查询命令之外,增加精确群聊后台文本发送 `send` - **🤖 AI 优先** — 默认 JSON 输出,专为 LLM Agent 工具调用设计 -- **🔒 全程本地** — SQLCipher 即时解密,数据不出本机 +- **🔒 本地查询** — SQLCipher 即时解密,查询数据不出本机 - **📊 丰富统计** — 发言排行、消息类型分布、24 小时活跃图 - **📝 灵活导出** — Markdown 或纯文本,支持时间范围过滤 @@ -139,12 +139,14 @@ sudo codesign --force --sign - --entitlements /dev/stdin /Applications/WeChat.ap com.apple.security.get-task-allow + com.apple.security.cs.disable-library-validation + EOF ``` -> **温馨提示:** 重新签名是安全的,**不会**导致封号或账号异常。但可能影响微信的部分功能或自动更新。如果发现任何功能异常(如搜一搜无法使用),或想更新到微信最新版,直接从[微信官网](https://mac.weixin.qq.com/)重新下载安装即可,**无需重新执行 init**,已有的配置和密钥不受影响。 +> 重新签名会改变应用签名,可能影响自动更新、部分功能或账号风控。需要恢复时,请从微信官网重新安装。 ### 第二步 — 开始使用 @@ -152,6 +154,7 @@ EOF wechat-cli sessions # 最近会话 wechat-cli history "张三" --limit 20 # 聊天记录 wechat-cli search "截止日期" --chat "项目组" # 搜索消息 +wechat-cli send "项目组" "大家好" # 后台发送群文本 ``` --- @@ -255,6 +258,35 @@ wechat-cli members "AI交流群" # 成员列表 wechat-cli members "AI交流群" --format text ``` +### `send` — 后台发送群文本(实验性) + +```bash +wechat-cli send "唯一完整群名" "消息文本" +wechat-cli send "54597320555@chatroom" $'第一行\n第二行' --timeout 15 --format json +``` + +`send` **只能发送群聊文本消息**。它不支持私聊、文件传输助手、图片、文件、语音、视频、表情或其他消息类型。群名必须唯一且完全匹配,也可以使用联系人库中真实存在的 `@chatroom` ID;模糊群名、同名群、伪造的 `@chatroom` ID 和非群聊联系人都会在提交前被拒绝。 + +发送链路如下: + +1. 严格校验 macOS ARM64、WeChat 4.1.8、进程身份、二进制 UUID 与关键函数签名。 +2. 记录目标群发送前的本地消息基线,首次调用时按需加载带本地凭据认证的进程内 bridge。 +3. bridge 在微信主线程调用已固定指纹的官方消息任务构建/提交链;不会聚焦微信窗口,也不会操作输入框或模拟键鼠。 +4. 微信客户端负责正常网络提交并写入自己的本地消息库。CLI 不直接修改微信数据库,只以只读方式轮询确认结果,因此成功消息会出现在手机端和当前 Mac 微信中。 +5. 仅当基线之后出现一条目标、发送者和文本完全一致,且同时具有正 `local_id`、正 `server_id`、文本类型和已发送状态的本地记录时,命令才返回成功。 + +默认 JSON 成功结果包含 `success`、`status`、`request_id`、`group`、`username`、`local_id` 和 `server_id`。`status: server_accepted` 表示本地库已经观察到服务器分配的消息 ID。 + +| 退出码 | 含义 | 是否可以重试 | +|---|---|---| +| `0` | 已确认服务器接受且 Mac 本地消息可见 | 不需要 | +| `1` | 群目标不存在、不唯一或不是群聊 | 修正目标后可以 | +| `2` | 文本或 `timeout` 参数无效 | 修正参数后可以 | +| `3` | 在提交前发现环境、版本、权限或 bridge 不可用 | 修正环境后可以 | +| `4` | 已进入提交边界,但最终结果无法确认 | **禁止自动重试** | + +消息一旦提交便不可撤销。退出码 `4` / `status: unknown` 表示动作可能已经发生但无法确认,必须人工查看目标群后再决定;微信升级、重新签名失败或权限不足会尽量在提交前拒绝发送。 + ### `stats` — 聊天统计 ```bash @@ -325,9 +357,9 @@ wechat-cli new-messages # 后续: 仅返回上次以来的新 ## 💻 系统要求 - **macOS** ≥ 26.3.1 -- **微信 Mac 版** ≤ 4.1.8.100 +- **微信 Mac 版** 4.1.8 -> macOS 老版本或更新的微信版本可能不兼容。 +> `send` 会额外校验 WeChat 4.1.8 的固定二进制指纹。其他微信版本可能不兼容。 --- @@ -340,6 +372,8 @@ wechat-cli new-messages # 后续: 仅返回上次以来的新 | Windows | ✅ 支持 | 读取 Weixin.exe 进程内存 | | Linux | ✅ 支持 | 读取 /proc/pid/mem,需要 root | +`send` 目前支持 macOS ARM64 WeChat 4.1.8,并在运行时校验固定二进制指纹。 + --- ## 🔧 工作原理 @@ -349,6 +383,7 @@ wechat-cli new-messages # 后续: 仅返回上次以来的新 1. **提取密钥** — 扫描微信进程内存获取加密密钥(`init`) 2. **即时解密** — 透明页级 AES-256-CBC 解密,带缓存 3. **本地查询** — 所有数据留在本机,无需网络访问 +4. **可选后台发送** — `send` 通过当前桌面微信进程提交群文本;查询命令仍只读 --- @@ -360,11 +395,12 @@ wechat-cli new-messages # 后续: 仅返回上次以来的新 ## ⚖️ 免责声明 -本项目为个人使用的本地数据查询工具,请注意: +本项目供个人管理本机微信数据及按需发送群文本,请注意: -- **只读不写** — 本工具仅读取本地存储的数据,不会发送、修改或删除任何消息 -- **数据不出本机** — 所有数据仅在你本机处理,不会上传至任何云端服务器 -- **不破坏微信生态** — 本工具不会干扰微信正常运行,不会自动化任何操作,不违反微信使用协议 +- **查询只读** — 查询命令不修改微信数据库;`send` 会产生真实、不可撤销的外部消息副作用 +- **本地处理** — 查询数据不上传到本工具的服务器;发送内容会由微信客户端正常提交给微信服务 +- **版本与账号风险** — `send` 支持 WeChat 4.1.8,并依赖固定二进制指纹和重新签名/进程加载,可能崩溃、失效、影响更新或触发账号风控 +- **未知状态不重试** — 超时或回执不完整时消息可能已经发送,调用方必须人工确认 - **风险自担** — 本项目仅供个人学习研究使用,使用者需确保遵守当地法律法规 --- diff --git a/docs/research/wechat-4.1.8.28-local-store-candidates.md b/docs/research/wechat-4.1.8.28-local-store-candidates.md new file mode 100644 index 0000000..9050e7c --- /dev/null +++ b/docs/research/wechat-4.1.8.28-local-store-candidates.md @@ -0,0 +1,58 @@ +# WeChat 4.1.8.28 Local-Store Boundary Map + +This note records only entry points verified against the pinned ARM64 slice. +It deliberately does not assign function roles to direct field writes or to +addresses that fall inside larger functions. + +## Image identity + +- Path: `/private/tmp/wechat-arm64-profile.dylib` +- SHA-256: `0e8c932461b883a4e4dc90313a14a30ea69a190fedf1083d014259bec56da2e0` +- UUID: `ABD88EC8-4590-3FDB-AAB5-4E5865B86B2A` +- Architecture: ARM64 + +## Active read-only probes + +| Label | RVA | Entry bytes | Evidence | +| --- | ---: | --- | --- | +| `model_text_finalize` | `0x25634B0` | `00e1049121411491a45ce794a92240a9` | Final text assignment immediately before the verified insert call. The same function loads a pre-existing shared message model into `x8`, sets `x0 = x8 + 0x138`, and copies text from `x9 + 0x510`; the subsequent insert receives that model in `x1`. | +| `insert` | `0x26E620C` | `e93c0390294942f9490000b420011fd6` | Function prologue or runtime-patch branch. Caller at `0x253B858` sets `x8` to the sret destination, `x0` to the storage/service object, and leaves the shared message model in `x1`. The wrapper calls `0x44CB850(x1)`, then tail-calls `0x26E6264` with `x2` set to the extracted string and `w3 = 1`. | +| `insert_impl` | `0x26E6264` | `e93c0390294d42f9490000b420011fd6` | Tail-call target of the verified insert wrapper. Its prologue preserves the message model from `x1` and the sret pointer from `x8`. | +| `mars_submit` | `0x498D2E0` | `ff0305d1fc6f11a9f44f12a9fd7b13a9` | Function entry with a normal ARM64 prologue. Nearby literals identify `mars::mmext`, `MMStartTask`, and `mmstn_manager.cc`. This is the already measured Mars task-submission boundary. | + +These four probes are installed together and stay within the capture tool's +conservative four-breakpoint ARM64 budget. The profiler only reads registers, +bounded memory, and backtraces. It never evaluates an expression, calls a +WeChat function, or writes target memory. + +The approved `WCPROFILE-20260802-01`, `WCPROFILE-20260802-02`, and +`WCPROFILE-20260803-01` captures establish the observed transaction as +`insert -> insert_impl -> mars_submit`. The third capture recorded the exact +marker in the model passed as `insert` argument `x1`, while the constructor +probe did not fire at all. `model_text_finalize`, `insert`, or `insert_impl` +may establish the marker transaction identity. Text-finalize capture tracks +the model in `x8`; insert capture tracks the shared model pointer in `x1`; +Mars is correlated by the coroutine thread and marker reachability. + +## Rejected candidates + +| Candidate | Status | Reason | +| --- | --- | --- | +| Model constructors at `0x44C9EA8`, `0x44CAE3C`, and `0x44CB324` | Rejected for action-time capture | The first constructor did not fire during the exact marker send. Static data flow at `0x2562BFC` shows the send coroutine receives the model through an existing shared pointer, so construction may precede the bounded action window. The other variants decode or copy stored records and do not improve action-time identity. | +| Recipient/client-id/create-time setters | Unresolved and not probed | Static call chains show that some values are assigned with direct field writes. No distinct function entry has been proved for these roles. | +| `0x26EA4FC` update | Rejected | Its caller at `0x253B018` belongs to the function beginning near `0x253AF94`, while insert's caller at `0x253B858` belongs to the separate function beginning at `0x253B798`. Neither approved marker capture hit this boundary, so it is not part of the observed outgoing insert transaction. | +| `0x372FD60` response decoder | Rejected pending runtime evidence | It is a real function, but current static evidence only supports a generic decoder/parser role, not the send-response boundary. | +| `0x202906C` notification | Rejected | The address lies inside the function spanning approximately `0x2028478..0x2029CD4`; it is not a valid entry point. | +| `0x25CD228` high-level send | Forbidden | Direct calls have a deterministic crash path because the surrounding Owl coroutine context is missing. | +| `0x3399194` UI coroutine closure | Excluded from this capture | It is a real closure entry, but observing the insert/update transaction does not require triggering or probing UI send code. | + +## Next evidence target + +The inserted model's first pointer resolves to vtable RVA `0x8A0E138`. In the +send function beginning at `0x2562BB0`, `0x2562BFC` copies the pre-existing +shared model pointer into the stack slot later loaded by `0x25634A4`. +`0x25634B0` then assigns final text through that model's `+0x138` string before +the call leading to insert. Runtime evidence must now prove that +`model_text_finalize` register `x8` equals the model observed as insert and +insert-impl register `x1`. Response decode and conversation notification remain +unresolved and must not be inferred from the rejected update candidate. diff --git a/docs/superpowers/plans/2026-08-02-local-visible-send.md b/docs/superpowers/plans/2026-08-02-local-visible-send.md new file mode 100644 index 0000000..314836a --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-local-visible-send.md @@ -0,0 +1,626 @@ +# Persistent Local-Visible Send Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the proven single `/newsendmsg` submission and add a persistent outgoing WeChat row that appears in the desktop conversation and `wechat-cli history` after restart. + +**Architecture:** A transaction adapter coordinates two pinned gateways: `LocalMessageGateway` inserts and updates messages through WeChat's internal message-storage queue, while `MarsGateway` retains the working network task implementation from `feat/wechat-send`. The transaction reuses one client identifier across both gateways, returns success only after the same positive `local_id` has a positive `server_id`, and fails closed while any runtime profile field is unverified. + +**Tech Stack:** C++17/Objective-C++, ARM64 assembly trampolines, macOS GCD/Mach APIs, Python 3, pytest, LLDB for read-only profiling. + +--- + +## File map + +- `native/include/wechat_bridge.hpp`: Existing bridge request/receipt contract; add internal gateway result types and factories without changing the socket JSON. +- `native/src/local_visible_adapter.mm`: New queue-independent transaction state machine, tested entirely with fake gateways. +- `native/src/wechat_adapter.mm`: Restore the proven Mars task implementation, then adapt it to the `MarsGateway` interface. +- `native/src/wechat_local_store.mm`: New strictly pinned WeChat storage/update gateway; no SQL and no direct database writes. +- `native/include/wechat_4_1_8_28_profile.hpp`: Generated constants for measured RVAs, entry signatures, ABI offsets, and queue boundary. +- `native/src/hook_trampolines.S`: Retain the proven request serialization hooks and add only a response hook if the measured virtual response boundary cannot complete directly. +- `native/build.sh`: Compile the new transaction and local-store translation units and retain hook trampolines. +- `native/tests/fake_host.mm`: Native fake gateway scenarios and process-level contract checks. +- `tests/test_native_bridge_host.py`: Red/green tests for local insert, single network submission, acknowledgement update, timeout, and fail-closed behavior. +- `tools/wechat_profile_capture.py`: Read-only LLDB capture helpers for one user-performed marker send. +- `tools/validate_wechat_profile.py`: Convert measured capture data into the pinned header only when all invariants agree. +- `tests/test_wechat_profile_validation.py`: Offline validation tests using sanitized capture fixtures. +- `docs/research/wechat-4.1.8.28-local-store-profile.md`: Human-readable evidence for every pinned field. +- `wechat_cli/core/native_sending.py`: No behavior change expected; regression tests prove it confirms the exact local row. + +### Task 1: Restore the proven Mars baseline on the isolated branch + +**Files:** +- Modify: `native/build.sh` +- Modify: `native/include/wechat_bridge.hpp` +- Modify: `native/src/wechat_adapter.mm` +- Modify: `native/tests/fake_host.mm` +- Modify: `tests/test_native_bridge_host.py` +- Delete: `codex_lldb_once.txt` +- Delete: `codex_map_functions.py` + +- [ ] **Step 1: Record the expected baseline difference** + +Run: + +```bash +git diff --exit-code feat/wechat-send -- \ + native/build.sh native/include/wechat_bridge.hpp \ + native/src/wechat_adapter.mm native/tests/fake_host.mm \ + tests/test_native_bridge_host.py +``` + +Expected: non-zero because the unsafe direct UI-closure experiment differs from the working branch. + +- [ ] **Step 2: Restore only the five experimental source/test files with `apply_patch`** + +Use `git show feat/wechat-send:` as the exact source for each file. Preserve the revised design and plan commits, and do not switch or modify `feat/wechat-send` itself. Remove the dead-PID LLDB command and the unused cross-build mapping helper; keep useful capture logic for Task 3. + +- [ ] **Step 3: Verify exact source parity with the working adapter** + +Run the same `git diff --exit-code` command from Step 1. + +Expected: exit 0 with no output. + +- [ ] **Step 4: Run the native bridge regression suite** + +Run: + +```bash +PYTHONPATH=. pytest -q tests/test_native_bridge_host.py +``` + +Expected: all tests pass and the fake host builds with `hook_trampolines.S` linked. + +- [ ] **Step 5: Commit the clean baseline** + +```bash +git add native/build.sh native/include/wechat_bridge.hpp \ + native/src/wechat_adapter.mm native/tests/fake_host.mm \ + tests/test_native_bridge_host.py +git commit -m "refactor: restore proven Mars send baseline" +``` + +### Task 2: Add the local-visible transaction contract + +**Files:** +- Create: `native/src/local_visible_adapter.mm` +- Modify: `native/include/wechat_bridge.hpp` +- Modify: `native/build.sh` +- Modify: `native/tests/fake_host.mm` +- Modify: `tests/test_native_bridge_host.py` + +- [ ] **Step 1: Write failing fake-gateway tests** + +Add fake-host modes and Python tests for these exact cases: + +```python +@pytest.mark.parametrize( + ("scenario", "expected_local_calls", "expected_network_calls", "expected_update_calls"), + [ + ("insert-failed", 1, 0, 0), + ("network-unknown", 1, 1, 0), + ("accepted", 1, 1, 1), + ], +) +def test_local_visible_transaction_order( + native_build, + scenario, + expected_local_calls, + expected_network_calls, + expected_update_calls, +): + result = _run_transaction_scenario(native_build, scenario) + assert result["insert_calls"] == expected_local_calls + assert result["network_calls"] == expected_network_calls + assert result["update_calls"] == expected_update_calls + + +def test_local_visible_transaction_reuses_identifiers(native_build): + result = _run_transaction_scenario(native_build, "accepted") + assert result["insert_client_id"] == result["network_client_id"] + assert result["insert_local_id"] == result["update_local_id"] == 41 + assert result["receipt"] == { + "ack_state": "acknowledged", + "local_id": 41, + "server_id": 99, + } +``` + +- [ ] **Step 2: Run the new tests and verify RED** + +Run: + +```bash +PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k local_visible_transaction +``` + +Expected: failures because the transaction factory and fake-host modes do not exist. + +- [ ] **Step 3: Define the gateway interfaces** + +Add these internal contracts to `native/include/wechat_bridge.hpp`: + +```cpp +struct LocalMessageIdentity { + std::int64_t local_id = 0; + std::uint64_t client_id = 0; + std::uint32_t create_time = 0; +}; + +struct NetworkAcceptance { + bool accepted = false; + std::int64_t server_id = 0; +}; + +using LocalInsertCompletion = + std::function)>; +using NetworkCompletion = std::function; +using LocalUpdateCompletion = std::function; + +class LocalMessageGateway { + public: + virtual ~LocalMessageGateway() = default; + virtual void InsertOutgoing(const SendRequest& request, + std::uint64_t client_id, + std::uint32_t create_time, + LocalInsertCompletion completion) = 0; + virtual void MarkAccepted(const LocalMessageIdentity& identity, + std::int64_t server_id, + LocalUpdateCompletion completion) = 0; +}; + +class MarsGateway { + public: + virtual ~MarsGateway() = default; + virtual void Submit(const SendRequest& request, + const LocalMessageIdentity& identity, + NetworkCompletion completion) = 0; +}; + +std::shared_ptr CreateLocalVisibleAdapter( + std::shared_ptr local, + std::shared_ptr mars); +``` + +- [ ] **Step 4: Implement the minimal transaction state machine** + +In `native/src/local_visible_adapter.mm`, generate `client_id` and `create_time` once, call `InsertOutgoing`, call `MarsGateway::Submit` only after a positive local ID, and call `MarkAccepted` only after a positive server ID. All invalid or incomplete callbacks produce `AckState::kUnknown`; no branch calls `Submit` twice. + +- [ ] **Step 5: Run RED tests to verify GREEN, then run all native tests** + +```bash +PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k local_visible_transaction +PYTHONPATH=. pytest -q tests/test_native_bridge_host.py +``` + +Expected: both commands exit 0. + +- [ ] **Step 6: Commit the transaction layer** + +```bash +git add native/include/wechat_bridge.hpp native/src/local_visible_adapter.mm \ + native/build.sh native/tests/fake_host.mm tests/test_native_bridge_host.py +git commit -m "feat: add local-visible send transaction" +``` + +### Task 3: Produce a complete read-only WeChat storage profile + +**Files:** +- Create: `tools/wechat_profile_capture.py` +- Create: `tools/validate_wechat_profile.py` +- Create: `tests/fixtures/wechat_local_store_capture.json` +- Create: `tests/test_wechat_profile_validation.py` +- Create: `docs/research/wechat-4.1.8.28-local-store-profile.md` +- Create: `native/include/wechat_4_1_8_28_profile.hpp` +- Delete: `codex_lldb_capture.py` + +- [ ] **Step 1: Write failing profile-validation tests** + +```python +def test_profile_rejects_missing_queue_or_identifier_evidence(tmp_path): + capture = _valid_capture() + capture.pop("storage_queue") + assert validate_capture(capture) == "missing_storage_queue" + + +def test_profile_rejects_direct_gcd_ui_closure(tmp_path): + capture = _valid_capture() + capture["insert_entry_rva"] = 0x3399194 + assert validate_capture(capture) == "unsafe_await_closure" + + +def test_profile_emits_all_pinned_constants(tmp_path): + header = render_header(_valid_capture()) + for name in ( + "kLocalInsertRva", + "kLocalUpdateRva", + "kConversationNotifyRva", + "kStorageDispatchRva", + "kResponseDecodeRva", + ): + assert name in header +``` + +- [ ] **Step 2: Run the validator tests and verify RED** + +```bash +PYTHONPATH=. pytest -q tests/test_wechat_profile_validation.py +``` + +Expected: import failure because `tools/validate_wechat_profile.py` does not exist. + +- [ ] **Step 3: Convert the existing capture helper into a bounded tool** + +`tools/wechat_profile_capture.py` must: + +- derive the loaded `wechat.dylib` base instead of hard-coding a PID or load address; +- filter every breakpoint by one exact marker present in the outgoing model; +- capture entry bytes, registers, queue/thread name, return value, and the same model before/after; +- include the observed candidates `0x26e6264` (local insert) and `0x26ea4fc` (local update), but label them candidates until validation proves their outputs; +- collect response decoding and conversation notification callers; +- automatically detach after the marker send and never invoke a send function. + +- [ ] **Step 4: Implement strict offline validation** + +`validate_capture()` returns a named failure unless all of these are measured in one manual marker send: positive local ID transition, identical client ID through request serialization, storage-queue identity, positive server ID update, conversation notification, current dylib UUID `ABD88EC8-4590-3FDB-AAB5-4E5865B86B2A`, and SHA-256 `b4a740135f3f1e937bca10caf0a95cff986ddf27fa6bb15ccaf64001fc651c93`. + +On success, `render_header()` emits integer constants and byte signatures. It must reject RVA `0x3399194` for insert, update, dispatch, or notification. + +- [ ] **Step 5: Run the offline tests to verify GREEN** + +```bash +PYTHONPATH=. pytest -q tests/test_wechat_profile_validation.py +``` + +Expected: all tests pass. + +- [ ] **Step 6: Capture one user-performed manual marker send** + +Run LLDB in read-only observation mode, ask the user to manually send one unique marker to the dedicated test group, then detach automatically. The tool itself must not send, write WeChat memory, patch instructions, or keep the process stopped. + +- [ ] **Step 7: Validate and generate the pinned header** + +```bash +python3 tools/validate_wechat_profile.py \ + --capture /tmp/wechat-local-store-capture.json \ + --image /private/tmp/wechat-arm64-profile.dylib \ + --fixture tests/fixtures/wechat_local_store_capture.json \ + --header native/include/wechat_4_1_8_28_profile.hpp +``` + +Expected: `profile_valid`; otherwise keep production sending disabled and stop this task without a live bridge test. + +- [ ] **Step 8: Record evidence and commit** + +Document each generated constant, ABI argument, return value, queue, and signature in `docs/research/wechat-4.1.8.28-local-store-profile.md`. + +```bash +git add tools/wechat_profile_capture.py tools/validate_wechat_profile.py \ + tests/fixtures/wechat_local_store_capture.json \ + tests/test_wechat_profile_validation.py \ + docs/research/wechat-4.1.8.28-local-store-profile.md \ + native/include/wechat_4_1_8_28_profile.hpp +git commit -m "docs: pin WeChat local message storage profile" +``` + +### Task 4: Implement the pinned local message gateway + +**Files:** +- Create: `native/src/wechat_local_store.mm` +- Modify: `native/include/wechat_bridge.hpp` +- Modify: `native/build.sh` +- Modify: `native/tests/fake_host.mm` +- Modify: `tests/test_native_bridge_host.py` + +- [ ] **Step 1: Write failing native layout and queue tests** + +Add fake-host tests that verify: + +```python +def test_local_store_builds_exact_outgoing_text_model(native_build): + result = _run_local_store_contract(native_build, "insert") + assert result["recipient"] == "59034084590@chatroom" + assert result["text"] == "Unicode 👋\n$HOME `literal`" + assert result["client_id"] == 202608020001 + assert result["local_id"] == 41 + + +def test_local_store_dispatches_only_on_captured_storage_queue(native_build): + result = _run_local_store_contract(native_build, "queue") + assert result["socket_thread_calls"] == 0 + assert result["storage_queue_calls"] == 1 +``` + +- [ ] **Step 2: Run the new tests and verify RED** + +```bash +PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k local_store +``` + +Expected: failure because `wechat_local_store.mm` is absent. + +- [ ] **Step 3: Implement the model, dispatch, insert, and update calls** + +Use only generated constants from `wechat_4_1_8_28_profile.hpp`. Validate all entry signatures before constructing the gateway. Retain model and callback storage until completion. `InsertOutgoing` accepts only exact `@chatroom` recipients and returns `nullopt` unless WeChat reports a positive local ID. `MarkAccepted` updates the same local ID with the measured positive server ID and invokes the measured conversation notification on the same internal queue. + +Expose this factory in `native/include/wechat_bridge.hpp`: + +```cpp +std::shared_ptr CreatePinnedLocalMessageGateway( + std::string* unsupported_reason); +``` + +- [ ] **Step 4: Verify GREEN and all native regressions** + +```bash +PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k local_store +PYTHONPATH=. pytest -q tests/test_native_bridge_host.py +``` + +Expected: both commands exit 0. + +- [ ] **Step 5: Commit the local gateway** + +```bash +git add native/src/wechat_local_store.mm native/include/wechat_bridge.hpp native/build.sh \ + native/tests/fake_host.mm tests/test_native_bridge_host.py +git commit -m "feat: add pinned local message gateway" +``` + +### Task 5: Adapt the proven Mars task to measured identifiers and ACK + +**Files:** +- Modify: `native/include/wechat_bridge.hpp` +- Modify: `native/src/wechat_adapter.mm` +- Modify: `native/src/hook_trampolines.S` +- Modify: `native/tests/fake_host.mm` +- Modify: `tests/test_native_bridge_host.py` + +- [ ] **Step 1: Write failing request and response tests** + +```python +def test_mars_request_reuses_local_message_client_id(native_build): + result = _run_mars_contract(native_build, "serialize") + assert result["message_client_id"] == 202608020001 + assert result["network_submit_count"] == 1 + + +def test_mars_ack_returns_server_id_once(native_build): + result = _run_mars_contract(native_build, "accepted") + assert result["completion_count"] == 1 + assert result["accepted"] is True + assert result["server_id"] == 99 + + +def test_mars_timeout_never_resubmits(native_build): + result = _run_mars_contract(native_build, "timeout") + assert result["network_submit_count"] == 1 + assert result["accepted"] is False +``` + +- [ ] **Step 2: Run the new tests and verify RED** + +```bash +PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k mars +``` + +Expected: failures because the original adapter generates its own client ID and discards response data. + +- [ ] **Step 3: Convert the original adapter into `MarsGateway`** + +Preserve `BuildTextRequest`, `PrepareOpaqueObjects`, `MMStartTask`, and the working Req2Buf hook layout. Replace its task-generated client ID with `LocalMessageIdentity.client_id`. Decode the response through the measured profile boundary and complete once with `NetworkAcceptance{true, positive_server_id}`. Transport errors, parse failures, process exit, or missing IDs complete unknown and never call `MMStartTask` again. + +Expose this factory in `native/include/wechat_bridge.hpp`: + +```cpp +std::shared_ptr CreatePinnedMarsGateway( + std::string* unsupported_reason); +``` + +- [ ] **Step 4: Verify GREEN and all native regressions** + +```bash +PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k mars +PYTHONPATH=. pytest -q tests/test_native_bridge_host.py +``` + +Expected: both commands exit 0. + +- [ ] **Step 5: Commit the Mars gateway** + +```bash +git add native/include/wechat_bridge.hpp native/src/wechat_adapter.mm native/src/hook_trampolines.S \ + native/tests/fake_host.mm tests/test_native_bridge_host.py +git commit -m "feat: correlate Mars ACK with local message" +``` + +### Task 6: Wire the production factory and fail-closed profile gate + +**Files:** +- Modify: `native/include/wechat_bridge.hpp` +- Modify: `native/src/bridge_service.mm` +- Modify: `native/src/wechat_adapter.mm` +- Modify: `native/src/bridge_entry.mm` +- Modify: `native/tests/fake_host.mm` +- Modify: `tests/test_native_bridge_host.py` +- Modify: `wechat_cli/core/native_bridge_provider.py` +- Modify: `tests/test_native_bridge_provider.py` + +- [ ] **Step 1: Write failing production-factory tests** + +```python +def test_production_factory_requires_complete_local_profile(native_build): + result = _run_factory_contract(native_build, "missing-notify-signature") + assert result == "unsupported_wechat_local_store_profile" + + +def test_production_factory_composes_local_and_mars_gateways(native_build): + result = _run_factory_contract(native_build, "complete-fake-profile") + assert result == "local-visible-adapter-ready" + + +def test_local_visible_endpoint_is_distinct_from_base_endpoint(tmp_path): + paths = _bridge_paths(tmp_path, pid=8717) + assert paths.metadata.name == "bridge-8717-local-visible.json" + assert paths.socket.name == "wechat-bridge-8717-local-visible.sock" + assert paths.error.name == "bridge-8717-local-visible.error" +``` + +- [ ] **Step 2: Run the tests and verify RED** + +```bash +PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k production_factory +``` + +Expected: failures because production still constructs only the Mars adapter. + +- [ ] **Step 3: Compose the production transaction** + +`CreateProductionWeChatAdapter` must validate the image path, UUID, SHA-256 supplied by preflight, every generated entry signature, and the storage dispatcher before returning. It creates both gateways first, rejects either error, and then returns: + +```cpp +return CreateLocalVisibleAdapter( + std::move(local_gateway), + std::move(mars_gateway)); +``` + +Any missing component publishes `unsupported_wechat_local_store_profile` before the bridge socket starts. Do not retain the direct UI closure RVA in executable code. + +Add `BridgeConfig.endpoint_suffix = "local-visible"`, accept only the literal +safe suffix in `BridgeService`, and use it consistently for metadata, socket, +startup-error, and Python provider paths. This prevents an already-loaded base +bridge in the same PID from being mistaken for the local-visible bridge and +permits hot loading without a WeChat restart. + +Define the Python path helper with a stable return type: + +```python +@dataclass(frozen=True) +class BridgePaths: + metadata: Path + socket: Path + error: Path + + +def _bridge_paths(directory: Path, pid: int) -> BridgePaths: + suffix = "local-visible" + return BridgePaths( + metadata=directory / f"bridge-{pid}-{suffix}.json", + socket=directory / f"wechat-bridge-{pid}-{suffix}.sock", + error=directory / f"bridge-{pid}-{suffix}.error", + ) +``` + +- [ ] **Step 4: Verify GREEN and run all native tests** + +```bash +PYTHONPATH=. pytest -q tests/test_native_bridge_host.py -k production_factory +PYTHONPATH=. pytest -q tests/test_native_bridge_provider.py +PYTHONPATH=. pytest -q tests/test_native_bridge_host.py +``` + +Expected: both commands exit 0. + +- [ ] **Step 5: Commit production composition** + +```bash +git add native/include/wechat_bridge.hpp native/src/bridge_service.mm \ + native/src/wechat_adapter.mm native/src/bridge_entry.mm \ + native/tests/fake_host.mm tests/test_native_bridge_host.py \ + wechat_cli/core/native_bridge_provider.py tests/test_native_bridge_provider.py +git commit -m "feat: enable pinned local-visible send adapter" +``` + +### Task 7: Run Python transaction and confirmation regressions + +**Files:** +- Test: `tests/test_native_send_service.py` +- Test: `tests/test_send_confirmation.py` +- Test: `tests/test_send.py` + +- [ ] **Step 1: Add a regression for the persistent row contract if absent** + +```python +def test_native_send_accepts_only_the_bridge_local_row(): + service, store = _service( + receipt=_receipt(local_id=41, server_id=99), + confirmation=MessageConfirmation(local_id=41, server_id=99), + ) + result = service.send_text(_request()) + assert result.local_id == 41 + assert result.server_id == 99 + assert store.poll_calls[0]["local_id"] == 41 +``` + +- [ ] **Step 2: Run the focused Python tests** + +```bash +PYTHONPATH=. pytest -q \ + tests/test_native_send_service.py \ + tests/test_send_confirmation.py \ + tests/test_send.py +``` + +Expected: all tests pass; if the regression already exists, no Python production file changes are required. + +- [ ] **Step 3: Commit only if a test was added** + +```bash +git add tests/test_native_send_service.py +git commit -m "test: require exact persisted local message" +``` + +### Task 8: Package, verify, and perform one explicitly approved live test + +**Files:** +- Modify: `wechat_cli/bin/libwechat_send_bridge.dylib` +- Modify: `wechat_cli/bin/wechat_send_injector` +- Modify: `README.md` +- Modify: `README_CN.md` + +- [ ] **Step 1: Run the complete automated suite** + +```bash +PYTHONPATH=. pytest -q +``` + +Expected: zero failures. + +- [ ] **Step 2: Build and inspect packaged ARM64 artifacts** + +```bash +./native/build.sh wechat_cli/bin +file wechat_cli/bin/libwechat_send_bridge.dylib \ + wechat_cli/bin/wechat_send_injector +codesign --verify --strict wechat_cli/bin/libwechat_send_bridge.dylib +codesign --verify --strict wechat_cli/bin/wechat_send_injector +``` + +Expected: both artifacts are ARM64 and both signature checks exit 0. + +- [ ] **Step 3: Verify CLI and fail-closed behavior without sending** + +```bash +PYTHONPATH=. python -m wechat_cli.main send --help +``` + +Expected: documented group/text/timeout/format options and no process attachment. + +- [ ] **Step 4: Ask for a separate live-test confirmation** + +State the exact group and one unique message. Do not reuse any earlier failed text. Submit exactly once only after the user confirms. + +- [ ] **Step 5: Verify local persistence after that one submission** + +Confirm all four observations without retrying: the CLI receipt contains positive `local_id/server_id`, the desktop conversation displays the text, `wechat-cli history` contains the exact outgoing row, and the row remains after a user-chosen WeChat restart. If any observation is incomplete, report `unknown` and stop. + +- [ ] **Step 6: Update documentation and commit packaged output** + +Document the pinned build, persistent local-row behavior, no-retry rule, and unsupported-profile failure. + +```bash +git add README.md README_CN.md \ + wechat_cli/bin/libwechat_send_bridge.dylib \ + wechat_cli/bin/wechat_send_injector +git commit -m "feat: ship persistent local-visible group send" +``` diff --git a/docs/superpowers/plans/2026-08-02-safe-local-visible-send-recovery-phase1.md b/docs/superpowers/plans/2026-08-02-safe-local-visible-send-recovery-phase1.md new file mode 100644 index 0000000..dd2fd8c --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-safe-local-visible-send-recovery-phase1.md @@ -0,0 +1,1233 @@ +# Safe Local-Visible Send Recovery Phase 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Remove the deterministic WeChat crash path, make production sending fail before injection while evidence is incomplete, and produce a complete measured local-message profile from one explicitly approved read-only observation. + +**Architecture:** Phase 1 deliberately ships no enabled sender. It replaces the unsafe high-level adapter with a fail-closed production stub, restores the strict identifier-based receipt contract, and builds an auditable capture/validation pipeline whose output remains `adapter_ready: false`. Phase 2 is written only after the measured profile supplies exact native types, entry signatures, ownership, execution context, and state transitions. + +**Tech Stack:** Python 3.10+, pytest, C++17/Objective-C++, ARM64 Mach-O inspection, LLDB Python API in read-only mode, macOS code signing and process inspection. + +--- + +## Evidence correction (2026-08-02) + +Static verification after Tasks 1-4 invalidated the assumed complete probe map +used by Tasks 4-8 below. In particular, the model setters may be direct field +writes, `0x372FD60` is not proved to be the send-response decoder, and +`0x202906C` is inside a larger function rather than a notification entry. + +The schema-v2 profile examples and the zero-RVA resolution instructions below +are retained as historical plan context, but they must not be used to sanitize +or generate artifacts from the first marker capture. The corrected discovery +capture installs only the four verified boundaries documented in +`docs/research/wechat-4.1.8.28-local-store-candidates.md`: insert wrapper, insert +implementation, Mars submit, and local update. Its purpose is to capture the +insert/update model data and backtraces needed to discover the actual response +and notification boundaries. No Task 8 profile artifact may be generated until +a revised schema removes every disproved role and a new failing-test-first plan +defines the evidence chain. + +## Scope and sequencing + +This plan covers the safety and evidence boundary only. It does not guess the +private local-message gateway ABI and does not perform `wechat-cli send`. + +The phase is complete when: + +- the crashing `OwlHighLevelSendAdapter` cannot be built or selected; +- `wechat-cli send` refuses before invoking the injector while + `adapter_ready` is false or absent; +- Python accepts only an acknowledged receipt with matching positive IDs; +- one sanitized measured profile passes strict validation; +- the generated profile manifest still says `adapter_ready: false`; +- all tests and clean temporary builds pass without injecting WeChat. + +Only then can a Phase 2 plan name exact typedefs and implement +`PinnedLocalMessageGateway` and `PinnedMarsGateway` without placeholders. + +## File map + +- `native/src/production_adapter.mm`: New fail-closed production factory used + until the measured gateways are implemented in Phase 2. +- `native/src/wechat_local_visible.mm`: Delete the deterministic crash path. +- `wechat_cli/bin/libwechat_local_visible_bridge*.dylib`: Delete the untracked + experimental local-visible artifacts so `wechat_cli = ["bin/*"]` cannot + package v1/v3/v4/v5/v6 accidentally. +- `native/build.sh`: Stop compiling the unsafe source; compile the transaction, + production stub, bridge service, and legacy Mars code into a safe-v1 artifact. +- `native/include/wechat_bridge.hpp`: Remove unsafe helper exports and the + transient submitted ACK state; retain the committed gateway contracts. +- `native/src/bridge_entry.mm`: Use the stable `local-visible-safe-v1` endpoint. +- `native/src/bridge_service.mm`: Serialize only acknowledged/unknown receipts. +- `native/tests/fake_host.mm`: Remove forged high-level model/Owl probes and add + production-stub contract coverage. +- `tests/test_native_bridge_host.py`: Crash regression, source/artifact ban, and + production-stub tests. +- `wechat_cli/core/native_bridge_provider.py`: Validate a packaged profile + manifest before running the injector. +- `tests/test_native_bridge_provider.py`: Prove missing/incomplete profiles stop + before subprocess execution. +- `wechat_cli/core/send_bridge.py`: Restore acknowledged/unknown protocol only. +- `wechat_cli/core/native_sending.py`: Require receipt IDs and exact-ID database + confirmation. +- `wechat_cli/core/send_confirmation.py`: Remove exact-text fallback lookup. +- `tests/test_send_bridge.py`: Reject submitted receipts. +- `tests/test_native_send_service.py`: Reject receipts without positive IDs. +- `tests/test_send_confirmation.py`: Retain exact-local-ID confirmation tests and + remove fallback-only cases. +- `tools/wechat_profile_capture.py`: Bounded phased read-only LLDB capture. +- `tools/validate_wechat_profile.py`: Strict raw-evidence parser, sanitizer, + profile validator, header and manifest generator. +- `tests/test_wechat_profile_capture.py`: Pure capture-plan and no-call tests. +- `tests/test_wechat_profile_validation.py`: Incomplete, inconsistent, unsafe, + and measured-profile tests. +- `tests/fixtures/wechat_local_store_capture.json`: Sanitized measured output, + created only after the approved marker observation. +- `native/include/wechat_4_1_8_28_profile.hpp`: Generated measured constants, + created only after validation. +- `wechat_cli/bin/libwechat_local_visible_bridge_safe_v1.dylib`: Generated + fail-closed Phase 1 bridge whose digest is pinned by the manifest. +- `wechat_cli/bin/wechat_local_visible_profile.json`: Generated profile gate with + `adapter_ready: false` in Phase 1. +- `docs/research/wechat-4.1.8.28-local-store-profile.md`: Evidence table and raw + capture digest, with no message text or memory dumps. + +### Task 1: Quarantine the deterministic crash path + +**Files:** +- Create: `native/src/production_adapter.mm` +- Delete: `native/src/wechat_local_visible.mm` +- Delete: `wechat_cli/bin/libwechat_local_visible_bridge.dylib` +- Delete: `wechat_cli/bin/libwechat_local_visible_bridge_v3.dylib` +- Delete: `wechat_cli/bin/libwechat_local_visible_bridge_v4.dylib` +- Delete: `wechat_cli/bin/libwechat_local_visible_bridge_v5.dylib` +- Delete: `wechat_cli/bin/libwechat_local_visible_bridge_v6.dylib` +- Modify: `native/build.sh` +- Modify: `native/include/wechat_bridge.hpp` +- Modify: `native/src/bridge_entry.mm` +- Modify: `native/tests/fake_host.mm` +- Modify: `tests/test_native_bridge_host.py` + +- [ ] **Step 1: Write the crashing-symbol regression test** + +Add this test to `tests/test_native_bridge_host.py`: + +```python +def test_production_build_excludes_crashing_high_level_send(native_build): + _, dylib = native_build + symbols = subprocess.run( + ["nm", "-nm", str(dylib)], + check=True, + text=True, + capture_output=True, + ).stdout + build_script = (ROOT / "native" / "build.sh").read_text(encoding="utf-8") + source = "\n".join( + path.read_text(encoding="utf-8") + for path in (ROOT / "native" / "src").glob("*.mm") + ) + + assert "OwlHighLevelSendAdapter" not in symbols + assert "OwlHighLevelSendAdapter" not in source + assert "VerifyOwlMainScopeRunner" not in symbols + assert "wechat_local_visible.mm" not in build_script + assert "0x25cd228" not in source.lower() + unsafe_artifacts = ( + "libwechat_local_visible_bridge.dylib", + "libwechat_local_visible_bridge_v3.dylib", + "libwechat_local_visible_bridge_v4.dylib", + "libwechat_local_visible_bridge_v5.dylib", + "libwechat_local_visible_bridge_v6.dylib", + ) + assert all(not (ROOT / "wechat_cli" / "bin" / name).exists() + for name in unsafe_artifacts) +``` + +Add a production-stub expectation: + +```python +def test_production_factory_fails_closed_until_profile_is_composed(native_build): + host, _ = native_build + completed = subprocess.run( + [str(host), "--verify-production-profile-incomplete"], + cwd=ROOT, + stdin=subprocess.DEVNULL, + text=True, + capture_output=True, + ) + assert completed.returncode == 0, completed.stderr +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```bash +uv run --project . pytest -q tests/test_native_bridge_host.py \ + -k 'excludes_crashing or profile_incomplete' +``` + +Expected: the first test fails because the v6 symbol, source, and build entry +exist; the second fails because the fake-host mode does not exist. + +- [ ] **Step 3: Add the fail-closed production factory** + +Create `native/src/production_adapter.mm` with exactly: + +```cpp +#include "wechat_bridge.hpp" + +#include +#include + +namespace wechat_bridge { + +std::shared_ptr CreateProductionWeChatAdapter( + std::string* unsupported_reason) { + if (unsupported_reason != nullptr) { + *unsupported_reason = "unsupported_wechat_local_store_profile"; + } + return nullptr; +} + +} // namespace wechat_bridge +``` + +In `native/build.sh`, remove `native/src/wechat_local_visible.mm`, add +`native/src/production_adapter.mm`, keep `native/src/local_visible_adapter.mm`, +and name the experimental output +`libwechat_local_visible_bridge_safe_v1.dylib` with the same install name. + +Delete `native/src/wechat_local_visible.mm`. In +`native/include/wechat_bridge.hpp`, delete only these unsafe declarations: + +```cpp +bool VerifyLocalVisibleTextLayout(); +bool VerifyRelocatedMessageVtable(std::uintptr_t image_base, + const void* bytes, + std::size_t size); +bool VerifyOwlMainScopeRunner(); +``` + +Delete the five exact experimental dylib paths listed in this task. Do not +delete the committed legacy Mars artifact `libwechat_send_bridge.dylib`. + +Set the exact bridge tag in `native/src/bridge_entry.mm`: + +```cpp +constexpr char kBridgeInstanceTag[] = "local-visible-safe-v1"; +``` + +Remove the three matching fake-host modes and their JSON output. Add this mode: + +```cpp +if (argc == 2 && + std::strcmp(argv[1], "--verify-production-profile-incomplete") == 0) { + std::string reason; + const auto adapter = + wechat_bridge::CreateProductionWeChatAdapter(&reason); + return adapter == nullptr && + reason == "unsupported_wechat_local_store_profile" + ? 0 + : 14; +} +``` + +- [ ] **Step 4: Rebuild in a temporary directory and verify GREEN** + +Run: + +```bash +uv run --project . pytest -q tests/test_native_bridge_host.py +``` + +Expected: all native host tests pass; no command attaches to WeChat. + +- [ ] **Step 5: Verify the unsafe source and symbol are absent** + +Run: + +```bash +rg -n 'OwlHighLevelSendAdapter|0x25cd228|wechat_local_visible\.mm' \ + native/src native/include native/build.sh wechat_cli/core +``` + +Expected: no production references. Historical design documents and crash +forensics are outside the searched paths. + +- [ ] **Step 6: Commit the quarantine** + +```bash +git add native/build.sh native/include/wechat_bridge.hpp \ + native/src/bridge_entry.mm native/src/production_adapter.mm \ + native/tests/fake_host.mm tests/test_native_bridge_host.py +git commit -m "fix: quarantine crashing local-visible adapter" +``` + +### Task 2: Stop before injection without a ready profile + +**Files:** +- Modify: `wechat_cli/core/native_bridge_provider.py` +- Modify: `tests/test_native_bridge_provider.py` + +- [ ] **Step 1: Write profile-manifest gate tests** + +Add to `tests/test_native_bridge_provider.py`: + +```python +import json +from types import SimpleNamespace + +import pytest + +from wechat_cli.core.sending import SendUnavailableError + + +def test_missing_profile_manifest_stops_before_injector(monkeypatch, tmp_path): + called = False + + def forbidden_run(*args, **kwargs): + nonlocal called + called = True + raise AssertionError("injector must not run") + + monkeypatch.setattr(native_bridge_provider, "_safe_bridge_directory", lambda: tmp_path) + monkeypatch.setattr(native_bridge_provider, "_package_binary", lambda name: tmp_path / name) + monkeypatch.setattr(native_bridge_provider.subprocess, "run", forbidden_run) + + with pytest.raises(SendUnavailableError, match="profile.*未完成"): + native_bridge_provider.prepare_native_bridge( + SimpleNamespace(pid=123), "12345678-1234-1234-1234-123456789abc" + ) + + assert called is False + + +@pytest.mark.parametrize( + "payload", + [ + {}, + [], + {"version": "1", "profile_complete": True, "adapter_ready": True}, + {"version": 1, "profile_complete": True, "adapter_ready": False}, + {"version": 1, "profile_complete": False, "adapter_ready": True}, + {"version": 1, "profile_complete": True, "adapter_ready": True, + "dylib": "../unsafe.dylib", "dylib_sha256": "0" * 64}, + ], +) +def test_incomplete_or_unsafe_manifest_stops_before_injector( + monkeypatch, tmp_path, payload +): + manifest = tmp_path / "wechat_local_visible_profile.json" + manifest.write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setattr(native_bridge_provider, "_package_binary", lambda name: tmp_path / name) + monkeypatch.setattr( + native_bridge_provider.subprocess, + "run", + lambda *args, **kwargs: pytest.fail("injector must not run"), + ) + + with pytest.raises(SendUnavailableError, match="profile.*未完成"): + native_bridge_provider.prepare_native_bridge( + SimpleNamespace(pid=123), "12345678-1234-1234-1234-123456789abc" + ) +``` + +- [ ] **Step 2: Run the tests and verify RED** + +Run: + +```bash +uv run --project . pytest -q tests/test_native_bridge_provider.py \ + -k 'profile_manifest or unsafe_manifest' +``` + +Expected: failures because the provider reads no manifest. + +- [ ] **Step 3: Implement strict manifest loading before `_safe_bridge_directory`** + +Add these constants and helpers to `native_bridge_provider.py`: + +```python +import hashlib +import json + +BRIDGE_INSTANCE_TAG = "local-visible-safe-v1" +BRIDGE_DYLIB_NAME = "libwechat_local_visible_bridge_safe_v1.dylib" +PROFILE_MANIFEST_NAME = "wechat_local_visible_profile.json" +_SHA256 = re.compile(r"[0-9a-f]{64}") +_UUID = re.compile( + r"[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}" +) + + +def _ready_profile(): + manifest_path = _package_binary(PROFILE_MANIFEST_NAME) + try: + info = manifest_path.lstat() + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise SendUnavailableError("本地可见发送 profile 未完成,未发送任何消息") from error + expected = { + "version", "profile_complete", "adapter_ready", "capture_sha256", + "image_uuid", "image_sha256", "dylib", "dylib_sha256", + } + if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid not in (0, os.geteuid()) + or info.st_mode & 0o022 + or not isinstance(payload, dict) + or set(payload) != expected + or payload["version"] != 1 + or payload["profile_complete"] is not True + or payload["adapter_ready"] is not True + or payload["dylib"] != BRIDGE_DYLIB_NAME + or not isinstance(payload["capture_sha256"], str) + or _SHA256.fullmatch(payload["capture_sha256"]) is None + or not isinstance(payload["image_uuid"], str) + or _UUID.fullmatch(payload["image_uuid"]) is None + or not isinstance(payload["image_sha256"], str) + or _SHA256.fullmatch(payload["image_sha256"]) is None + or not isinstance(payload["dylib_sha256"], str) + or _SHA256.fullmatch(payload["dylib_sha256"]) is None + ): + raise SendUnavailableError("本地可见发送 profile 未完成,未发送任何消息") + dylib = _package_binary(BRIDGE_DYLIB_NAME) + try: + dylib_info = dylib.lstat() + dylib_sha256 = hashlib.sha256(dylib.read_bytes()).hexdigest() + except OSError as error: + raise SendUnavailableError( + "本地可见发送 profile 与 bridge 不匹配,未发送任何消息" + ) from error + if ( + not stat.S_ISREG(dylib_info.st_mode) + or dylib_info.st_uid not in (0, os.geteuid()) + or dylib_info.st_mode & 0o022 + or dylib_sha256 != payload["dylib_sha256"] + ): + raise SendUnavailableError("本地可见发送 profile 与 bridge 不匹配,未发送任何消息") + return payload +``` + +Call `_ready_profile()` as the first statement of `prepare_native_bridge`, +before creating the runtime directory or resolving the injector. + +The manifest is intentionally absent until Task 8, and Task 8 writes +`adapter_ready: false`, so all Phase 1 production calls stop here. + +- [ ] **Step 4: Run provider tests and verify GREEN** + +Run: + +```bash +uv run --project . pytest -q tests/test_native_bridge_provider.py +``` + +Expected: all provider tests pass and mocked injector calls remain zero. + +- [ ] **Step 5: Commit the pre-injection gate** + +```bash +git add wechat_cli/core/native_bridge_provider.py \ + tests/test_native_bridge_provider.py +git commit -m "fix: gate local-visible injection on measured profile" +``` + +### Task 3: Restore strict identifier-based confirmation + +**Files:** +- Modify: `native/include/wechat_bridge.hpp` +- Modify: `native/src/bridge_service.mm` +- Modify: `wechat_cli/core/send_bridge.py` +- Modify: `wechat_cli/core/native_sending.py` +- Modify: `wechat_cli/core/send_confirmation.py` +- Modify: `tests/test_send_bridge.py` +- Modify: `tests/test_native_send_service.py` +- Modify: `tests/test_send_confirmation.py` + +- [ ] **Step 1: Write strict receipt regressions** + +In `tests/test_send_bridge.py`, replace the submitted-success case with: + +```python +def test_client_rejects_submitted_receipt_without_ids_as_unknown(tmp_path): + fake_socket = FakeSocket( + _response( + _success_payload( + ack_state="submitted", + local_id=None, + server_id=None, + ) + ) + ) + client = _client(tmp_path, fake_socket) + + with pytest.raises(SendUnknownError): + client.send_text(_request()) +``` + +In `tests/test_native_send_service.py`, add: + +```python +@pytest.mark.parametrize("local_id,server_id", [(None, None), (41, None), (None, 99)]) +def test_native_send_never_accepts_receipt_without_both_ids(local_id, server_id): + bridge = FakeBridge( + receipt=_receipt( + ack_state=BridgeAckState.ACKNOWLEDGED, + local_id=local_id, + server_id=server_id, + ) + ) + service, _, store, _ = _service(bridge=bridge) + + result = service.send_text(_request()) + + assert result.status is SendStatus.UNKNOWN + assert store.poll_calls == [] +``` + +In `tests/test_send_confirmation.py`, add: + +```python +def test_confirmation_rejects_matching_id_with_different_text(tmp_path): + db_path = tmp_path / "message.db" + _create_message_db( + db_path, + rows=[(21, 201, 1, 7, 2, "另一条消息")], + names=[(7, "wxid_me")], + ) + store = MessageConfirmationStore(_locator_for(db_path)) + baseline = MessageBaseline( + username=USERNAME, + max_local_ids={_identity(db_path): 20}, + ) + + confirmation = store.find_confirmation( + username=USERNAME, + self_username="wxid_me", + local_id=21, + text="预期消息", + baseline=baseline, + ) + + assert confirmation is None +``` + +- [ ] **Step 2: Run tests and verify RED** + +Run: + +```bash +uv run --project . pytest -q tests/test_send_bridge.py \ + tests/test_native_send_service.py +``` + +Expected: submitted is currently accepted and the service polls by text for a +receipt without IDs, so the new expectations fail. + +- [ ] **Step 3: Remove the transient submitted protocol and text fallback** + +Make these exact contract changes: + +```cpp +enum class AckState { + kAcknowledged, + kUnknown, +}; +``` + +In `native/src/bridge_service.mm`, encode only `acknowledged` or `unknown`. + +In `send_bridge.py`, remove `BridgeAckState.SUBMITTED`; require positive +`local_id` for acknowledged receipts and allow optional IDs only for unknown. + +In `NativeSendService.send_text`, accept only +`BridgeAckState.ACKNOWLEDGED`, require positive `receipt.local_id` and +`receipt.server_id`, and call only: + +```python +confirmation = self._confirmation_store.poll_confirmation( + username=request.username, + self_username=self_username, + local_id=receipt.local_id, + text=request.text, + baseline=baseline, + timeout=remaining, +) +``` + +Delete `find_confirmation_by_text` and `poll_confirmation_by_text` from +`send_confirmation.py`, their fake-store method, and their fallback-only tests. +Add a required `text` keyword to `find_confirmation` and `poll_confirmation`. +Select `message_content` in the same local-ID query and accept the row only when +`type(message_content) is str and message_content == text`. Update every direct +confirmation test call to pass the row's exact text; tests exercising invalid +rows or invalid baselines pass any non-empty string because they must fail +before content can establish success. In the native-service success test, +assert `store.poll_calls[0]["text"] == "你好 👋\n第二行"`. + +- [ ] **Step 4: Run the focused confirmation suite and verify GREEN** + +Run: + +```bash +uv run --project . pytest -q tests/test_send_bridge.py \ + tests/test_native_send_service.py tests/test_send_confirmation.py tests/test_send.py +``` + +Expected: all focused tests pass. + +- [ ] **Step 5: Commit the strict contract** + +```bash +git add native/include/wechat_bridge.hpp native/src/bridge_service.mm \ + wechat_cli/core/send_bridge.py wechat_cli/core/native_sending.py \ + wechat_cli/core/send_confirmation.py tests/test_send_bridge.py \ + tests/test_native_send_service.py tests/test_send_confirmation.py +git commit -m "fix: require exact local IDs for send success" +``` + +### Task 4: Define the measured profile schema and generator + +**Files:** +- Modify: `tools/validate_wechat_profile.py` +- Modify: `tests/test_wechat_profile_validation.py` + +- [ ] **Step 1: Replace the fabricated flat fixture with a measured schema** + +Use this exact top-level schema in tests: + +```python +EXPECTED_ARM64_SLICE_SHA256 = ( + "0e8c932461b883a4e4dc90313a14a30ea69a190fedf1083d014259bec56da2e0" +) + + +def _measured_profile(*, adapter_ready=False): + return { + "schema_version": 2, + "evidence_kind": "read_only_lldb", + "adapter_ready": adapter_ready, + "capture_sha256": "1" * 64, + "marker_sha256": "2" * 64, + "image": { + "path": "/Applications/WeChat.app/Contents/Frameworks/wechat.dylib", + "uuid": EXPECTED_UUID, + "sha256": EXPECTED_SHA256, + "arm64_slice_sha256": EXPECTED_ARM64_SLICE_SHA256, + }, + "runtime_ready": { + "entry_rva": 0x3EF9964, + "signature": "e98202f029b143f9490000b420011fd6", + "ready_value": 1, + }, + "execution_context": { + "thread_name": "coroutine", + "queue_label": "owl.main", + "dispatch_rva": 0x57B30, + "signature": "f85fbca9f65701a9f44f02a9fd7b03a9", + }, + "model": { + "factory_rva": 0x1010, + "factory_signature": "3" * 32, + "recipient_setter_rva": 0x1020, + "recipient_setter_signature": "4" * 32, + "text_setter_rva": 0x1030, + "text_setter_signature": "5" * 32, + "client_id_setter_rva": 0x1040, + "client_id_setter_signature": "6" * 32, + "create_time_setter_rva": 0x1050, + "create_time_setter_signature": "7" * 32, + "ownership": "shared_ptr", + }, + "insert": { + "entry_rva": 0x26E6264, + "signature": "e93c0390294d42f9490000b420011fd6", + "local_id_before": 0, + "local_id_after": 41, + "client_id": 202608020001, + }, + "mars": {"client_id": 202608020001, "submit_count": 1}, + "response": { + "decode_rva": 0x372FD60, + "signature": "8" * 32, + "server_id": 99, + }, + "update": { + "entry_rva": 0x26EA4FC, + "signature": "c93c0390296142f9490000b420011fd6", + "local_id": 41, + "server_id_before": 0, + "server_id_after": 99, + }, + "notification": { + "entry_rva": 0x202906C, + "signature": "9" * 32, + "local_id": 41, + }, + } +``` + +Add tests that remove one nested field at a time, disagree on IDs, use the +unsafe high-level RVAs `0x25CD228`/`0x3399194`, use `adapter_ready: true` in +Phase 1, or supply a wrong raw capture digest. Each must return a stable named +failure. Add a positive test that expects `profile_valid_not_ready`. + +- [ ] **Step 2: Run validator tests and verify RED** + +Run: + +```bash +uv run --project . pytest -q tests/test_wechat_profile_validation.py +``` + +Expected: failures because the current validator accepts a small flat, +hand-written dictionary and can render an activation header from it. + +- [ ] **Step 3: Implement strict nested validation** + +Implement these public functions in `tools/validate_wechat_profile.py`: + +```python +def validate_profile(profile, *, raw_capture=None): + """Return profile_valid_not_ready or one stable failure reason.""" + + +def sanitize_capture(raw_capture, *, image_path, arm64_slice_path, marker): + """Parse exact JSONL bytes and return schema-v2 data without raw secrets.""" + + +def render_header(profile): + """Render measured constants; reject adapter_ready=True during Phase 1.""" + + +def render_manifest(profile, *, dylib_name, dylib_sha256): + return { + "version": 1, + "profile_complete": True, + "adapter_ready": False, + "capture_sha256": profile["capture_sha256"], + "image_uuid": profile["image"]["uuid"], + "image_sha256": profile["image"]["sha256"], + "dylib": dylib_name, + "dylib_sha256": dylib_sha256, + } +``` + +`sanitize_capture` must hash the exact raw JSONL bytes and marker UTF-8 bytes, +verify the universal image SHA-256 +`b4a740135f3f1e937bca10caf0a95cff986ddf27fa6bb15ccaf64001fc651c93`, +verify the extracted ARM64 slice SHA-256 +`0e8c932461b883a4e4dc90313a14a30ea69a190fedf1083d014259bec56da2e0`, +then omit `regions`, registers, backtraces, raw model bytes, marker text, and +auth material. `validate_profile` must require exact key sets at every level, +exact ID agreement, one Mars submission, the supported image and slice, 16-byte +lowercase signatures, positive bounded RVAs, and absence of unsafe RVAs. + +- [ ] **Step 4: Run validator tests and verify GREEN** + +Run: + +```bash +uv run --project . pytest -q tests/test_wechat_profile_validation.py +``` + +Expected: all schema, sanitization, digest, and rendering tests pass. + +- [ ] **Step 5: Commit the evidence schema** + +```bash +git add tools/validate_wechat_profile.py tests/test_wechat_profile_validation.py +git commit -m "test: require measured local-store profile evidence" +``` + +### Task 5: Make the LLDB capture bounded, phased, and read-only + +**Files:** +- Modify: `tools/wechat_profile_capture.py` +- Create: `tests/test_wechat_profile_capture.py` + +- [ ] **Step 1: Write pure capture-plan tests** + +Create `tests/test_wechat_profile_capture.py` with: + +```python +import inspect + +from tools import wechat_profile_capture as capture + + +def test_probe_plan_never_uses_more_than_four_hardware_breakpoints(): + phases = capture.build_probe_phases() + assert phases + assert all(1 <= len(phase.probes) <= 4 for phase in phases) + assert {probe.label for phase in phases for probe in phase.probes} >= { + "runtime_ready", "model_factory", "recipient_setter", "text_setter", + "insert", "mars_submit", "response_decode", "update", "notification", + } + + +def test_capture_source_cannot_call_or_evaluate_wechat_functions(): + source = inspect.getsource(capture) + for forbidden in ( + "EvaluateExpression", "SBExpressionOptions", "CallFunction", + "process.Call", "thread.Call", "write_memory", "WriteMemory", + ): + assert forbidden not in source + + +def test_lldb_arguments_auto_detach_and_never_send(tmp_path): + arguments = capture.build_lldb_arguments( + pid=123, + marker="WCPROFILE-20260802-01", + output=tmp_path / "events.jsonl", + ) + lldb_commands = [ + arguments[index + 1] + for index, value in enumerate(arguments[:-1]) + if value == "-o" + ] + assert "process continue" in lldb_commands + assert any(command.startswith("wechat-profile-start ") + for command in lldb_commands) + assert all("process call" not in command.lower() + for command in lldb_commands) + assert all("expression" not in command.lower() + for command in lldb_commands) + assert any("detach" in command.lower() for command in lldb_commands) +``` + +- [ ] **Step 2: Run capture tests and verify RED** + +Run: + +```bash +uv run --project . pytest -q tests/test_wechat_profile_capture.py +``` + +Expected: import or attribute failures because phased probe objects and the +argument builder do not exist. + +- [ ] **Step 3: Implement the pure plan and command builder** + +Add: + +```python +from dataclasses import dataclass + + +@dataclass(frozen=True) +class Probe: + label: str + rva: int + capture_return: bool = False + + +@dataclass(frozen=True) +class ProbePhase: + name: str + probes: tuple[Probe, ...] + + +def build_probe_phases(): + return ( + ProbePhase("model", ( + Probe("runtime_ready", 0x3EF9964, True), + Probe("model_factory", 0, True), + Probe("recipient_setter", 0, False), + Probe("text_setter", 0, False), + )), + ProbePhase("insert", ( + Probe("client_id_setter", 0, False), + Probe("create_time_setter", 0, False), + Probe("insert", 0x26E6264, True), + Probe("mars_submit", 0x498D2E0, False), + )), + ProbePhase("accept", ( + Probe("response_decode", 0, True), + Probe("update", 0x26EA4FC, True), + Probe("notification", 0, False), + )), + ) +``` + +Zero RVAs are explicit unresolved candidates. `start_capture` must refuse a +phase containing a zero RVA and print the missing labels, so a marker is never +requested until static analysis has supplied every candidate. + +Implement `build_lldb_arguments` to import this script, start the capture, +continue, and execute `process detach` after the script's bounded completion +command. It must validate PID, marker length, and an absolute output path. + +- [ ] **Step 4: Implement return probes and bounded automatic detach** + +For each `capture_return=True` entry hit, read LR, create a one-shot breakpoint +restricted to the same thread, disable that entry breakpoint before installing +the return breakpoint, and capture `x0` plus the same bounded memory regions on +return. This keeps the installed hardware-breakpoint count at four or fewer. +The first marker-reachable model/setter event arms the bounded lifecycle; later +insert/Mars/response/update/notification events may lack marker bytes and are +retained only inside that same lifecycle window for the same observed thread, +model pointer, client ID, or local ID. The sanitizer must reject the capture if +those identities do not form one unbroken chain. Stop after 30 seconds by using +LLDB's asynchronous process interrupt, remove all breakpoints, write one +`capture_terminal` event with `complete` or a sorted list of missing labels, and +detach. Never invoke a target expression. + +Every raw event must contain only: + +```python +{ + "label": str, + "edge": "entry" | "return", + "time": float, + "rva": str, + "signature": str, + "marker_reachable": bool, + "thread_id": int, + "thread_name": str | None, + "queue_name": str | None, + "registers": dict[str, str], + "regions": dict[str, str], + "backtrace": list[dict[str, object]], +} +``` + +The final record has this separate exact schema: + +```python +{ + "label": "capture_terminal", + "status": "complete" | "incomplete" | "error", + "missing_labels": list[str], + "pid": int, + "duration_ms": int, +} +``` + +- [ ] **Step 5: Run capture tests and verify GREEN** + +Run: + +```bash +uv run --project . pytest -q tests/test_wechat_profile_capture.py \ + tests/test_wechat_profile_validation.py +``` + +Expected: all pure tests pass; this command does not import LLDB or attach. + +- [ ] **Step 6: Commit the bounded capture tool** + +```bash +git add tools/wechat_profile_capture.py tests/test_wechat_profile_capture.py +git commit -m "feat: add bounded read-only WeChat profile capture" +``` + +### Task 6: Resolve all zero-RVA candidates offline + +**Files:** +- Modify: `tools/wechat_profile_capture.py` +- Create: `docs/research/wechat-4.1.8.28-local-store-candidates.md` +- Modify: `tests/test_wechat_profile_capture.py` + +- [ ] **Step 1: Add a failing no-zero-RVA test** + +```python +def test_every_runtime_probe_has_a_resolved_positive_rva(): + probes = [probe for phase in capture.build_probe_phases() for probe in phase.probes] + assert all(0 < probe.rva < 0x10000000 for probe in probes) +``` + +- [ ] **Step 2: Run it and verify RED** + +Run: + +```bash +uv run --project . pytest -q tests/test_wechat_profile_capture.py \ + -k resolved_positive +``` + +Expected: failure listing model factory, setters, response decode, and +notification candidates as zero. + +- [ ] **Step 3: Resolve candidates using only the pinned image** + +Use `/private/tmp/wechat-arm64-profile.dylib`. Before disassembly, run: + +```bash +test "$(shasum -a 256 /private/tmp/wechat-arm64-profile.dylib | awk '{print $1}')" = \ + "0e8c932461b883a4e4dc90313a14a30ea69a190fedf1083d014259bec56da2e0" +dwarfdump --uuid /private/tmp/wechat-arm64-profile.dylib | \ + rg 'ABD88EC8-4590-3FDB-AAB5-4E5865B86B2A \(arm64\)' +``` + +Expected: both commands exit 0. Then disassemble the known normal-send call +chains around: + +```text +UI trigger: 0x3399194 +local insert: 0x26e6264 +local update: 0x26ea4fc +Mars submit: 0x498d2e0 +insert callers: 0x253b85c, 0x25634e4, 0x2533948, 0x260be0c, + 0x2609e88, 0x25cf0e4, 0x25cc098, 0x25cbf0c, + 0x25cd3b8 +update callers: 0x253b01c, 0x2565ad8, 0x2536fbc, 0x265274c, + 0x2651b1c, 0x3725be4 +``` + +For each new RVA, record the 16 entry bytes, every call-site argument register, +the caller chain that ties it to the marker model, and why it is a factory, +setter, response decoder, or notification boundary. Do not infer a role from a +string alone. + +Write the evidence table to +`docs/research/wechat-4.1.8.28-local-store-candidates.md` and replace every zero +RVA in `build_probe_phases()` with the measured candidate. + +- [ ] **Step 4: Run the no-zero and full capture tests to verify GREEN** + +```bash +uv run --project . pytest -q tests/test_wechat_profile_capture.py +``` + +Expected: all probe RVAs are positive and all capture tests pass. + +- [ ] **Step 5: Commit the offline candidate map** + +```bash +git add tools/wechat_profile_capture.py tests/test_wechat_profile_capture.py \ + docs/research/wechat-4.1.8.28-local-store-candidates.md +git commit -m "docs: map pinned local-store profile candidates" +``` + +### Task 7: Capture one explicitly approved normal marker send + +**Files:** +- Runtime output only: `/private/tmp/wechat-local-store-events-v2.jsonl` + +- [ ] **Step 1: Run all pre-capture safety checks** + +Run: + +```bash +uv run --project . pytest -q tests/test_wechat_profile_capture.py \ + tests/test_wechat_profile_validation.py tests/test_native_bridge_host.py +``` + +Expected: all tests pass. + +Record the current WeChat PID and crash-report count without operating the UI: + +```bash +wechat_profile_pid="$(pgrep -x WeChat)" +test "$(printf '%s\n' "$wechat_profile_pid" | awk 'NF {n++} END {print n+0}')" -eq 1 +printf '%s\n' "$wechat_profile_pid" +find "$HOME/Library/Logs/DiagnosticReports" -name 'WeChat-*.ips' | wc -l +``` + +Expected: exactly one entered WeChat PID. If account readiness is not observable +without UI operation, ask the user to confirm that WeChat is entered before +attaching. + +- [ ] **Step 2: Request action-time confirmation for the marker** + +State exactly: + +```text +The read-only profiler is ready. It requires you to manually send +"WCPROFILE-20260802-01" once to "AI聊天群". The tool will only observe and detach; +it will not send, click, focus, or type. May I start the capture? +``` + +Do not attach until the user confirms this distinct externally visible action. + +- [ ] **Step 3: Start the bounded read-only capture** + +Resolve exactly one PID again immediately before attach, then pass that exact +numeric value to the profiler: + +```bash +wechat_profile_pid="$(pgrep -x WeChat)" +test "$(printf '%s\n' "$wechat_profile_pid" | awk 'NF {n++} END {print n+0}')" -eq 1 +uv run --project . python tools/wechat_profile_capture.py \ + --pid "$wechat_profile_pid" \ + --marker WCPROFILE-20260802-01 \ + --output /private/tmp/wechat-local-store-events-v2.jsonl +``` + +Expected: `wechat_profile_ready`. Ask the user to manually send the exact marker +once. The capture must print `capture_complete` and detach within 30 seconds. + +If it prints missing labels, do not request another marker. Stop and return to +Task 6 with the captured evidence; any second marker needs a new explicit +confirmation. + +- [ ] **Step 4: Verify process health and capture completeness** + +Run: + +```bash +pgrep -x WeChat +find "$HOME/Library/Logs/DiagnosticReports" -name 'WeChat-*.ips' | wc -l +tail -n 1 /private/tmp/wechat-local-store-events-v2.jsonl +``` + +Expected: the PID and crash-report count are unchanged, and the terminal event +is complete. If WeChat exited or a new report exists, stop Phase 1. + +### Task 8: Sanitize, validate, and generate a not-ready profile + +**Files:** +- Create: `tests/fixtures/wechat_local_store_capture.json` +- Create: `native/include/wechat_4_1_8_28_profile.hpp` +- Create: `wechat_cli/bin/libwechat_local_visible_bridge_safe_v1.dylib` +- Create: `wechat_cli/bin/wechat_local_visible_profile.json` +- Create: `docs/research/wechat-4.1.8.28-local-store-profile.md` +- Modify: `tests/test_wechat_profile_validation.py` + +- [ ] **Step 1: Add a fixture reproducibility test** + +```python +def test_committed_profile_artifacts_are_reproducible(): + profile = json.loads(PROFILE_FIXTURE.read_text(encoding="utf-8")) + assert validate_profile(profile) == "profile_valid_not_ready" + assert PROFILE_HEADER.read_text(encoding="utf-8") == render_header(profile) + manifest = json.loads(PROFILE_MANIFEST.read_text(encoding="utf-8")) + assert manifest["profile_complete"] is True + assert manifest["adapter_ready"] is False + assert manifest["capture_sha256"] == profile["capture_sha256"] +``` + +- [ ] **Step 2: Run it and verify RED** + +Run: + +```bash +uv run --project . pytest -q tests/test_wechat_profile_validation.py \ + -k reproducible +``` + +Expected: failure because no measured fixture/header/manifest is committed. + +- [ ] **Step 3: Generate sanitized artifacts from the raw capture** + +Run: + +```bash +phase1_package_build="$(mktemp -d /private/tmp/wechat-phase1-package.XXXXXX)" +./native/build.sh "$phase1_package_build" +install -m 755 \ + "$phase1_package_build/libwechat_local_visible_bridge_safe_v1.dylib" \ + wechat_cli/bin/libwechat_local_visible_bridge_safe_v1.dylib +uv run --project . python tools/validate_wechat_profile.py \ + --raw-capture /private/tmp/wechat-local-store-events-v2.jsonl \ + --image /Applications/WeChat.app/Contents/Frameworks/wechat.dylib \ + --arm64-slice /private/tmp/wechat-arm64-profile.dylib \ + --marker WCPROFILE-20260802-01 \ + --fixture tests/fixtures/wechat_local_store_capture.json \ + --header native/include/wechat_4_1_8_28_profile.hpp \ + --manifest wechat_cli/bin/wechat_local_visible_profile.json \ + --bridge wechat_cli/bin/libwechat_local_visible_bridge_safe_v1.dylib +``` + +Expected: `profile_valid_not_ready`. The command must never print the marker or +raw memory. The generated manifest must contain `adapter_ready: false`. + +- [ ] **Step 4: Write the evidence document** + +Document the exact image fingerprint, capture digest, every measured RVA and +signature, ABI argument/return observations, execution context, ownership, +local/client/server ID agreement, notification evidence, and unresolved Phase 2 +work in `docs/research/wechat-4.1.8.28-local-store-profile.md`. + +Do not include marker text, raw registers, memory dumps, decrypted database +paths, account IDs, or tokens. + +- [ ] **Step 5: Run reproducibility and provider fail-closed tests** + +Run: + +```bash +uv run --project . pytest -q tests/test_wechat_profile_validation.py \ + tests/test_native_bridge_provider.py +``` + +Expected: all tests pass. Provider tests prove `adapter_ready: false` still +stops before injection. + +- [ ] **Step 6: Commit measured evidence only** + +```bash +git add tests/fixtures/wechat_local_store_capture.json \ + native/include/wechat_4_1_8_28_profile.hpp \ + wechat_cli/bin/libwechat_local_visible_bridge_safe_v1.dylib \ + wechat_cli/bin/wechat_local_visible_profile.json \ + docs/research/wechat-4.1.8.28-local-store-profile.md \ + tests/test_wechat_profile_validation.py +git commit -m "docs: pin measured local-store profile evidence" +``` + +Never add `/private/tmp/wechat-local-store-events-v2.jsonl`. + +### Task 9: Verify Phase 1 remains fail closed + +- [ ] **Step 1: Run the complete automated suite** + +```bash +uv run --project . pytest -q +``` + +Expected: zero failures. + +- [ ] **Step 2: Build and inspect temporary native artifacts** + +```bash +phase1_build_dir=$(mktemp -d /private/tmp/wechat-phase1-build.XXXXXX) +./native/build.sh "$phase1_build_dir" +file "$phase1_build_dir"/* +codesign --verify --strict "$phase1_build_dir/libwechat_local_visible_bridge_safe_v1.dylib" +nm -nm "$phase1_build_dir/libwechat_local_visible_bridge_safe_v1.dylib" | \ + rg 'OwlHighLevelSendAdapter|VerifyOwlMainScopeRunner' +``` + +Expected: the build and code-sign commands exit 0; the final `rg` exits 1 with +no matches. Remove the explicit temporary directory after recording its exact +path; it contains reproducible build outputs only. + +- [ ] **Step 3: Prove production still stops before injection** + +Run: + +```bash +uv run --project . python - <<'PY' +from wechat_cli.core.native_bridge_provider import _ready_profile +from wechat_cli.core.sending import SendUnavailableError + +try: + _ready_profile() +except SendUnavailableError as error: + message = str(error) + assert "profile" in message + assert "未发送任何消息" in message + print("profile_gate_fail_closed") +else: + raise AssertionError("Phase 1 profile must not enable the adapter") +PY +``` + +Expected: exit 0 and print only `profile_gate_fail_closed`. This imports and +checks the packaged manifest directly; it does not invoke the CLI, injector, +bridge socket, or message store. + +## Phase 1 completion gate + +Do not claim the send feature is fixed at the end of this plan. Report only that +the crashing route is quarantined and the measured profile is ready for Phase +2. Start a separate `writing-plans` pass only after Task 9, using the generated +header and evidence table to write every Phase 2 typedef, RVA, ownership rule, +and call sequence exactly. `测试信息6` remains unsent until that evidence-derived +Phase 2 implementation, clean verification, and pre-send runtime checks all +pass. diff --git a/docs/superpowers/specs/2026-08-01-local-visible-send-design.md b/docs/superpowers/specs/2026-08-01-local-visible-send-design.md new file mode 100644 index 0000000..e58859e --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-local-visible-send-design.md @@ -0,0 +1,114 @@ +# Local-Visible Background Send Design + +## Goal + +Make `wechat-cli send` create the same persistent outgoing text row that a +normal WeChat send creates, while retaining background operation, exact group +resolution, and the already-working network delivery path. + +## Observed behavior + +The adapter on `feat/wechat-send` submits one `/newsendmsg` Mars task. Group +members receive the text, but WeChat does not create an outgoing row in its +local message store. The message is therefore absent from the desktop +conversation and from `wechat-cli history`. + +An experimental replacement called a captured UI send closure from the GCD +main queue. WeChat aborted before local persistence or network submission with +`await_noexcept() must be called from coroutine context`. That boundary cannot +be called directly and remains disabled. + +## Chosen design + +Retain the proven Mars adapter from `feat/wechat-send` and add a pinned local +persistence stage for WeChat 4.1.8.28 ARM64. Before the existing adapter submits +its one network task, the bridge asks WeChat's own MessageStore/MessageService +layer, on its required internal queue, to create the outgoing text row. The +returned positive `local_id` and the message's client identifier are reused by +the Mars request. A successful response updates that same row's status and +`server_id` through WeChat's internal message API and publishes the normal +conversation refresh notification. + +This design does not call the UI send closure or replace the working network +path. The base `feat/wechat-send` branch remains unchanged. If local persistence +cannot be proven safe before any side effect, this branch fails before network +submission. After a local row or network task may exist, every incomplete +result is `unknown`; the bridge never falls back or retries. + +## Data flow + +1. Resolve an exact group and validate non-empty text as today. +2. Build one outgoing WeChat message model with a unique request/client ID. +3. Submit a local-insert operation to WeChat's message-storage queue and require + a positive `local_id` before continuing. +4. Serialize the existing Mars task once, reusing the same recipient, text, + client ID, timestamp, and local identifier where the pinned protocol fields + support it. +5. On the internal response callback, update the same local row to the accepted + state with a positive `server_id`, then notify the conversation model. +6. Refresh the existing read cache and confirm that exact outgoing row before + reporting `server_accepted`. + +If the local row exists but submission or acknowledgement is incomplete, the +row remains pending or failed according to WeChat's own state transition. It is +not deleted, rewritten directly, or automatically resent. + +## Profile discovery + +Use offline metadata, strings, disassembly, and instruction cross-references +first. Candidate storage and update boundaries must expose or lead to all of: + +- an exact `@chatroom` recipient; +- UTF-8 text passed without rewriting; +- creation of a positive local message ID on WeChat's storage queue; +- an update of the same row by local/client identifier; +- a conversation refresh notification; +- a completion path that stores a positive server ID. + +Any runtime observation remains read-only until the ABI, queue, ownership, and +instruction signatures are known. No diagnostic message is sent to a real group +without a separate explicit request. + +## Bridge contract + +Keep the existing authenticated Unix-socket request format and the Mars +adapter's single network submission. The native adapter returns `acknowledged` +only when it has a positive local ID and the corresponding internal response +updated the same message. Python then confirms the outgoing row has `status=2` +and a positive server ID before returning `server_accepted`. + +The bridge remains single-flight. Timeout, process exit, incomplete callbacks, +or identifier disagreement returns `unknown` and forbids automatic retry. + +## Hot loading + +Build the experimental bridge with a distinct dylib/install name and distinct +PID-specific metadata/socket names. This permits loading without restarting +WeChat while preventing reuse of a bridge from a dead PID. The CLI on this +branch uses only the experimental endpoint and never falls back to the base +endpoint after a request begins. + +## Non-goals + +- No direct writes to WeChat databases. +- No UI input, clipboard use, or foreground-window switching. +- No external recreation of the WeChat network protocol. +- No direct invocation of an `await_noexcept` closure from a GCD queue. +- No automatic fallback or retry after a possible submission. +- No support for non-text messages, mentions, replies, attachments, or direct + contacts. + +## Failure containment and tests + +Every pinned function has a build fingerprint and entry-instruction signature. +The storage queue, ownership rules, local-ID output, update callback, and +notification boundary must all be profiled; partial profiles remain disabled. +Unsupported or ambiguous profiles fail before local insertion or network +submission. All owned argument storage remains alive through completion. + +Native fake-host tests cover local insert success/failure, exact identifier +reuse, acknowledgement updates, conversation notifications, timeouts, and the +rule that no network submission occurs when local insertion fails. CI never +attaches to WeChat. A real test is allowed only after those checks pass and the +user separately approves one uniquely identifiable message to a dedicated test +group. diff --git a/docs/superpowers/specs/2026-08-02-safe-local-visible-send-recovery-design.md b/docs/superpowers/specs/2026-08-02-safe-local-visible-send-recovery-design.md new file mode 100644 index 0000000..5330d54 --- /dev/null +++ b/docs/superpowers/specs/2026-08-02-safe-local-visible-send-recovery-design.md @@ -0,0 +1,213 @@ +# Safe Local-Visible Background Send Recovery Design + +## Goal + +Make `wechat-cli send` deliver one group text while creating the same persistent +outgoing row that desktop WeChat displays, without focusing, raising, clicking, +typing into, or otherwise operating any WeChat window. The path must also work +when WeChat is minimized or covered by another application. + +The first approved live acceptance message is exactly `测试信息6` to the uniquely +resolved group `AI聊天群`. It may be submitted once only after every offline and +non-mutating runtime gate passes. + +## Incident and root cause + +The current uncommitted `local-visible v6` experiment is unsafe and must not be +used again. Crash reports at 14:03 and 18:02 on 2026-08-02 show the same +deterministic failure: + +- WeChat 4.1.8 (36571) terminates itself with `SIGABRT` on the main thread. +- The stack returns directly to `OwlHighLevelSendAdapter` after its indirect call + to WeChat RVA `0x25cd228`. +- The adapter passes a manually fabricated private message object to the high + level send routine. +- WeChat reaches an internal fatal invariant and calls `abort()` before a local + row or confirmed network result exists. + +The exact rejected private field is not present in the stripped crash report, +but the call boundary and repeated failure are conclusive. This is not a login +transition, timeout, or random process crash. + +The existing 212-test suite passes because its fake Owl runner executes a C++ +lambda without modeling WeChat's private object and coroutine invariants. Those +tests do not establish live safety. + +## Rejected approaches + +### Patch the high-level private send object + +Rejected. The private object layout and caller-owned invariants are incomplete, +and two live attempts produced the same fatal abort. Further field-by-field live +experiments would use the user's WeChat process as a crash probe. + +### Directly write the WeChat database + +Rejected. Direct writes can desynchronize WCDB indexes, conversation state, +notifications, encryption metadata, and server acknowledgement state. They also +cannot prove that the desktop client accepted and owns the row. + +### UI automation + +Rejected by the product requirement. The send path must not focus or operate a +window, clipboard, input field, or keyboard, even temporarily. + +## Chosen architecture + +Retain the committed queue-independent `LocalVisibleAdapter` transaction and +complete its two gateway dependencies: + +1. `PinnedLocalMessageGateway` creates and updates an outgoing row through + WeChat's measured internal message-storage API on its measured execution + context. +2. `PinnedMarsGateway` retains the proven one-shot network task from + `feat/wechat-send` and reuses the local message identity. +3. `LocalVisibleAdapter` sequences local insert, one network submission, and + local acceptance update. It never retries or falls back. + +The production factory composes these gateways only when an exact, complete, +measured profile is available for the running WeChat image. Otherwise `send` +fails before creating a local row or submitting a network task. + +The unsafe `OwlHighLevelSendAdapter`, its source, its provider selection, and +all experimental v3-v6 packaged dylibs are removed from the production path. + +## Profile evidence gate + +Production constants must be generated from one sanitized, read-only capture +of a normal user-performed marker send on the exact supported build. Static +disassembly may explain captured calls but cannot substitute for runtime +evidence. + +The capture must establish all of these in one message lifecycle: + +- exact image path, ARM64 UUID, SHA-256, and entry signatures; +- the real message model ownership and required lifetime; +- the storage execution context and dispatch boundary; +- exact insert arguments and a zero-to-positive `local_id` transition; +- the same client identifier in local storage and the Mars request; +- the server response decode boundary and a positive `server_id`; +- update of the same local row to accepted status; +- the normal conversation refresh notification for that row; +- a non-mutating runtime-readiness probe that distinguishes an entered, + send-capable account from the pre-entry login screen. + +The validator emits a pinned header only when every invariant agrees. A +hand-written test fixture can test validator branches, but it can never enable +the production factory. Generated output records a digest of the sanitized raw +capture so fabricated constants or partial captures fail closed. + +If another manual marker capture is required, it is a separate externally +visible action and requires explicit confirmation at action time. The capture +tool only observes and automatically detaches; it never calls a WeChat function +or sends a message. + +## Send data flow + +1. Resolve `GROUP` to one exact existing `@chatroom` contact and validate text. +2. Verify the pinned app path, bundle/build identifiers, image UUID/hash, code + signatures, one live WeChat PID, and the measured send-capable account state. +3. Capture the target message-table baseline before any mutation. +4. Generate one request ID, client ID, and creation timestamp. +5. On the measured storage context, insert the outgoing text model and require a + positive local ID that matches the transaction identity. +6. Submit the proven Mars task exactly once with the same recipient, text, + client ID, timestamp, and supported local identifier fields. +7. Decode one positive server acceptance. On the measured storage context, + update the same local row and publish the measured conversation notification. +8. Refresh the read cache and confirm exactly one new outgoing text row with the + same local ID, positive server ID, sender identity, text, and accepted status. +9. Return `server_accepted` only after all checks pass. + +No step operates the WeChat UI or changes the frontmost application. + +## Failure semantics + +Failures are classified by the last proven boundary: + +- Before local insertion and before network submission: return `unavailable` + and explicitly state that no message was sent. +- After a local row or any network bytes may exist: return `unknown`, preserve + every known identifier, and prohibit automatic retry. +- Process exit, bridge disconnect, timeout, duplicate matching rows, identifier + disagreement, missing notification, or an incomplete callback after a + mutation boundary is always `unknown`. +- A stale bridge from another PID or profile is never reused. +- The tool never restarts, relaunches, re-enters, or foregrounds WeChat after a + failure. + +## Safety containment + +- The build and source tests ban the high-level send RVA and + `OwlHighLevelSendAdapter` from the production bridge. +- Profile validation occurs before the production adapter becomes reachable. +- All native argument, callback, model, and task storage remains owned through + its final callback or process lifetime where the WeChat lifetime is unknown. +- The local and network gateways are single-flight for one group transaction. +- Injection permission failure is an `unavailable` result; the tool does not + re-sign or restart WeChat during `send`. +- Runtime files use distinct PID- and implementation-scoped names and strict + ownership/mode checks. +- Logs and receipts exclude message text, auth tokens, decrypted keys, and raw + captured memory. + +## Testing strategy + +Implementation follows test-first red-green-refactor cycles. + +### Static and profile tests + +- Reject every missing capture invariant, synthesized production profile, + wrong image, unsafe RVA, signature mismatch, or queue mismatch. +- Verify the generated header is reproducible from the sanitized capture. +- Verify production source and linked artifacts contain no reference to the + unsafe adapter or high-level send entry. + +### Native fake-gateway tests + +- Insert failure performs zero network submissions. +- Success performs one insert, one network submission, and one update in order. +- Every stage reuses the same identity. +- Duplicate, late, invalid, timeout, and reentrant callbacks complete once and + never resubmit. +- Model and callback ownership survives asynchronous completion. + +### Python and bridge tests + +- Only an acknowledged receipt with matching positive IDs and the exact new + database row returns success. +- Pre-entry/login-not-ready state fails before message mutation. +- Stale endpoints, process replacement, and malformed receipts fail closed. +- Full CLI, bridge, confirmation, and packaging suites pass from a clean build. + +### Live acceptance test + +Before the one approved send, record the frontmost app, WeChat PID, crash-report +baseline, target history baseline, and absence of `测试信息6`. The user controls +whether WeChat is minimized; the tool does not change its window state. + +Submit exactly once to `AI聊天群`, then require all of: + +- CLI receipt contains matching positive `local_id` and `server_id`; +- `wechat-cli history` contains exactly one new outgoing `测试信息6` row; +- the desktop conversation displays `测试信息6` when inspected read-only; +- WeChat PID is unchanged and no new crash report exists; +- the previously frontmost application remains frontmost throughout send. + +If the send result is `unknown`, do not retry even when a later observation is +missing. Report the evidence and stop. + +## Non-goals + +- Direct contacts, non-text messages, mentions, replies, and attachments. +- Supporting a different WeChat version or image fingerprint. +- Restarting WeChat to prove persistence during this recovery task. +- Generalizing private ABI discovery beyond the one pinned supported build. +- Hiding an unsupported or incomplete profile behind a best-effort fallback. + +## Acceptance criteria + +The recovery is complete only when the unsafe path is absent, the evidence gate +is complete, clean automated verification passes, and the single approved live +test satisfies every observation above. Passing fake tests without a complete +measured profile is not completion. diff --git a/native/build.sh b/native/build.sh new file mode 100755 index 0000000..828842b --- /dev/null +++ b/native/build.sh @@ -0,0 +1,74 @@ +#!/bin/bash +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" || "$(uname -m)" != "arm64" ]]; then + echo "native bridge builds only on macOS ARM64" >&2 + exit 2 +fi + +if [[ $# -ne 1 || -z "$1" ]]; then + echo "usage: native/build.sh OUTPUT_DIRECTORY" >&2 + exit 2 +fi + +script_directory="$(cd "$(dirname "$0")" && pwd)" +output_directory="$1" +compiler="$(xcrun --find clang++)" +sdk_path="$(xcrun --sdk macosx --show-sdk-path)" + +mkdir -p "$output_directory" + +common_flags=( + -std=c++17 + -arch arm64 + -isysroot "$sdk_path" + -I"$sdk_path/usr/include/c++/v1" + -mmacosx-version-min=13.0 + -fobjc-arc + -fblocks + -Wall + -Wextra + -Werror + -I"$script_directory/include" +) + +"$compiler" "${common_flags[@]}" \ + -dynamiclib \ + -Wl,-install_name,@rpath/libwechat_bridge_core.dylib \ + "$script_directory/src/bridge_service.mm" \ + "$script_directory/src/local_visible_adapter.mm" \ + "$script_directory/src/official_task_adapter.mm" \ + "$script_directory/src/production_adapter.mm" \ + "$script_directory/src/wechat_adapter.mm" \ + "$script_directory/src/official_task_trampolines.S" \ + "$script_directory/src/hook_trampolines.S" \ + -framework Foundation \ + -framework Security \ + -o "$output_directory/libwechat_bridge_core.dylib" + +"$compiler" "${common_flags[@]}" \ + "$script_directory/tests/fake_host.mm" \ + -L"$output_directory" \ + -lwechat_bridge_core \ + -Wl,-rpath,@loader_path \ + -framework Foundation \ + -o "$output_directory/native_fake_host" + +"$compiler" "${common_flags[@]}" \ + -dynamiclib \ + -Wl,-install_name,@rpath/libwechat_official_task_bridge_v2.dylib \ + "$script_directory/src/bridge_service.mm" \ + "$script_directory/src/official_task_adapter.mm" \ + "$script_directory/src/production_adapter.mm" \ + "$script_directory/src/official_task_trampolines.S" \ + "$script_directory/src/bridge_entry.mm" \ + -framework Foundation \ + -framework Security \ + -o "$output_directory/libwechat_official_task_bridge_v2.dylib" + +"$compiler" "${common_flags[@]}" \ + "$script_directory/src/injector.cc" \ + -o "$output_directory/wechat_send_injector" + +codesign --force --sign - "$output_directory/libwechat_official_task_bridge_v2.dylib" +codesign --force --sign - "$output_directory/wechat_send_injector" diff --git a/native/include/wechat_bridge.hpp b/native/include/wechat_bridge.hpp new file mode 100644 index 0000000..5b75594 --- /dev/null +++ b/native/include/wechat_bridge.hpp @@ -0,0 +1,135 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace wechat_bridge { + +inline constexpr std::uint32_t kProtocolVersion = 1; +inline constexpr std::size_t kMaximumFrameBytes = 1024 * 1024; + +struct SendRequest { + std::string request_id; + std::string group; + std::string username; + std::string text; +}; + +enum class AckState { + kAcknowledged, + kSubmitted, + kUnknown, +}; + +struct SendReceipt { + AckState ack_state = AckState::kUnknown; + std::optional local_id; + std::optional server_id; +}; + +using SendCompletion = std::function; + +// Implementations must enqueue work on their captured message queue and invoke +// completion asynchronously. The bridge itself never calls WeChat internals. +class SendAdapter { + public: + virtual ~SendAdapter() = default; + virtual void Send(const SendRequest& request, SendCompletion completion) = 0; +}; + +struct LocalMessageIdentity { + std::int64_t local_id = 0; + std::uint64_t client_id = 0; + std::uint32_t create_time = 0; +}; + +struct NetworkAcceptance { + bool accepted = false; + std::int64_t server_id = 0; +}; + +using LocalInsertCompletion = + std::function)>; +using NetworkCompletion = std::function; +using LocalUpdateCompletion = std::function; + +class LocalMessageGateway { + public: + virtual ~LocalMessageGateway() = default; + virtual void InsertOutgoing(const SendRequest& request, + std::uint64_t client_id, + std::uint32_t create_time, + LocalInsertCompletion completion) = 0; + virtual void MarkAccepted(const LocalMessageIdentity& identity, + std::int64_t server_id, + LocalUpdateCompletion completion) = 0; +}; + +class MarsGateway { + public: + virtual ~MarsGateway() = default; + virtual void Submit(const SendRequest& request, + const LocalMessageIdentity& identity, + NetworkCompletion completion) = 0; +}; + +std::shared_ptr CreateLocalVisibleAdapter( + std::shared_ptr local, + std::shared_ptr mars); + +struct OfficialTaskBindings { + std::uintptr_t image_base = 0; + std::uintptr_t builder_address = 0; + std::uintptr_t submit_address = 0; + void* builder_service = nullptr; + std::function)> main_dispatch; +}; + +// Exposed for the native fake host so the pinned ABI can be tested without +// loading WeChat. Production callers use CreateProductionWeChatAdapter. +std::shared_ptr CreateOfficialTaskAdapter( + OfficialTaskBindings bindings); + +struct BridgeConfig { + std::string safe_directory; + std::string instance_tag; + std::uint32_t adapter_timeout_ms = 15000; +}; + +class BridgeService { + public: + BridgeService(); + ~BridgeService(); + + BridgeService(const BridgeService&) = delete; + BridgeService& operator=(const BridgeService&) = delete; + + bool Start(const BridgeConfig& config, + std::shared_ptr adapter, + std::string* error); + void Stop(); + + std::string metadata_path() const; + std::string socket_path() const; + + private: + class Impl; + std::unique_ptr impl_; +}; + +// Creates the strictly pinned WeChat 4.1.8.28 ARM64 adapter. Any path, +// instruction signature, or build mismatch fails before sending is enabled. +std::shared_ptr CreateProductionWeChatAdapter( + std::string* unsupported_reason); + +// Shared by the pinned adapter and its native fake-host contract test. +bool VerifyUnusedHookIslandBytes(const void* bytes, std::size_t size); +int HookWriteProtection(); +int EmptyTextResponseStatus(); + +} // namespace wechat_bridge diff --git a/native/src/bridge_entry.mm b/native/src/bridge_entry.mm new file mode 100644 index 0000000..58884ed --- /dev/null +++ b/native/src/bridge_entry.mm @@ -0,0 +1,127 @@ +#include "wechat_bridge.hpp" + +#import + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace { + +constexpr char kExpectedExecutable[] = + "/Applications/WeChat.app/Contents/MacOS/WeChat"; +constexpr char kBridgeInstanceTag[] = "official-task-v1"; + +wechat_bridge::BridgeService* g_service = nullptr; + +bool ExactWeChatProcess() { + std::uint32_t size = 0; + if (_NSGetExecutablePath(nullptr, &size) != -1 || size == 0) { + return false; + } + std::string raw(size, '\0'); + if (_NSGetExecutablePath(raw.data(), &size) != 0) { + return false; + } + char resolved[PATH_MAX]; + return realpath(raw.c_str(), resolved) != nullptr && + std::strcmp(resolved, kExpectedExecutable) == 0 && + [[[NSBundle mainBundle] bundleIdentifier] + isEqualToString:@"com.tencent.xinWeChat"]; +} + +bool PrepareSafeDirectory(std::string* result) { + const passwd* user = getpwuid(geteuid()); + if (user == nullptr || user->pw_dir == nullptr || user->pw_dir[0] != '/' || + geteuid() != getuid()) { + return false; + } + const std::string directory = + std::string(user->pw_dir) + + "/Library/Containers/com.tencent.xinWeChat/Data/wcb"; + struct stat metadata {}; + if (lstat(directory.c_str(), &metadata) != 0) { + if (errno != ENOENT || mkdir(directory.c_str(), 0700) != 0) { + return false; + } + } + if (lstat(directory.c_str(), &metadata) != 0 || + !S_ISDIR(metadata.st_mode) || metadata.st_uid != geteuid() || + chmod(directory.c_str(), 0700) != 0) { + return false; + } + char resolved[PATH_MAX]; + if (realpath(directory.c_str(), resolved) == nullptr || + directory != resolved) { + return false; + } + *result = directory; + return true; +} + +void PublishStartupError(const std::string& directory, + const std::string& reason) { + if (reason.empty() || reason.size() > 128 || + reason.find_first_not_of("abcdefghijklmnopqrstuvwxyz0123456789_") != + std::string::npos) { + return; + } + const std::string path = directory + "/bridge-" + + kBridgeInstanceTag + "-" + std::to_string(getpid()) + ".error"; + const int descriptor = open( + path.c_str(), O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, + 0600); + if (descriptor < 0) { + return; + } + const std::string payload = reason + "\n"; + (void)write(descriptor, payload.data(), payload.size()); + (void)fchmod(descriptor, 0600); + close(descriptor); +} + +void StartBridge() { + @autoreleasepool { + if (!ExactWeChatProcess() || g_service != nullptr) { + return; + } + std::string safe_directory; + if (!PrepareSafeDirectory(&safe_directory)) { + return; + } + std::string adapter_error; + auto adapter = + wechat_bridge::CreateProductionWeChatAdapter(&adapter_error); + if (adapter == nullptr) { + PublishStartupError(safe_directory, adapter_error); + return; + } + auto service = std::make_unique(); + wechat_bridge::BridgeConfig config; + config.safe_directory = safe_directory; + config.instance_tag = kBridgeInstanceTag; + config.adapter_timeout_ms = 15000; + std::string service_error; + if (!service->Start(config, std::move(adapter), &service_error)) { + PublishStartupError(safe_directory, service_error); + return; + } + g_service = service.release(); + } +} + +__attribute__((constructor)) void WechatSendBridgeEntry() { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0), ^{ + StartBridge(); + }); +} + +} // namespace diff --git a/native/src/bridge_service.mm b/native/src/bridge_service.mm new file mode 100644 index 0000000..1fec724 --- /dev/null +++ b/native/src/bridge_service.mm @@ -0,0 +1,1139 @@ +#include "wechat_bridge.hpp" + +#import +#import + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wechat_bridge { +namespace { + +bool ValidInstanceTag(const std::string& value) { + if (value.size() > 32) { + return false; + } + return std::all_of(value.begin(), value.end(), [](unsigned char byte) { + return (byte >= 'a' && byte <= 'z') || + (byte >= '0' && byte <= '9') || byte == '-'; + }); +} + +constexpr std::size_t kTokenBytes = 32; +constexpr int kClientIoTimeoutSeconds = 5; +constexpr std::size_t kMaximumJsonDepth = 64; +constexpr std::size_t kMaximumGroupBytes = 16 * 1024; +constexpr std::size_t kMaximumUsernameBytes = 4096; + +bool WriteAll(int descriptor, const std::uint8_t* bytes, std::size_t size) { + std::size_t written = 0; + while (written < size) { + const ssize_t count = + write(descriptor, bytes + written, size - written); + if (count > 0) { + written += static_cast(count); + continue; + } + if (count < 0 && errno == EINTR) { + continue; + } + return false; + } + return true; +} + +bool ReadAll(int descriptor, std::uint8_t* bytes, std::size_t size) { + std::size_t received = 0; + while (received < size) { + const ssize_t count = + read(descriptor, bytes + received, size - received); + if (count > 0) { + received += static_cast(count); + continue; + } + if (count < 0 && errno == EINTR) { + continue; + } + return false; + } + return true; +} + +bool IsIntegerNumber(NSNumber* value) { + if (value == nil || CFGetTypeID((__bridge CFTypeRef)value) == + CFBooleanGetTypeID()) { + return false; + } + const char type = value.objCType[0]; + return std::strchr("cCsSiIlLqQ", type) != nullptr; +} + +std::string Utf8(NSString* value) { + NSData* data = [value dataUsingEncoding:NSUTF8StringEncoding]; + return std::string(static_cast(data.bytes), data.length); +} + +class JsonSyntaxScanner { + public: + JsonSyntaxScanner(const std::uint8_t* bytes, std::size_t size) + : bytes_(bytes), size_(size) {} + + bool Parse() { + SkipWhitespace(); + if (!ParseValue(0)) { + return false; + } + SkipWhitespace(); + return position_ == size_; + } + + private: + bool ParseValue(std::size_t depth) { + if (position_ >= size_ || depth > kMaximumJsonDepth) { + return false; + } + switch (bytes_[position_]) { + case '{': + return ParseObject(depth + 1); + case '[': + return ParseArray(depth + 1); + case '"': { + std::size_t ignored_start = 0; + std::size_t ignored_end = 0; + return ParseString(&ignored_start, &ignored_end); + } + case 't': + return ParseLiteral("true"); + case 'f': + return ParseLiteral("false"); + case 'n': + return ParseLiteral("null"); + default: + return ParseNumber(); + } + } + + bool ParseObject(std::size_t depth) { + ++position_; + SkipWhitespace(); + if (Consume('}')) { + return true; + } + std::set keys; + while (true) { + std::size_t start = 0; + std::size_t end = 0; + if (!ParseString(&start, &end)) { + return false; + } + std::string decoded_key; + if (!DecodeString(start, end, &decoded_key) || + !keys.insert(decoded_key).second) { + return false; + } + SkipWhitespace(); + if (!Consume(':')) { + return false; + } + SkipWhitespace(); + if (!ParseValue(depth)) { + return false; + } + SkipWhitespace(); + if (Consume('}')) { + return true; + } + if (!Consume(',')) { + return false; + } + SkipWhitespace(); + } + } + + bool ParseArray(std::size_t depth) { + ++position_; + SkipWhitespace(); + if (Consume(']')) { + return true; + } + while (true) { + if (!ParseValue(depth)) { + return false; + } + SkipWhitespace(); + if (Consume(']')) { + return true; + } + if (!Consume(',')) { + return false; + } + SkipWhitespace(); + } + } + + bool ParseString(std::size_t* start, std::size_t* end) { + if (position_ >= size_ || bytes_[position_] != '"') { + return false; + } + *start = position_++; + while (position_ < size_) { + const std::uint8_t byte = bytes_[position_++]; + if (byte == '"') { + *end = position_; + return true; + } + if (byte < 0x20) { + return false; + } + if (byte != '\\') { + continue; + } + if (position_ >= size_) { + return false; + } + const std::uint8_t escape = bytes_[position_++]; + if (std::strchr("\"\\/bfnrt", escape) != nullptr) { + continue; + } + if (escape != 'u' || position_ + 4 > size_) { + return false; + } + for (int index = 0; index < 4; ++index) { + const std::uint8_t digit = bytes_[position_++]; + if (!((digit >= '0' && digit <= '9') || + (digit >= 'a' && digit <= 'f') || + (digit >= 'A' && digit <= 'F'))) { + return false; + } + } + } + return false; + } + + bool DecodeString(std::size_t start, + std::size_t end, + std::string* decoded) { + NSMutableData* wrapped = [NSMutableData data]; + const std::uint8_t prefix = '['; + const std::uint8_t suffix = ']'; + [wrapped appendBytes:&prefix length:1]; + [wrapped appendBytes:bytes_ + start length:end - start]; + [wrapped appendBytes:&suffix length:1]; + NSError* error = nil; + id value = [NSJSONSerialization JSONObjectWithData:wrapped + options:0 + error:&error]; + if (error != nil || ![value isKindOfClass:[NSArray class]] || + [value count] != 1 || + ![[value objectAtIndex:0] isKindOfClass:[NSString class]]) { + return false; + } + *decoded = Utf8([value objectAtIndex:0]); + return true; + } + + bool ParseNumber() { + const std::size_t start = position_; + Consume('-'); + if (position_ >= size_) { + return false; + } + if (Consume('0')) { + if (position_ < size_ && bytes_[position_] >= '0' && + bytes_[position_] <= '9') { + return false; + } + } else { + if (bytes_[position_] < '1' || bytes_[position_] > '9') { + return false; + } + while (position_ < size_ && bytes_[position_] >= '0' && + bytes_[position_] <= '9') { + ++position_; + } + } + if (Consume('.')) { + if (!ConsumeDigits()) { + return false; + } + } + if (position_ < size_ && + (bytes_[position_] == 'e' || bytes_[position_] == 'E')) { + ++position_; + if (position_ < size_ && + (bytes_[position_] == '+' || bytes_[position_] == '-')) { + ++position_; + } + if (!ConsumeDigits()) { + return false; + } + } + return position_ > start; + } + + bool ConsumeDigits() { + const std::size_t start = position_; + while (position_ < size_ && bytes_[position_] >= '0' && + bytes_[position_] <= '9') { + ++position_; + } + return position_ > start; + } + + bool ParseLiteral(const char* literal) { + const std::size_t length = std::strlen(literal); + if (position_ + length > size_ || + std::memcmp(bytes_ + position_, literal, length) != 0) { + return false; + } + position_ += length; + return true; + } + + bool Consume(std::uint8_t expected) { + if (position_ >= size_ || bytes_[position_] != expected) { + return false; + } + ++position_; + return true; + } + + void SkipWhitespace() { + while (position_ < size_ && + (bytes_[position_] == ' ' || bytes_[position_] == '\t' || + bytes_[position_] == '\r' || bytes_[position_] == '\n')) { + ++position_; + } + } + + const std::uint8_t* bytes_; + std::size_t size_; + std::size_t position_ = 0; +}; + +bool ConstantTimeTokenMatch(NSString* supplied, + const std::array& token) { + NSData* data = [supplied dataUsingEncoding:NSASCIIStringEncoding + allowLossyConversion:NO]; + const std::size_t length = data == nil ? 0 : data.length; + const auto* bytes = static_cast(data.bytes); + std::size_t difference = length ^ (kTokenBytes * 2); + + // The published token is hexadecimal, so compare against its encoded form. + static constexpr char kHex[] = "0123456789abcdef"; + for (std::size_t index = 0; index < kTokenBytes * 2; ++index) { + const std::uint8_t expected = + static_cast(kHex[(index % 2 == 0) + ? token[index / 2] >> 4 + : token[index / 2] & 0x0f]); + const std::uint8_t actual = + index < length ? bytes[index] : static_cast(0); + difference |= expected ^ actual; + } + return difference == 0; +} + +std::string HexToken(const std::array& token) { + static constexpr char kHex[] = "0123456789abcdef"; + std::string encoded(kTokenBytes * 2, '0'); + for (std::size_t index = 0; index < token.size(); ++index) { + encoded[index * 2] = kHex[token[index] >> 4]; + encoded[index * 2 + 1] = kHex[token[index] & 0x0f]; + } + return encoded; +} + +std::string CompactSocketName(const std::string& instance_tag, + const std::string& pid) { + constexpr std::uint64_t kFnvOffset = 14695981039346656037ULL; + constexpr std::uint64_t kFnvPrime = 1099511628211ULL; + static constexpr char kHex[] = "0123456789abcdef"; + std::uint64_t hash = kFnvOffset; + for (const unsigned char byte : instance_tag) { + hash ^= byte; + hash *= kFnvPrime; + } + std::string encoded(16, '0'); + for (std::size_t index = 0; index < encoded.size(); ++index) { + encoded[encoded.size() - index - 1] = kHex[hash & 0x0f]; + hash >>= 4; + } + return "w" + encoded + "p" + pid; +} + +bool ExactRequestKeys(NSDictionary* object) { + static NSSet* expected = [NSSet + setWithArray:@[ + @"version", @"type", @"auth_token", @"request_id", @"group", + @"username", @"text" + ]]; + return object.count == expected.count && + [[NSSet setWithArray:object.allKeys] isEqualToSet:expected]; +} + +bool ContainsNul(NSString* value) { + for (NSUInteger index = 0; index < value.length; ++index) { + if ([value characterAtIndex:index] == 0) { + return true; + } + } + return false; +} + +bool ParseRequest(const std::uint8_t* bytes, + std::size_t size, + const std::array& token, + SendRequest* request) { + if (!JsonSyntaxScanner(bytes, size).Parse()) { + return false; + } + NSData* data = [NSData dataWithBytes:bytes length:size]; + if ([[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] == + nil) { + return false; + } + NSError* error = nil; + id value = [NSJSONSerialization JSONObjectWithData:data + options:0 + error:&error]; + if (error != nil || ![value isKindOfClass:[NSDictionary class]]) { + return false; + } + NSDictionary* object = value; + if (!ExactRequestKeys(object)) { + return false; + } + NSNumber* version = object[@"version"]; + NSString* type = object[@"type"]; + NSString* auth_token = object[@"auth_token"]; + NSString* request_id = object[@"request_id"]; + NSString* group = object[@"group"]; + NSString* username = object[@"username"]; + NSString* text = object[@"text"]; + if (!IsIntegerNumber(version) || version.longLongValue != kProtocolVersion || + ![type isKindOfClass:[NSString class]] || + ![type isEqualToString:@"send_text"] || + ![auth_token isKindOfClass:[NSString class]] || + ![request_id isKindOfClass:[NSString class]] || + ![group isKindOfClass:[NSString class]] || + ![username isKindOfClass:[NSString class]] || + ![text isKindOfClass:[NSString class]] || + !ConstantTimeTokenMatch(auth_token, token)) { + return false; + } + const std::size_t request_id_size = + [request_id lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + const std::size_t group_size = + [group lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + const std::size_t username_size = + [username lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + const std::size_t text_size = + [text lengthOfBytesUsingEncoding:NSUTF8StringEncoding]; + NSString* trimmed_text = [text + stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (request_id_size == 0 || request_id_size > 256 || + group_size == 0 || group_size > kMaximumGroupBytes || + username_size == 0 || username_size > kMaximumUsernameBytes || + text_size == 0 || text_size > kMaximumFrameBytes || + trimmed_text.length == 0 || + ![username hasSuffix:@"@chatroom"] || + ContainsNul(request_id) || ContainsNul(group) || + ContainsNul(username) || ContainsNul(text)) { + return false; + } + request->request_id = Utf8(request_id); + request->group = Utf8(group); + request->username = Utf8(username); + request->text = Utf8(text); + return true; +} + +NSData* EncodeReceipt(const SendRequest& request, SendReceipt receipt) { + if (receipt.local_id.has_value() && receipt.local_id.value() <= 0) { + receipt.local_id.reset(); + receipt.server_id.reset(); + receipt.ack_state = AckState::kUnknown; + } + if (receipt.server_id.has_value() && receipt.server_id.value() <= 0) { + receipt.server_id.reset(); + } + if (receipt.ack_state == AckState::kAcknowledged && + !receipt.local_id.has_value()) { + receipt.ack_state = AckState::kUnknown; + } + if (receipt.ack_state == AckState::kSubmitted && + (receipt.local_id.has_value() || receipt.server_id.has_value())) { + receipt.local_id.reset(); + receipt.server_id.reset(); + receipt.ack_state = AckState::kUnknown; + } + NSString* request_id = + [[NSString alloc] initWithBytes:request.request_id.data() + length:request.request_id.size() + encoding:NSUTF8StringEncoding]; + NSString* group = [[NSString alloc] initWithBytes:request.group.data() + length:request.group.size() + encoding:NSUTF8StringEncoding]; + NSString* username = + [[NSString alloc] initWithBytes:request.username.data() + length:request.username.size() + encoding:NSUTF8StringEncoding]; + if (request_id == nil || group == nil || username == nil) { + return nil; + } + NSDictionary* object = @{ + @"version" : @(kProtocolVersion), + @"type" : @"send_receipt", + @"ack_state" : receipt.ack_state == AckState::kAcknowledged + ? @"acknowledged" + : receipt.ack_state == AckState::kSubmitted + ? @"submitted" + : @"unknown", + @"request_id" : request_id, + @"group" : group, + @"username" : username, + @"local_id" : receipt.local_id.has_value() + ? @(receipt.local_id.value()) + : [NSNull null], + @"server_id" : receipt.server_id.has_value() + ? @(receipt.server_id.value()) + : [NSNull null], + }; + NSError* error = nil; + NSData* data = [NSJSONSerialization dataWithJSONObject:object + options:0 + error:&error]; + return error == nil ? data : nil; +} + +bool SameFile(const std::string& path, + dev_t device, + ino_t inode, + mode_t type) { + struct stat metadata {}; + return lstat(path.c_str(), &metadata) == 0 && + metadata.st_dev == device && metadata.st_ino == inode && + (metadata.st_mode & S_IFMT) == type; +} + +bool SameFileAt(int directory_fd, + const std::string& name, + dev_t device, + ino_t inode, + mode_t type) { + struct stat metadata {}; + return directory_fd >= 0 && + fstatat(directory_fd, name.c_str(), &metadata, + AT_SYMLINK_NOFOLLOW) == 0 && + metadata.st_dev == device && metadata.st_ino == inode && + (metadata.st_mode & S_IFMT) == type; +} + +} // namespace + +class BridgeService::Impl { + public: + ~Impl() { Stop(); } + + bool Start(const BridgeConfig& config, + std::shared_ptr adapter, + std::string* error) { + std::lock_guard lock(lifecycle_mutex_); + if (running_ || adapter == nullptr || config.safe_directory.empty() || + config.safe_directory.front() != '/' || + config.adapter_timeout_ms == 0) { + SetError(error, "invalid_or_already_running_bridge_configuration"); + return false; + } + + struct stat supplied_directory {}; + char resolved[PATH_MAX]; + char symlink_target[PATH_MAX]; + const ssize_t symlink_size = readlink( + config.safe_directory.c_str(), symlink_target, + sizeof(symlink_target)); + if (symlink_size >= 0 || + lstat(config.safe_directory.c_str(), &supplied_directory) != 0 || + !S_ISDIR(supplied_directory.st_mode) || + realpath(config.safe_directory.c_str(), resolved) == nullptr) { + SetError(error, "unsafe_bridge_directory"); + return false; + } + if (config.safe_directory != std::string(resolved)) { + SetError(error, "unsafe_bridge_directory"); + return false; + } + directory_ = resolved; + directory_fd_ = + open(directory_.c_str(), O_RDONLY | O_DIRECTORY | O_NOFOLLOW | + O_CLOEXEC); + struct stat directory_metadata {}; + if (directory_fd_ < 0 || + fstat(directory_fd_, &directory_metadata) != 0 || + !S_ISDIR(directory_metadata.st_mode) || + directory_metadata.st_uid != geteuid() || + (directory_metadata.st_mode & 0077) != 0) { + SetError(error, "unsafe_bridge_directory"); + CloseDirectory(); + return false; + } + directory_identity_.device = directory_metadata.st_dev; + directory_identity_.inode = directory_metadata.st_ino; + directory_identity_.type = S_IFDIR; + if (!SameDirectoryPath()) { + SetError(error, "unsafe_bridge_directory"); + ResetPathsAndDirectory(); + return false; + } + + if (!ValidInstanceTag(config.instance_tag)) { + SetError(error, "invalid_bridge_instance_tag"); + ResetPathsAndDirectory(); + return false; + } + const std::string pid = std::to_string(getpid()); + const std::string tag = config.instance_tag.empty() + ? std::string() + : "-" + config.instance_tag; + socket_name_ = "wechat-bridge" + tag + "-" + pid + ".sock"; + metadata_name_ = "bridge" + tag + "-" + pid + ".json"; + socket_path_ = directory_ + "/" + socket_name_; + if (socket_path_.size() >= sizeof(sockaddr_un::sun_path)) { + socket_name_ = CompactSocketName(config.instance_tag, pid); + socket_path_ = directory_ + "/" + socket_name_; + } + metadata_path_ = directory_ + "/" + metadata_name_; + if (socket_path_.size() >= sizeof(sockaddr_un::sun_path) || + RelativePathExists(socket_name_) || + RelativePathExists(metadata_name_)) { + SetError(error, "bridge_paths_unavailable"); + ResetPathsAndDirectory(); + return false; + } + + if (SecRandomCopyBytes(kSecRandomDefault, token_.size(), token_.data()) != + errSecSuccess) { + SetError(error, "secure_token_generation_failed"); + ResetPathsAndDirectory(); + return false; + } + + listen_fd_.store(socket(AF_UNIX, SOCK_STREAM, 0)); + if (listen_fd_.load() < 0) { + SetError(error, "bridge_socket_creation_failed"); + ResetPathsAndDirectory(); + return false; + } + fcntl(listen_fd_.load(), F_SETFD, FD_CLOEXEC); + sockaddr_un address {}; + address.sun_family = AF_UNIX; + std::memcpy(address.sun_path, socket_path_.c_str(), + socket_path_.size() + 1); + if (bind(listen_fd_.load(), reinterpret_cast(&address), + sizeof(address)) != 0) { + SetError(error, "bridge_socket_bind_failed"); + CloseListenSocket(); + ResetPathsAndDirectory(); + return false; + } + if (!SameDirectoryPath() || + !CaptureOwnedRelativePath(socket_name_, S_IFSOCK, + &socket_identity_)) { + SetError(error, "bridge_socket_bind_failed"); + CloseListenSocket(); + ResetPathsAndDirectory(); + return false; + } + owns_socket_ = true; + if (chmod(socket_path_.c_str(), 0600) != 0 || + listen(listen_fd_.load(), 8) != 0 || + !SameDirectoryPath() || + !SameFileAt(directory_fd_, socket_name_, socket_identity_.device, + socket_identity_.inode, socket_identity_.type)) { + SetError(error, "bridge_socket_bind_failed"); + CloseListenSocket(); + CleanupOwnedPaths(); + ResetPathsAndDirectory(); + return false; + } + + const std::string token = HexToken(token_); + NSString* socket_path_value = + [[NSString alloc] initWithUTF8String:socket_path_.c_str()]; + NSString* token_value = [NSString stringWithUTF8String:token.c_str()]; + if (socket_path_value == nil || token_value == nil) { + SetError(error, "bridge_metadata_publish_failed"); + CleanupOwnedPaths(); + CloseListenSocket(); + ResetPathsAndDirectory(); + return false; + } + NSDictionary* metadata = @{ + @"version" : @(kProtocolVersion), + @"pid" : @(getpid()), + @"socket_path" : socket_path_value, + @"token" : token_value, + }; + NSError* json_error = nil; + NSData* metadata_data = + [NSJSONSerialization dataWithJSONObject:metadata + options:0 + error:&json_error]; + if (json_error != nil || metadata_data == nil || + metadata_data.length == 0 || !SameDirectoryPath()) { + SetError(error, "bridge_metadata_publish_failed"); + CleanupOwnedPaths(); + CloseListenSocket(); + ResetPathsAndDirectory(); + return false; + } + const std::string metadata_temp_name = + "." + metadata_name_ + "." + token.substr(0, 16) + ".tmp"; + if (RelativePathExists(metadata_temp_name)) { + SetError(error, "bridge_metadata_publish_failed"); + CleanupOwnedPaths(); + CloseListenSocket(); + ResetPathsAndDirectory(); + return false; + } + const int metadata_fd = + openat(directory_fd_, metadata_temp_name.c_str(), + O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + if (metadata_fd < 0) { + SetError(error, "bridge_metadata_publish_failed"); + CleanupOwnedPaths(); + CloseListenSocket(); + ResetPathsAndDirectory(); + return false; + } + struct stat metadata_file {}; + if (fstat(metadata_fd, &metadata_file) != 0 || + !S_ISREG(metadata_file.st_mode) || + metadata_file.st_uid != geteuid()) { + close(metadata_fd); + SetError(error, "bridge_metadata_publish_failed"); + CleanupOwnedPaths(); + CloseListenSocket(); + ResetPathsAndDirectory(); + return false; + } + PathIdentity metadata_temp_identity; + metadata_temp_identity.device = metadata_file.st_dev; + metadata_temp_identity.inode = metadata_file.st_ino; + metadata_temp_identity.type = S_IFREG; + if (fchmod(metadata_fd, 0600) != 0 || + !WriteAll(metadata_fd, + static_cast(metadata_data.bytes), + metadata_data.length) || + fsync(metadata_fd) != 0) { + close(metadata_fd); + if (SameFileAt(directory_fd_, metadata_temp_name, + metadata_temp_identity.device, + metadata_temp_identity.inode, + metadata_temp_identity.type)) { + unlinkat(directory_fd_, metadata_temp_name.c_str(), 0); + } + SetError(error, "bridge_metadata_publish_failed"); + CleanupOwnedPaths(); + CloseListenSocket(); + ResetPathsAndDirectory(); + return false; + } + close(metadata_fd); + if (!SameDirectoryPath() || + !SameFileAt(directory_fd_, metadata_temp_name, + metadata_temp_identity.device, + metadata_temp_identity.inode, + metadata_temp_identity.type) || + renameatx_np(directory_fd_, metadata_temp_name.c_str(), + directory_fd_, metadata_name_.c_str(), + RENAME_EXCL) != 0) { + if (SameFileAt(directory_fd_, metadata_temp_name, + metadata_temp_identity.device, + metadata_temp_identity.inode, + metadata_temp_identity.type)) { + unlinkat(directory_fd_, metadata_temp_name.c_str(), 0); + } + SetError(error, "bridge_metadata_publish_failed"); + CleanupOwnedPaths(); + CloseListenSocket(); + ResetPathsAndDirectory(); + return false; + } + metadata_identity_ = metadata_temp_identity; + owns_metadata_ = true; + if (!SameDirectoryPath() || + !SameFileAt(directory_fd_, metadata_name_, metadata_identity_.device, + metadata_identity_.inode, metadata_identity_.type)) { + SetError(error, "bridge_metadata_publish_failed"); + CleanupOwnedPaths(); + CloseListenSocket(); + ResetPathsAndDirectory(); + return false; + } + adapter_ = std::move(adapter); + adapter_timeout_ms_ = config.adapter_timeout_ms; + stopping_.store(false); + running_ = true; + try { + server_thread_ = std::thread([this] { ServerLoop(); }); + } catch (...) { + running_ = false; + adapter_.reset(); + CloseListenSocket(); + CleanupOwnedPaths(); + ResetPathsAndDirectory(); + SetError(error, "bridge_server_thread_creation_failed"); + return false; + } + return true; + } + + void Stop() { + std::unique_lock lock(lifecycle_mutex_); + if (!running_ && !owns_socket_ && !owns_metadata_) { + return; + } + stopping_.store(true); + const int descriptor = listen_fd_.exchange(-1); + if (descriptor >= 0) { + shutdown(descriptor, SHUT_RDWR); + close(descriptor); + } + { + std::lock_guard client_lock(client_mutex_); + if (active_client_fd_ >= 0) { + shutdown(active_client_fd_, SHUT_RDWR); + } + if (const auto pending = active_pending_.lock()) { + std::lock_guard pending_lock(pending->mutex); + pending->cancelled = true; + pending->condition.notify_one(); + } + } + lock.unlock(); + if (server_thread_.joinable()) { + server_thread_.join(); + } + lock.lock(); + CleanupOwnedPaths(); + adapter_.reset(); + running_ = false; + ResetPathsAndDirectory(); + } + + std::string metadata_path() const { + std::lock_guard lock(lifecycle_mutex_); + return metadata_path_; + } + + std::string socket_path() const { + std::lock_guard lock(lifecycle_mutex_); + return socket_path_; + } + + private: + struct PathIdentity { + dev_t device = 0; + ino_t inode = 0; + mode_t type = 0; + }; + + struct PendingReceipt { + std::mutex mutex; + std::condition_variable condition; + bool completed = false; + bool cancelled = false; + SendReceipt receipt; + }; + + static void SetError(std::string* error, const char* value) { + if (error != nullptr) { + *error = value; + } + } + + bool SameDirectoryPath() const { + return SameFile(directory_, directory_identity_.device, + directory_identity_.inode, directory_identity_.type); + } + + bool RelativePathExists(const std::string& name) const { + struct stat metadata {}; + if (fstatat(directory_fd_, name.c_str(), &metadata, + AT_SYMLINK_NOFOLLOW) == 0) { + return true; + } + return errno != ENOENT; + } + + bool CaptureOwnedRelativePath(const std::string& name, + mode_t type, + PathIdentity* identity) const { + struct stat metadata {}; + if (fstatat(directory_fd_, name.c_str(), &metadata, + AT_SYMLINK_NOFOLLOW) != 0 || + (metadata.st_mode & S_IFMT) != type || + metadata.st_uid != geteuid()) { + return false; + } + identity->device = metadata.st_dev; + identity->inode = metadata.st_ino; + identity->type = type; + return true; + } + + void ServerLoop() { + while (!stopping_.load()) { + const int descriptor = listen_fd_.load(); + if (descriptor < 0) { + break; + } + const int client = accept(descriptor, nullptr, nullptr); + if (client < 0) { + if (errno == EINTR) { + continue; + } + if (stopping_.load() || errno == EBADF || errno == EINVAL) { + break; + } + continue; + } + fcntl(client, F_SETFD, FD_CLOEXEC); + int no_sigpipe = 1; + setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &no_sigpipe, + sizeof(no_sigpipe)); + timeval timeout {}; + timeout.tv_sec = kClientIoTimeoutSeconds; + setsockopt(client, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + setsockopt(client, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + { + std::lock_guard client_lock(client_mutex_); + active_client_fd_ = client; + } + uid_t peer_euid = static_cast(-1); + gid_t peer_egid = static_cast(-1); + if (!stopping_.load() && + getpeereid(client, &peer_euid, &peer_egid) == 0 && + peer_euid == geteuid()) { + HandleClient(client); + } + { + std::lock_guard client_lock(client_mutex_); + active_client_fd_ = -1; + } + close(client); + } + } + + void HandleClient(int client) { + std::uint32_t network_size = 0; + if (!ReadAll(client, reinterpret_cast(&network_size), + sizeof(network_size))) { + return; + } + const std::uint32_t size = ntohl(network_size); + if (size == 0 || size > kMaximumFrameBytes) { + return; + } + std::unique_ptr bytes(new std::uint8_t[size]); + if (!ReadAll(client, bytes.get(), size)) { + return; + } + SendRequest request; + if (!ParseRequest(bytes.get(), size, token_, &request)) { + return; + } + + auto pending = std::make_shared(); + const auto adapter = adapter_; + { + std::lock_guard client_lock(client_mutex_); + if (stopping_.load() || adapter == nullptr) { + return; + } + active_pending_ = pending; + try { + adapter->Send( + request, + [pending, adapter](SendReceipt receipt) { + (void)adapter; + std::lock_guard lock(pending->mutex); + if (pending->completed || pending->cancelled) { + return; + } + pending->receipt = std::move(receipt); + pending->completed = true; + pending->condition.notify_one(); + }); + } catch (...) { + active_pending_.reset(); + return; + } + } + + SendReceipt receipt; + bool adapter_timed_out = false; + bool adapter_cancelled = false; + { + std::unique_lock lock(pending->mutex); + if (!pending->condition.wait_for( + lock, std::chrono::milliseconds(adapter_timeout_ms_), + [this, &pending] { + return pending->completed || pending->cancelled || + stopping_.load(); + })) { + receipt.ack_state = AckState::kUnknown; + pending->cancelled = true; + adapter_timed_out = true; + } else if (pending->cancelled || stopping_.load()) { + pending->cancelled = true; + adapter_cancelled = true; + } else { + receipt = pending->receipt; + } + } + { + std::lock_guard client_lock(client_mutex_); + if (active_pending_.lock() == pending) { + active_pending_.reset(); + } + } + if (adapter_cancelled) { + return; + } + NSData* payload = EncodeReceipt(request, receipt); + if (payload == nil || payload.length == 0 || + payload.length > kMaximumFrameBytes) { + if (adapter_timed_out) { + DisableListening(); + } + return; + } + const std::uint32_t response_size = + htonl(static_cast(payload.length)); + if (!WriteAll(client, + reinterpret_cast(&response_size), + sizeof(response_size))) { + if (adapter_timed_out) { + DisableListening(); + } + return; + } + WriteAll(client, static_cast(payload.bytes), + payload.length); + if (adapter_timed_out) { + DisableListening(); + } + } + + void DisableListening() { + stopping_.store(true); + CloseListenSocket(); + std::lock_guard lock(lifecycle_mutex_); + CleanupOwnedPaths(); + } + + void CloseListenSocket() { + const int descriptor = listen_fd_.exchange(-1); + if (descriptor >= 0) { + close(descriptor); + } + } + + void CloseDirectory() { + if (directory_fd_ >= 0) { + close(directory_fd_); + directory_fd_ = -1; + } + } + + void CleanupOwnedPaths() { + if (owns_metadata_ && + SameFileAt(directory_fd_, metadata_name_, metadata_identity_.device, + metadata_identity_.inode, metadata_identity_.type)) { + unlinkat(directory_fd_, metadata_name_.c_str(), 0); + } + owns_metadata_ = false; + if (owns_socket_ && + SameFileAt(directory_fd_, socket_name_, socket_identity_.device, + socket_identity_.inode, socket_identity_.type)) { + unlinkat(directory_fd_, socket_name_.c_str(), 0); + } + owns_socket_ = false; + } + + void ResetPathsAndDirectory() { + CloseDirectory(); + directory_.clear(); + socket_name_.clear(); + metadata_name_.clear(); + socket_path_.clear(); + metadata_path_.clear(); + } + + mutable std::mutex lifecycle_mutex_; + std::mutex client_mutex_; + std::atomic stopping_{false}; + bool running_ = false; + bool owns_socket_ = false; + bool owns_metadata_ = false; + std::atomic listen_fd_{-1}; + int active_client_fd_ = -1; + std::weak_ptr active_pending_; + int directory_fd_ = -1; + std::uint32_t adapter_timeout_ms_ = 15000; + std::array token_{}; + std::string directory_; + std::string socket_name_; + std::string metadata_name_; + std::string socket_path_; + std::string metadata_path_; + PathIdentity socket_identity_; + PathIdentity metadata_identity_; + PathIdentity directory_identity_; + std::shared_ptr adapter_; + std::thread server_thread_; +}; + +BridgeService::BridgeService() : impl_(std::make_unique()) {} + +BridgeService::~BridgeService() = default; + +bool BridgeService::Start(const BridgeConfig& config, + std::shared_ptr adapter, + std::string* error) { + return impl_->Start(config, std::move(adapter), error); +} + +void BridgeService::Stop() { + impl_->Stop(); +} + +std::string BridgeService::metadata_path() const { + return impl_->metadata_path(); +} + +std::string BridgeService::socket_path() const { + return impl_->socket_path(); +} + +} // namespace wechat_bridge diff --git a/native/src/hook_trampolines.S b/native/src/hook_trampolines.S new file mode 100644 index 0000000..e08697f --- /dev/null +++ b/native/src/hook_trampolines.S @@ -0,0 +1,81 @@ +.text +.p2align 2 + +.globl _WechatBridgeReq2BufEnterHook +_WechatBridgeReq2BufEnterHook: + sub sp, sp, #0x120 + stp x0, x1, [sp, #0x00] + stp x2, x3, [sp, #0x10] + stp x4, x5, [sp, #0x20] + stp x6, x7, [sp, #0x30] + stp x8, x9, [sp, #0x40] + stp x10, x11, [sp, #0x50] + stp x12, x13, [sp, #0x60] + stp x14, x15, [sp, #0x70] + stp x16, x17, [sp, #0x80] + str x30, [sp, #0x90] + stp q0, q1, [sp, #0xa0] + stp q2, q3, [sp, #0xc0] + stp q4, q5, [sp, #0xe0] + stp q6, q7, [sp, #0x100] + mov x0, x1 + mov x1, x24 + bl _WechatBridgeHandleReq2BufEnter + ldp q6, q7, [sp, #0x100] + ldp q4, q5, [sp, #0xe0] + ldp q2, q3, [sp, #0xc0] + ldp q0, q1, [sp, #0xa0] + ldr x30, [sp, #0x90] + ldp x16, x17, [sp, #0x80] + ldp x14, x15, [sp, #0x70] + ldp x12, x13, [sp, #0x60] + ldp x10, x11, [sp, #0x50] + ldp x8, x9, [sp, #0x40] + ldp x6, x7, [sp, #0x30] + ldp x4, x5, [sp, #0x20] + ldp x2, x3, [sp, #0x10] + ldp x0, x1, [sp, #0x00] + add sp, sp, #0x120 + ldr x9, [x24, #0x60]! + adrp x17, _WechatBridgeReq2BufEnterContinue@PAGE + ldr x17, [x17, _WechatBridgeReq2BufEnterContinue@PAGEOFF] + br x17 + +.globl _WechatBridgeReq2BufExitHook +_WechatBridgeReq2BufExitHook: + sub sp, sp, #0x120 + stp x0, x1, [sp, #0x00] + stp x2, x3, [sp, #0x10] + stp x4, x5, [sp, #0x20] + stp x6, x7, [sp, #0x30] + stp x8, x9, [sp, #0x40] + stp x10, x11, [sp, #0x50] + stp x12, x13, [sp, #0x60] + stp x14, x15, [sp, #0x70] + stp x16, x17, [sp, #0x80] + str x30, [sp, #0x90] + stp q0, q1, [sp, #0xa0] + stp q2, q3, [sp, #0xc0] + stp q4, q5, [sp, #0xe0] + stp q6, q7, [sp, #0x100] + mov x0, x25 + bl _WechatBridgeHandleReq2BufExit + ldp q6, q7, [sp, #0x100] + ldp q4, q5, [sp, #0xe0] + ldp q2, q3, [sp, #0xc0] + ldp q0, q1, [sp, #0xa0] + ldr x30, [sp, #0x90] + ldp x16, x17, [sp, #0x80] + ldp x14, x15, [sp, #0x70] + ldp x12, x13, [sp, #0x60] + ldp x10, x11, [sp, #0x50] + ldp x8, x9, [sp, #0x40] + ldp x6, x7, [sp, #0x30] + ldp x4, x5, [sp, #0x20] + ldp x2, x3, [sp, #0x10] + ldp x0, x1, [sp, #0x00] + add sp, sp, #0x120 + ldp x28, x27, [sp], #0x60 + adrp x17, _WechatBridgeReq2BufExitContinue@PAGE + ldr x17, [x17, _WechatBridgeReq2BufExitContinue@PAGEOFF] + br x17 diff --git a/native/src/injector.cc b/native/src/injector.cc new file mode 100644 index 0000000..55c582f --- /dev/null +++ b/native/src/injector.cc @@ -0,0 +1,317 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr char kExpectedExecutable[] = + "/Applications/WeChat.app/Contents/MacOS/WeChat"; +constexpr mach_vm_size_t kRemoteStackBytes = 1024 * 1024; +constexpr std::size_t kDlopenAddressOffset = 24; +constexpr std::size_t kPthreadExitAddressOffset = 32; +constexpr std::size_t kBootstrapParkOffset = 40; +constexpr std::size_t kLoaderStubBytes = 44; + +struct RemoteMemory { + mach_vm_size_t page_size = 0; + mach_vm_address_t data = 0; + mach_vm_address_t code = 0; + mach_vm_address_t stack = 0; +}; + +void PrintError(const char* value) { + std::fprintf(stderr, "%s\n", value); +} + +bool ParsePid(const char* value, pid_t* pid) { + if (value == nullptr || *value == '\0') { + return false; + } + char* end = nullptr; + errno = 0; + const long parsed = std::strtol(value, &end, 10); + if (errno != 0 || end == value || *end != '\0' || parsed <= 1 || + parsed > INT32_MAX) { + return false; + } + *pid = static_cast(parsed); + return true; +} + +bool ExactRegularDylib(const char* supplied, std::string* resolved_path) { + if (supplied == nullptr || supplied[0] != '/') { + return false; + } + char resolved[PATH_MAX]; + if (realpath(supplied, resolved) == nullptr || + std::strcmp(supplied, resolved) != 0) { + return false; + } + struct stat metadata {}; + if (lstat(resolved, &metadata) != 0 || !S_ISREG(metadata.st_mode) || + metadata.st_nlink != 1 || + (metadata.st_uid != geteuid() && metadata.st_uid != 0) || + (metadata.st_mode & 0022) != 0 || metadata.st_size <= 0) { + return false; + } + const std::string path(resolved); + if (path.size() < 6 || path.compare(path.size() - 6, 6, ".dylib") != 0) { + return false; + } + *resolved_path = path; + return true; +} + +bool ExactTarget(pid_t pid) { + char path[PROC_PIDPATHINFO_MAXSIZE] = {}; + const int size = proc_pidpath(pid, path, sizeof(path)); + if (size <= 0 || std::strcmp(path, kExpectedExecutable) != 0) { + return false; + } + proc_bsdinfo info {}; + const int info_size = proc_pidinfo( + pid, PROC_PIDTBSDINFO, 0, &info, sizeof(info)); + return info_size == sizeof(info) && info.pbi_uid == geteuid(); +} + +bool WriteRemote(task_t task, + mach_vm_address_t destination, + const void* bytes, + mach_vm_size_t size) { + return mach_vm_write( + task, destination, + reinterpret_cast(bytes), + static_cast(size)) == KERN_SUCCESS; +} + +bool AllocateRemoteMemory(task_t task, RemoteMemory* memory) { + memory->page_size = static_cast(getpagesize()); + return memory->page_size >= 4096 && + mach_vm_allocate(task, &memory->data, memory->page_size * 2, + VM_FLAGS_ANYWHERE) == KERN_SUCCESS && + mach_vm_allocate(task, &memory->code, memory->page_size, + VM_FLAGS_ANYWHERE) == KERN_SUCCESS && + mach_vm_allocate(task, &memory->stack, kRemoteStackBytes, + VM_FLAGS_ANYWHERE) == KERN_SUCCESS; +} + +bool VerifyMemoryLayout() { + RemoteMemory memory; + if (!AllocateRemoteMemory(mach_task_self(), &memory)) { + return false; + } + const mach_vm_address_t pthread_storage = + memory.data + memory.page_size; + const bool separate_pages = + memory.code / memory.page_size != + pthread_storage / memory.page_size; + const bool protected_code = + mach_vm_protect(mach_task_self(), memory.code, memory.page_size, false, + VM_PROT_READ | VM_PROT_EXECUTE) == KERN_SUCCESS; + const std::uint64_t marker = 0x7763627061676573ULL; + const bool writable_storage = + WriteRemote(mach_task_self(), pthread_storage, &marker, + sizeof(marker)); + mach_vm_deallocate(mach_task_self(), memory.data, memory.page_size * 2); + mach_vm_deallocate(mach_task_self(), memory.code, memory.page_size); + mach_vm_deallocate(mach_task_self(), memory.stack, kRemoteStackBytes); + return separate_pages && protected_code && writable_storage; +} + +std::vector BuildLoaderStub( + std::uintptr_t dlopen_address, + std::uintptr_t pthread_exit_address) { + // dlopen(path, RTLD_NOW | RTLD_LOCAL), then terminate the created pthread. + // pthread_create_from_mach_thread does not provide a return address that a + // raw `ret` start routine can safely use on every supported macOS build. + std::vector stub(kLoaderStubBytes, 0); + const std::uint32_t instructions[] = { + 0xd28000c1u, // mov x1, #6 + 0x580000b0u, // ldr x16, dlopen address + 0xd63f0200u, // blr x16 + 0xd2800000u, // mov x0, #0 + 0x58000090u, // ldr x16, pthread_exit address + 0xd61f0200u, // br x16 + }; + const std::uint32_t park = 0x14000000u; + std::memcpy(stub.data(), instructions, sizeof(instructions)); + std::memcpy( + stub.data() + kDlopenAddressOffset, + &dlopen_address, + sizeof(dlopen_address)); + std::memcpy( + stub.data() + kPthreadExitAddressOffset, + &pthread_exit_address, + sizeof(pthread_exit_address)); + std::memcpy(stub.data() + kBootstrapParkOffset, &park, sizeof(park)); + return stub; +} + +bool VerifyLoaderStub() { + constexpr std::uintptr_t kDlopenMarker = 0x1122334455667788ULL; + constexpr std::uintptr_t kPthreadExitMarker = 0x8877665544332211ULL; + const std::vector stub = + BuildLoaderStub(kDlopenMarker, kPthreadExitMarker); + std::uintptr_t observed_dlopen = 0; + std::uintptr_t observed_pthread_exit = 0; + std::uint32_t exit_branch = 0; + std::uint32_t park = 0; + if (stub.size() != kLoaderStubBytes) { + return false; + } + std::memcpy( + &observed_dlopen, + stub.data() + kDlopenAddressOffset, + sizeof(observed_dlopen)); + std::memcpy( + &observed_pthread_exit, + stub.data() + kPthreadExitAddressOffset, + sizeof(observed_pthread_exit)); + std::memcpy(&exit_branch, stub.data() + 20, sizeof(exit_branch)); + std::memcpy(&park, stub.data() + kBootstrapParkOffset, sizeof(park)); + return observed_dlopen == kDlopenMarker && + observed_pthread_exit == kPthreadExitMarker && + exit_branch == 0xd61f0200u && park == 0x14000000u; +} + +int Inject(pid_t pid, const std::string& dylib_path) { + if (!ExactTarget(pid)) { + PrintError("target_wechat_identity_mismatch"); + return 3; + } + + mach_port_t task = MACH_PORT_NULL; + kern_return_t status = task_for_pid(mach_task_self(), pid, &task); + if (status != KERN_SUCCESS || task == MACH_PORT_NULL) { + PrintError("task_for_pid_denied_re_sign_wechat_with_debug_entitlements"); + return 3; + } + + void* pthread_entry = + dlsym(RTLD_DEFAULT, "pthread_create_from_mach_thread"); + void* dlopen_entry = dlsym(RTLD_DEFAULT, "dlopen"); + void* pthread_exit_entry = dlsym(RTLD_DEFAULT, "pthread_exit"); + if (pthread_entry == nullptr || dlopen_entry == nullptr || + pthread_exit_entry == nullptr) { + PrintError("required_system_loader_symbols_unavailable"); + mach_port_deallocate(mach_task_self(), task); + return 3; + } + + RemoteMemory memory; + if (!AllocateRemoteMemory(task, &memory)) { + PrintError("remote_memory_allocation_failed"); + mach_port_deallocate(mach_task_self(), task); + return 3; + } + + const mach_vm_address_t remote_path = memory.data; + const mach_vm_address_t remote_pthread = memory.data + memory.page_size; + const mach_vm_address_t remote_stub = memory.code; + const mach_vm_address_t remote_loop = + remote_stub + kBootstrapParkOffset; + if (!WriteRemote(task, remote_path, dylib_path.c_str(), + dylib_path.size() + 1)) { + PrintError("remote_path_write_failed"); + mach_port_deallocate(mach_task_self(), task); + return 3; + } + + const std::uintptr_t dlopen_address = + reinterpret_cast(dlopen_entry); + const std::uintptr_t pthread_exit_address = + reinterpret_cast(pthread_exit_entry); + const std::vector stub = + BuildLoaderStub(dlopen_address, pthread_exit_address); + if (!WriteRemote(task, remote_stub, stub.data(), stub.size()) || + mach_vm_protect(task, remote_stub, memory.page_size, false, + VM_PROT_READ | VM_PROT_EXECUTE) != KERN_SUCCESS) { + PrintError("remote_loader_stub_write_failed"); + mach_port_deallocate(mach_task_self(), task); + return 3; + } + + arm_thread_state64_t thread_state {}; + thread_state.__x[0] = remote_pthread; + thread_state.__x[1] = 0; + thread_state.__x[2] = remote_stub; + thread_state.__x[3] = remote_path; + arm_thread_state64_set_pc_fptr( + thread_state, reinterpret_cast(pthread_entry)); + arm_thread_state64_set_lr_fptr( + thread_state, reinterpret_cast(remote_loop)); + arm_thread_state64_set_sp( + thread_state, memory.stack + kRemoteStackBytes - 0x100); + + thread_act_t bootstrap_thread = MACH_PORT_NULL; + status = thread_create_running( + task, ARM_THREAD_STATE64, + reinterpret_cast(&thread_state), + ARM_THREAD_STATE64_COUNT, &bootstrap_thread); + if (status != KERN_SUCCESS) { + PrintError("remote_loader_thread_creation_failed"); + mach_port_deallocate(mach_task_self(), task); + return 3; + } + + // The bootstrap thread only creates a real pthread and then parks. Wait + // until it reaches that safe point before terminating the bootstrap. + bool parked = false; + for (int attempt = 0; attempt < 100; ++attempt) { + arm_thread_state64_t observed {}; + mach_msg_type_number_t observed_count = ARM_THREAD_STATE64_COUNT; + if (thread_get_state( + bootstrap_thread, ARM_THREAD_STATE64, + reinterpret_cast(&observed), + &observed_count) == KERN_SUCCESS && + arm_thread_state64_get_pc(observed) == remote_loop) { + parked = true; + break; + } + usleep(10000); + } + if (parked) { + thread_terminate(bootstrap_thread); + } + mach_port_deallocate(mach_task_self(), bootstrap_thread); + mach_port_deallocate(mach_task_self(), task); + return 0; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc == 2 && + std::strcmp(argv[1], "--verify-memory-layout") == 0) { + return VerifyMemoryLayout() ? 0 : 5; + } + if (argc == 2 && + std::strcmp(argv[1], "--verify-loader-stub") == 0) { + return VerifyLoaderStub() ? 0 : 5; + } + if (argc != 3) { + PrintError("usage: wechat_send_injector PID ABSOLUTE_DYLIB_PATH"); + return 2; + } + pid_t pid = 0; + std::string dylib_path; + if (!ParsePid(argv[1], &pid) || + !ExactRegularDylib(argv[2], &dylib_path)) { + PrintError("invalid_injector_arguments"); + return 2; + } + return Inject(pid, dylib_path); +} diff --git a/native/src/local_visible_adapter.mm b/native/src/local_visible_adapter.mm new file mode 100644 index 0000000..39190b2 --- /dev/null +++ b/native/src/local_visible_adapter.mm @@ -0,0 +1,185 @@ +#include "wechat_bridge.hpp" + +#include +#include +#include +#include +#include +#include + +namespace wechat_bridge { +namespace { + +enum class TransactionStage { + kWaitingForInsert, + kWaitingForNetwork, + kWaitingForUpdate, + kFinished, +}; + +struct TransactionState { + std::mutex mutex; + TransactionStage stage = TransactionStage::kWaitingForInsert; + SendCompletion completion; +}; + +std::atomic g_client_sequence{1}; + +bool EndsWithChatroom(const std::string& username) { + static constexpr char kSuffix[] = "@chatroom"; + constexpr std::size_t kSuffixSize = sizeof(kSuffix) - 1; + return username.size() >= kSuffixSize && + username.compare(username.size() - kSuffixSize, + kSuffixSize, kSuffix) == 0; +} + +std::uint64_t NextClientId(std::uint32_t create_time) { + const auto sequence = g_client_sequence.fetch_add(1); + return (static_cast(create_time) << 32) | + (sequence == 0 ? 1 : sequence); +} + +bool Advance(const std::shared_ptr& state, + TransactionStage expected, + TransactionStage next) { + std::lock_guard lock(state->mutex); + if (state->stage != expected) { + return false; + } + state->stage = next; + return true; +} + +void Finish(const std::shared_ptr& state, + SendReceipt receipt) { + SendCompletion completion; + { + std::lock_guard lock(state->mutex); + if (state->stage == TransactionStage::kFinished) { + return; + } + state->stage = TransactionStage::kFinished; + completion = std::move(state->completion); + } + if (completion) { + completion(std::move(receipt)); + } +} + +SendReceipt UnknownReceipt( + const std::optional& identity = std::nullopt, + std::optional server_id = std::nullopt) { + SendReceipt receipt; + receipt.ack_state = AckState::kUnknown; + if (identity.has_value() && identity->local_id > 0) { + receipt.local_id = identity->local_id; + } + if (server_id.has_value() && server_id.value() > 0) { + receipt.server_id = server_id; + } + return receipt; +} + +class LocalVisibleAdapter final : public SendAdapter { + public: + LocalVisibleAdapter(std::shared_ptr local, + std::shared_ptr mars) + : local_(std::move(local)), mars_(std::move(mars)) {} + + void Send(const SendRequest& request, SendCompletion completion) override { + if (local_ == nullptr || mars_ == nullptr || request.request_id.empty() || + request.text.empty() || !EndsWithChatroom(request.username)) { + if (completion) { + completion(UnknownReceipt()); + } + return; + } + + const auto now = std::time(nullptr); + if (now <= 0 || + static_cast(now) > UINT32_MAX) { + if (completion) { + completion(UnknownReceipt()); + } + return; + } + const auto create_time = static_cast(now); + const auto client_id = NextClientId(create_time); + auto state = std::make_shared(); + state->completion = std::move(completion); + auto local = local_; + auto mars = mars_; + + local->InsertOutgoing( + request, client_id, create_time, + [state, local, mars, request, client_id, create_time]( + std::optional identity) mutable { + if (!identity.has_value() || identity->local_id <= 0 || + identity->client_id != client_id || + identity->create_time != create_time) { + Finish(state, UnknownReceipt(identity)); + return; + } + if (!Advance(state, TransactionStage::kWaitingForInsert, + TransactionStage::kWaitingForNetwork)) { + return; + } + mars->Submit( + request, identity.value(), + [state, local, identity](NetworkAcceptance acceptance) mutable { + if (!acceptance.accepted || acceptance.server_id <= 0) { + Finish(state, UnknownReceipt(identity)); + return; + } + if (!Advance(state, TransactionStage::kWaitingForNetwork, + TransactionStage::kWaitingForUpdate)) { + return; + } + local->MarkAccepted( + identity.value(), acceptance.server_id, + [state, identity, acceptance](bool updated) mutable { + if (!updated) { + Finish(state, UnknownReceipt( + identity, acceptance.server_id)); + return; + } + if (!Advance(state, + TransactionStage::kWaitingForUpdate, + TransactionStage::kFinished)) { + return; + } + SendReceipt receipt; + receipt.ack_state = AckState::kAcknowledged; + receipt.local_id = identity->local_id; + receipt.server_id = acceptance.server_id; + SendCompletion completion; + { + std::lock_guard lock(state->mutex); + completion = std::move(state->completion); + } + if (completion) { + completion(std::move(receipt)); + } + }); + }); + }); + } + + private: + std::shared_ptr local_; + std::shared_ptr mars_; +}; + +} // namespace + +std::shared_ptr CreateLocalVisibleAdapter( + std::shared_ptr local, + std::shared_ptr mars) { + if (local == nullptr || mars == nullptr) { + return nullptr; + } + return std::make_shared( + std::move(local), std::move(mars)); +} + +} // namespace wechat_bridge diff --git a/native/src/official_task_adapter.mm b/native/src/official_task_adapter.mm new file mode 100644 index 0000000..8c5f45e --- /dev/null +++ b/native/src/official_task_adapter.mm @@ -0,0 +1,235 @@ +#include "wechat_bridge.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wechat_bridge { +namespace { + +constexpr std::uintptr_t kMessageVtableRva = 0x8a100f0; +constexpr std::size_t kMessageBytes = 0x540; +constexpr std::size_t kMaximumTextBytes = 512 * 1024; + +constexpr std::size_t kWeakSelfOffset = 0x08; +constexpr std::size_t kRecipientOffset = 0x90; +constexpr std::size_t kRequestIdOffset = 0x4c8; +constexpr std::size_t kTextOffset = 0x510; +constexpr std::size_t kTrailingStringOffset = 0x528; + +struct alignas(8) OpaqueTask { + std::array bytes{}; +}; + +struct alignas(8) OpaqueHandle { + std::array bytes{}; +}; + +struct alignas(16) EmptyCallback { + std::array bytes{}; +}; + +struct SourceLocation { + const char* file = nullptr; + const char* function = nullptr; + std::uint64_t line = 0; + const void* return_address = nullptr; +}; + +static_assert(sizeof(OpaqueTask) == 0x38); +static_assert(sizeof(OpaqueHandle) == 0x28); +static_assert(sizeof(EmptyCallback) == 0x20); +static_assert(sizeof(SourceLocation) == 0x20); + +extern "C" void WechatBridgeInvokeOfficialTaskBuilder( + std::uintptr_t function, + void* output, + void* service, + void* messages, + bool single_message); +extern "C" void WechatBridgeInvokeOfficialTaskSubmit( + std::uintptr_t function, + void* output, + const void* task, + const void* first_callback, + const void* second_callback, + const void* third_callback, + const void* source_location); + +struct alignas(16) MessageStorage { + MessageStorage() { + bytes.fill(0); + new (At>(kWeakSelfOffset)) std::weak_ptr(); + new (At(kRecipientOffset)) std::string(); + new (At(kRequestIdOffset)) std::string(); + new (At(kTextOffset)) std::string(); + new (At(kTrailingStringOffset)) std::string(); + } + + ~MessageStorage() { + At(kTrailingStringOffset)->~basic_string(); + At(kTextOffset)->~basic_string(); + At(kRequestIdOffset)->~basic_string(); + At(kRecipientOffset)->~basic_string(); + At>(kWeakSelfOffset)->~weak_ptr(); + } + + template + T* At(std::size_t offset) { + return reinterpret_cast(bytes.data() + offset); + } + + std::array bytes{}; +}; + +static_assert(sizeof(MessageStorage) == kMessageBytes); + +struct PendingOfficialSend { + std::mutex completion_mutex; + bool completed = false; + std::shared_ptr storage; + std::vector> messages; + OpaqueTask task; + OpaqueHandle handle; + EmptyCallback first_callback; + EmptyCallback second_callback; + EmptyCallback third_callback; + SourceLocation source_location; + SendCompletion completion; +}; + +std::mutex g_retained_mutex; +std::vector> g_retained_sends; + +bool EndsWithChatroom(const std::string& username) { + static constexpr char kSuffix[] = "@chatroom"; + constexpr std::size_t kSuffixSize = sizeof(kSuffix) - 1; + return username.size() >= kSuffixSize && + username.compare(username.size() - kSuffixSize, + kSuffixSize, kSuffix) == 0; +} + +template +void Store(MessageStorage* storage, std::size_t offset, T value) { + std::memcpy(storage->bytes.data() + offset, &value, sizeof(value)); +} + +std::shared_ptr BuildMessage( + std::uintptr_t image_base, + const SendRequest& request) { + auto storage = std::make_shared(); + Store(storage.get(), 0, image_base + kMessageVtableRva); + *storage->At(kRecipientOffset) = request.username; + *storage->At(kRequestIdOffset) = request.request_id; + *storage->At(kTextOffset) = request.text; + Store(storage.get(), 0x7c, 1); + Store(storage.get(), 0x84, 7); + Store(storage.get(), 0xb0, 1); + Store(storage.get(), 0x138, request.text.size()); + + std::shared_ptr alias(storage, storage.get()); + *storage->At>(kWeakSelfOffset) = alias; + return storage; +} + +void Retain(const std::shared_ptr& pending) { + std::lock_guard lock(g_retained_mutex); + g_retained_sends.push_back(pending); +} + +void Complete(const std::shared_ptr& pending, + AckState ack_state) { + SendCompletion completion; + { + std::lock_guard lock(pending->completion_mutex); + if (pending->completed) { + return; + } + pending->completed = true; + completion = std::move(pending->completion); + } + if (completion) { + SendReceipt receipt; + receipt.ack_state = ack_state; + completion(std::move(receipt)); + } +} + +const void* SourceReturnAddress() { + const void* address = __builtin_return_address(0); + return address == nullptr + ? reinterpret_cast(&SourceReturnAddress) + : address; +} + +class OfficialTaskAdapter final : public SendAdapter { + public: + explicit OfficialTaskAdapter(OfficialTaskBindings bindings) + : bindings_(std::move(bindings)) {} + + void Send(const SendRequest& request, SendCompletion completion) override { + if (request.request_id.empty() || request.request_id.size() > 256 || + request.text.empty() || request.text.size() > kMaximumTextBytes || + !EndsWithChatroom(request.username)) { + if (completion) { + SendReceipt receipt; + receipt.ack_state = AckState::kUnknown; + completion(std::move(receipt)); + } + return; + } + + auto pending = std::make_shared(); + pending->storage = BuildMessage(bindings_.image_base, request); + pending->messages.emplace_back(pending->storage, + pending->storage.get()); + pending->source_location.file = "official_task_adapter.mm"; + pending->source_location.function = "OfficialTaskAdapter::Send"; + pending->source_location.line = __LINE__; + pending->source_location.return_address = SourceReturnAddress(); + pending->completion = std::move(completion); + + const OfficialTaskBindings bindings = bindings_; + bindings.main_dispatch([pending, bindings] { + // Builder/submit may hand ownership to asynchronous WeChat work. Keep + // every attempted send alive for the process lifetime rather than guess + // opaque destructors or retry an uncertain submission. + Retain(pending); + try { + WechatBridgeInvokeOfficialTaskBuilder( + bindings.builder_address, &pending->task, + bindings.builder_service, &pending->messages, true); + WechatBridgeInvokeOfficialTaskSubmit( + bindings.submit_address, &pending->handle, &pending->task, + &pending->first_callback, &pending->second_callback, + &pending->third_callback, &pending->source_location); + Complete(pending, AckState::kSubmitted); + } catch (...) { + Complete(pending, AckState::kUnknown); + } + }); + } + + private: + OfficialTaskBindings bindings_; +}; + +} // namespace + +std::shared_ptr CreateOfficialTaskAdapter( + OfficialTaskBindings bindings) { + if (bindings.image_base == 0 || bindings.builder_address == 0 || + bindings.submit_address == 0 || !bindings.main_dispatch) { + return nullptr; + } + return std::make_shared(std::move(bindings)); +} + +} // namespace wechat_bridge diff --git a/native/src/official_task_trampolines.S b/native/src/official_task_trampolines.S new file mode 100644 index 0000000..0cd5c95 --- /dev/null +++ b/native/src/official_task_trampolines.S @@ -0,0 +1,22 @@ +.text +.p2align 2 + +.globl _WechatBridgeInvokeOfficialTaskBuilder +_WechatBridgeInvokeOfficialTaskBuilder: + mov x9, x0 + mov x8, x1 + mov x0, x2 + mov x1, x3 + mov w2, w4 + br x9 + +.globl _WechatBridgeInvokeOfficialTaskSubmit +_WechatBridgeInvokeOfficialTaskSubmit: + mov x9, x0 + mov x8, x1 + mov x0, x2 + mov x1, x3 + mov x2, x4 + mov x3, x5 + mov x4, x6 + br x9 diff --git a/native/src/production_adapter.mm b/native/src/production_adapter.mm new file mode 100644 index 0000000..4b5a994 --- /dev/null +++ b/native/src/production_adapter.mm @@ -0,0 +1,151 @@ +#include "wechat_bridge.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace wechat_bridge { +namespace { + +constexpr char kExpectedImagePath[] = + "/Applications/WeChat.app/Contents/Frameworks/wechat.dylib"; +constexpr std::uintptr_t kBuilderRva = 0x33729fc; +constexpr std::uintptr_t kSubmitRva = 0x695e08; +constexpr std::uintptr_t kMessageVtableRva = 0x8a100f0; +constexpr std::uintptr_t kMessageDestructorRva = 0x2ce5520; +constexpr std::uintptr_t kMessageDeletingDestructorRva = 0x4513d18; + +constexpr std::array kBuilderSignature = { + 0x29, 0xdb, 0x02, 0x90, 0x29, 0x55, 0x41, 0xf9, + 0x49, 0x00, 0x00, 0xb4, 0x20, 0x01, 0x1f, 0xd6, +}; +constexpr std::array kSubmitSignature = { + 0xff, 0x03, 0x03, 0xd1, 0xf8, 0x5f, 0x08, 0xa9, + 0xf6, 0x57, 0x09, 0xa9, 0xf4, 0x4f, 0x0a, 0xa9, +}; +constexpr std::array kExpectedUuid = { + 0xab, 0xd8, 0x8e, 0xc8, 0x45, 0x90, 0x3f, 0xdb, + 0xaa, 0xb5, 0x4e, 0x58, 0x65, 0xb8, 0x6b, 0x2a, +}; + +void SetError(std::string* error, const char* value) { + if (error != nullptr) { + *error = value; + } +} + +std::uintptr_t FindPinnedImage() { + for (std::uint32_t index = 0; index < _dyld_image_count(); ++index) { + const char* name = _dyld_get_image_name(index); + if (name != nullptr && std::strcmp(name, kExpectedImagePath) == 0) { + return reinterpret_cast( + _dyld_get_image_header(index)); + } + } + return 0; +} + +bool UuidMatches(std::uintptr_t image_base) { + const auto* header = reinterpret_cast(image_base); + if (header == nullptr || header->magic != MH_MAGIC_64 || + header->cputype != CPU_TYPE_ARM64 || + header->sizeofcmds > 16 * 1024 * 1024) { + return false; + } + const auto* command_bytes = reinterpret_cast( + header + 1); + std::size_t offset = 0; + for (std::uint32_t index = 0; index < header->ncmds; ++index) { + if (offset + sizeof(load_command) > header->sizeofcmds) { + return false; + } + const auto* command = reinterpret_cast( + command_bytes + offset); + if (command->cmdsize < sizeof(load_command) || + command->cmdsize > header->sizeofcmds - offset) { + return false; + } + if (command->cmd == LC_UUID) { + if (command->cmdsize < sizeof(uuid_command)) { + return false; + } + const auto* uuid = reinterpret_cast(command); + return std::memcmp(uuid->uuid, kExpectedUuid.data(), + kExpectedUuid.size()) == 0; + } + offset += command->cmdsize; + } + return false; +} + +template +bool SignatureMatches(std::uintptr_t address, + const std::array& signature) { + return address != 0 && + std::memcmp(reinterpret_cast(address), + signature.data(), signature.size()) == 0; +} + +bool MessageVtableMatches(std::uintptr_t image_base) { + std::array entries{}; + std::memcpy(entries.data(), + reinterpret_cast( + image_base + kMessageVtableRva), + sizeof(entries)); + return entries[0] == image_base + kMessageDestructorRva && + entries[1] == image_base + kMessageDeletingDestructorRva; +} + +} // namespace + +std::shared_ptr CreateProductionWeChatAdapter( + std::string* unsupported_reason) { + const std::uintptr_t image_base = FindPinnedImage(); + if (image_base == 0) { + SetError(unsupported_reason, "unsupported_wechat_build_image"); + return nullptr; + } + if (!UuidMatches(image_base)) { + SetError(unsupported_reason, "unsupported_wechat_build_uuid"); + return nullptr; + } + if (!SignatureMatches(image_base + kBuilderRva, kBuilderSignature)) { + SetError(unsupported_reason, "unsupported_wechat_build_builder"); + return nullptr; + } + if (!SignatureMatches(image_base + kSubmitRva, kSubmitSignature)) { + SetError(unsupported_reason, "unsupported_wechat_build_submit"); + return nullptr; + } + if (!MessageVtableMatches(image_base)) { + SetError(unsupported_reason, "unsupported_wechat_build_message_vtable"); + return nullptr; + } + + OfficialTaskBindings bindings; + bindings.image_base = image_base; + bindings.builder_address = image_base + kBuilderRva; + bindings.submit_address = image_base + kSubmitRva; + bindings.main_dispatch = [](std::function work) { + dispatch_async(dispatch_get_main_queue(), ^{ + work(); + }); + }; + auto adapter = CreateOfficialTaskAdapter(std::move(bindings)); + if (adapter == nullptr) { + SetError(unsupported_reason, "unsupported_wechat_official_task_adapter"); + } else if (unsupported_reason != nullptr) { + unsupported_reason->clear(); + } + return adapter; +} + +} // namespace wechat_bridge diff --git a/native/src/wechat_adapter.mm b/native/src/wechat_adapter.mm new file mode 100644 index 0000000..68c2e24 --- /dev/null +++ b/native/src/wechat_adapter.mm @@ -0,0 +1,540 @@ +#include "wechat_bridge.hpp" + +#import + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wechat_bridge { +namespace { + +// Strict profile for WeChat 4.1.8.28 (36571), arm64. +constexpr std::uintptr_t kReq2BufEnterRva = 0x3806b30; +constexpr std::uintptr_t kReq2BufExitRva = 0x3807c44; +constexpr std::uintptr_t kReq2BufEnterIslandRva = 0x4e12cc8; +constexpr std::uintptr_t kReq2BufExitIslandRva = 0x4e12cd8; +constexpr std::uintptr_t kSerializeBytesRva = 0x382d820; +constexpr std::uintptr_t kContextLookupRva = 0x490f494; +constexpr std::uintptr_t kNetworkCoreGetterRva = 0x4964574; +constexpr std::uintptr_t kStartTaskRva = 0x498d2e0; +constexpr std::uintptr_t kOriginalTaskVtableRva = 0x8915f28; + +constexpr std::uint32_t kReq2BufEnterSignature = 0xf8460f09; +constexpr std::uint32_t kReq2BufExitSignature = 0xa8c66ffc; +constexpr std::uint32_t kStartTaskSignature = 0xd10503ff; +constexpr std::uint32_t kContextLookupSignature = 0xd10643ff; +constexpr std::uint32_t kNetworkCoreGetterSignature = 0xd10103ff; +constexpr std::uint32_t kFirstTaskId = 0x20010000; +constexpr std::size_t kTriggerBytes = 1024; +constexpr std::size_t kTaskObjectBytes = 0x120; +constexpr std::size_t kWrapperBytes = 0x30; +constexpr std::size_t kMaximumTextBytes = 512 * 1024; + +extern "C" void WechatBridgeReq2BufEnterHook(); +extern "C" void WechatBridgeReq2BufExitHook(); +extern "C" std::uintptr_t WechatBridgeReq2BufEnterContinue; +extern "C" std::uintptr_t WechatBridgeReq2BufExitContinue; + +using SerializeBytesFn = void (*)(void*, const void*, std::uint32_t); +using ContextLookupFn = void* (*)(const std::string*); +using NetworkCoreGetterFn = void* (*)(void*); +using StartTaskFn = std::int64_t (*)(void*, void*); + +struct PendingSend { + std::uint32_t task_id = 0; + std::array trigger{}; + std::array task_object{}; + std::array wrapper{}; + std::array vtable{}; + std::array cgi{}; + std::vector protobuf; + void** inserted_slot = nullptr; + SendCompletion completion; +}; + +std::mutex g_pending_mutex; +std::shared_ptr g_pending; +// Mars may retain the task object for Resp2Buf/cleanup after Req2Buf returns. +// The bridge is process-scoped, so keeping completed task storage until WeChat +// exits is safer than guessing an asynchronous lifetime and risking UAF. +std::vector> g_retired_tasks; +std::uintptr_t g_wechat_base = 0; +SerializeBytesFn g_serialize_bytes = nullptr; +std::atomic g_next_task_id{kFirstTaskId}; + +bool EndsWith(const char* value, const char* suffix) { + if (value == nullptr || suffix == nullptr) { + return false; + } + const std::size_t value_size = std::strlen(value); + const std::size_t suffix_size = std::strlen(suffix); + return value_size >= suffix_size && + std::memcmp(value + value_size - suffix_size, suffix, suffix_size) == 0; +} + +std::uintptr_t FindPinnedWeChatImage() { + static constexpr char kExpectedPath[] = + "/Applications/WeChat.app/Contents/Frameworks/wechat.dylib"; + for (std::uint32_t index = 0; index < _dyld_image_count(); ++index) { + const char* name = _dyld_get_image_name(index); + if (name != nullptr && std::strcmp(name, kExpectedPath) == 0) { + return reinterpret_cast(_dyld_get_image_header(index)); + } + } + return 0; +} + +bool WordMatches(std::uintptr_t address, std::uint32_t expected) { + std::uint32_t actual = 0; + std::memcpy(&actual, reinterpret_cast(address), sizeof(actual)); + return actual == expected; +} + +std::uint32_t BranchInstruction(std::uintptr_t source, + std::uintptr_t destination) { + const std::int64_t delta = static_cast(destination) - + static_cast(source); + if ((delta & 3) != 0 || delta < -(1LL << 27) || delta >= (1LL << 27)) { + return 0; + } + return 0x14000000u | + (static_cast(delta >> 2) & 0x03ffffffu); +} + +void WriteAbsoluteJump(std::uintptr_t island, std::uintptr_t destination) { + // ldr x17, #8; br x17; .quad destination + const std::array instructions = { + 0x58000051u, + 0xd61f0220u, + }; + std::memcpy(reinterpret_cast(island), instructions.data(), + sizeof(instructions)); + std::memcpy(reinterpret_cast(island + sizeof(instructions)), + &destination, sizeof(destination)); +} + +bool PatchHookBranches(std::uintptr_t enter, + std::uintptr_t enter_island, + std::uintptr_t exit, + std::uintptr_t exit_island) { + const std::uint32_t enter_branch = BranchInstruction(enter, enter_island); + const std::uint32_t exit_branch = BranchInstruction(exit, exit_island); + if (enter_branch == 0 || exit_branch == 0) { + return false; + } + const mach_vm_size_t page_size = static_cast(getpagesize()); + const mach_vm_address_t first_page = enter & ~(page_size - 1); + const mach_vm_address_t last_page = exit & ~(page_size - 1); + const mach_vm_size_t span = last_page - first_page + page_size; + if (mach_vm_protect(mach_task_self(), first_page, span, false, + HookWriteProtection()) != KERN_SUCCESS) { + return false; + } + std::memcpy(reinterpret_cast(enter), &enter_branch, + sizeof(enter_branch)); + std::memcpy(reinterpret_cast(exit), &exit_branch, + sizeof(exit_branch)); + sys_icache_invalidate(reinterpret_cast(enter), sizeof(enter_branch)); + sys_icache_invalidate(reinterpret_cast(exit), sizeof(exit_branch)); + return mach_vm_protect(mach_task_self(), first_page, span, false, + VM_PROT_READ | VM_PROT_EXECUTE) == KERN_SUCCESS; +} + +bool InstallReq2BufHooks(std::string* error) { + const std::uintptr_t enter = g_wechat_base + kReq2BufEnterRva; + const std::uintptr_t exit = g_wechat_base + kReq2BufExitRva; + const std::uintptr_t enter_island = + g_wechat_base + kReq2BufEnterIslandRva; + const std::uintptr_t exit_island = + g_wechat_base + kReq2BufExitIslandRva; + if (!VerifyUnusedHookIslandBytes( + reinterpret_cast(enter_island), 32)) { + if (error != nullptr) { + *error = "wechat_hook_island_signature_failed"; + } + return false; + } + if (BranchInstruction(enter, enter_island) == 0 || + BranchInstruction(exit, exit_island) == 0) { + if (error != nullptr) { + *error = "wechat_hook_island_out_of_range"; + } + return false; + } + const mach_vm_size_t page_size = static_cast(getpagesize()); + const mach_vm_address_t island_page = enter_island & ~(page_size - 1); + if (mach_vm_protect(mach_task_self(), island_page, page_size, false, + HookWriteProtection()) != KERN_SUCCESS) { + if (error != nullptr) { + *error = "wechat_hook_island_protection_failed"; + } + return false; + } + WriteAbsoluteJump( + enter_island, + reinterpret_cast(&WechatBridgeReq2BufEnterHook)); + WriteAbsoluteJump( + exit_island, + reinterpret_cast(&WechatBridgeReq2BufExitHook)); + sys_icache_invalidate(reinterpret_cast(island_page), 32); + if (mach_vm_protect(mach_task_self(), island_page, + page_size, false, + VM_PROT_READ | VM_PROT_EXECUTE) != KERN_SUCCESS) { + if (error != nullptr) { + *error = "wechat_hook_island_restore_failed"; + } + return false; + } + WechatBridgeReq2BufEnterContinue = enter + 4; + WechatBridgeReq2BufExitContinue = exit + 4; + if (!PatchHookBranches(enter, enter_island, exit, exit_island)) { + if (error != nullptr) { + *error = "wechat_hook_target_patch_failed"; + } + return false; + } + return true; +} + +void AppendVarint(std::vector* output, std::uint64_t value) { + while (value >= 0x80) { + output->push_back(static_cast(value) | 0x80); + value >>= 7; + } + output->push_back(static_cast(value)); +} + +void AppendBytesField(std::vector* output, + std::uint8_t tag, + const std::uint8_t* bytes, + std::size_t size) { + output->push_back(tag); + AppendVarint(output, size); + output->insert(output->end(), bytes, bytes + size); +} + +std::vector BuildTextRequest(const SendRequest& request, + std::uint32_t task_id) { + const auto* username = reinterpret_cast( + request.username.data()); + const auto* text = reinterpret_cast(request.text.data()); + + std::vector builtin_username; + AppendBytesField(&builtin_username, 0x0a, username, request.username.size()); + + std::vector message; + AppendBytesField(&message, 0x0a, builtin_username.data(), + builtin_username.size()); + AppendBytesField(&message, 0x12, text, request.text.size()); + message.push_back(0x18); + message.push_back(0x01); + message.push_back(0x20); + AppendVarint(&message, static_cast( + [[NSDate date] timeIntervalSince1970])); + message.push_back(0x28); + AppendVarint(&message, + (static_cast(task_id) << 1) | + static_cast(arc4random_uniform(2))); + static constexpr char kMessageSource[] = + "1\0"; + AppendBytesField(&message, 0x32, + reinterpret_cast(kMessageSource), + sizeof(kMessageSource) - 1); + + std::vector request_bytes; + request_bytes.push_back(0x08); + request_bytes.push_back(0x01); + AppendBytesField(&request_bytes, 0x12, message.data(), message.size()); + return request_bytes; +} + +void NoopDestructor(void*) {} + +bool SerializeTextTask(void* self, void* output, void*, std::uint8_t* error) { + if (error != nullptr) { + *error = 0; + } + std::shared_ptr pending; + { + std::lock_guard lock(g_pending_mutex); + if (g_pending != nullptr && g_pending->task_object.data() == self) { + pending = g_pending; + } + } + if (pending == nullptr || g_serialize_bytes == nullptr || + pending->protobuf.empty() || pending->protobuf.size() > UINT32_MAX) { + return false; + } + g_serialize_bytes(output, pending->protobuf.data(), + static_cast(pending->protobuf.size())); + return true; +} + +int IgnoreTextResponse(void*, void*) { + return EmptyTextResponseStatus(); +} + +void* RequestStorage(void* self) { + return static_cast(self) + 0xb8; +} + +void* ResponseStorage(void* self) { + return static_cast(self) + 0xe8; +} + +void StorePointer(void* bytes, std::size_t offset, const void* value) { + std::memcpy(static_cast(bytes) + offset, &value, + sizeof(value)); +} + +template +void StoreValue(void* bytes, std::size_t offset, T value) { + std::memcpy(static_cast(bytes) + offset, &value, + sizeof(value)); +} + +// Opaque MMStartTask request layout for the pinned build. These bytes describe +// task scheduling metadata only; recipient and text are serialized separately. +constexpr std::array kTriggerTemplate = { + 0x0a, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x40, 0xec, 0x0e, 0x12, 0x01, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, 0x00, 0x01, 0x01, 0x01, + 0x00, 0xaa, 0xaa, 0xaa, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0xaa, 0xaa, 0xaa, 0xff, 0xff, 0xff, 0xff, + 0xaa, 0xaa, 0xaa, 0xaa, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x02, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x2d, 0x6c, 0x6f, 0x6e, 0x67, + 0x6c, 0x69, 0x6e, 0x6b, 0x00, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0xaa, 0x10, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xaa, 0xaa, 0xaa, 0xaa, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, + 0xaa, 0xaa, 0xaa, 0xaa, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xaa, 0xaa, + 0xaa, 0xaa, 0xaa, 0xaa, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, +}; + +void PrepareOpaqueObjects(const std::shared_ptr& pending) { + std::memcpy(pending->trigger.data() + 4, kTriggerTemplate.data(), + kTriggerTemplate.size()); + StoreValue(pending->trigger.data(), 0, pending->task_id); + static constexpr char kCgi[] = "/cgi-bin/micromsg-bin/newsendmsg"; + std::memcpy(pending->cgi.data(), kCgi, sizeof(kCgi)); + StorePointer(pending->trigger.data(), 0x18, pending->cgi.data()); + StorePointer(pending->trigger.data(), 0xb8, + pending->trigger.data() + 0xc0); + StorePointer(pending->trigger.data(), 0x190, + pending->trigger.data() + 0x198); + + pending->vtable[0] = 0; + std::memcpy( + &pending->vtable[1], + reinterpret_cast( + g_wechat_base + kOriginalTaskVtableRva - sizeof(std::uintptr_t)), + sizeof(std::uintptr_t)); + pending->vtable[2] = reinterpret_cast(&NoopDestructor); + pending->vtable[3] = reinterpret_cast(&NoopDestructor); + pending->vtable[4] = reinterpret_cast(&SerializeTextTask); + pending->vtable[5] = reinterpret_cast(&IgnoreTextResponse); + pending->vtable[6] = reinterpret_cast(&RequestStorage); + pending->vtable[7] = reinterpret_cast(&ResponseStorage); + const void* vtable_pointer = pending->vtable.data() + 2; + StorePointer(pending->task_object.data(), 0, vtable_pointer); + StoreValue(pending->task_object.data(), 0x08, pending->task_id); + StoreValue(pending->task_object.data(), 0x0c, 0x20a); + StoreValue(pending->task_object.data(), 0x10, 3); + StorePointer(pending->task_object.data(), 0x18, pending->cgi.data()); + StoreValue(pending->task_object.data(), 0x20, 0x20); + + StoreValue(pending->wrapper.data(), 0x18, 1); + StoreValue(pending->wrapper.data(), 0x20, pending->task_id); + StorePointer(pending->wrapper.data(), 0x28, pending->task_object.data()); +} + +void CompleteUnknown(const std::shared_ptr& pending) { + if (pending == nullptr || !pending->completion) { + return; + } + SendReceipt receipt; + receipt.ack_state = AckState::kUnknown; + auto completion = std::move(pending->completion); + completion(std::move(receipt)); +} + +class MarsTextSendAdapter final : public SendAdapter { + public: + void Send(const SendRequest& request, SendCompletion completion) override { + if (request.username.empty() || request.text.empty() || + request.text.size() > kMaximumTextBytes || + !EndsWith(request.username.c_str(), "@chatroom")) { + SendReceipt receipt; + receipt.ack_state = AckState::kUnknown; + completion(std::move(receipt)); + return; + } + + auto pending = std::make_shared(); + pending->task_id = g_next_task_id.fetch_add(1); + if (pending->task_id < kFirstTaskId) { + pending->task_id = kFirstTaskId; + g_next_task_id.store(kFirstTaskId + 1); + } + pending->completion = std::move(completion); + pending->protobuf = BuildTextRequest(request, pending->task_id); + PrepareOpaqueObjects(pending); + + bool already_sending = false; + { + std::lock_guard lock(g_pending_mutex); + if (g_pending != nullptr) { + already_sending = true; + } else { + g_pending = pending; + } + } + if (already_sending) { + CompleteUnknown(pending); + return; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + const auto context_lookup = reinterpret_cast( + g_wechat_base + kContextLookupRva); + const auto getter = reinterpret_cast( + g_wechat_base + kNetworkCoreGetterRva); + const auto start = reinterpret_cast( + g_wechat_base + kStartTaskRva); + const std::string context_name("default"); + void* context = context_lookup(&context_name); + void* core = context == nullptr ? nullptr : getter(context); + if (core == nullptr || start(core, pending->trigger.data()) == 0) { + std::shared_ptr failed; + { + std::lock_guard lock(g_pending_mutex); + if (g_pending == pending) { + failed = std::move(g_pending); + } + } + CompleteUnknown(failed); + } + }); + } +}; + +} // namespace + +bool VerifyUnusedHookIslandBytes(const void* bytes, std::size_t size) { + if (bytes == nullptr || size != 32) { + return false; + } + const auto* value = static_cast(bytes); + return std::all_of(value, value + size, + [](std::uint8_t byte) { return byte == 0; }); +} + +int HookWriteProtection() { + return VM_PROT_READ | VM_PROT_WRITE | VM_PROT_COPY; +} + +int EmptyTextResponseStatus() { + // Mars Buf2Resp returns zero for a successfully decoded response. + return 0; +} + +extern "C" { +std::uintptr_t WechatBridgeReq2BufEnterContinue = 0; +std::uintptr_t WechatBridgeReq2BufExitContinue = 0; +} + +extern "C" void WechatBridgeHandleReq2BufEnter(std::uint64_t task_id, + void* x24) { + std::lock_guard lock(g_pending_mutex); + if (g_pending == nullptr || + static_cast(task_id) != g_pending->task_id || + x24 == nullptr) { + return; + } + auto** slot = reinterpret_cast( + static_cast(x24) + 0x60); + *slot = g_pending->wrapper.data(); + g_pending->inserted_slot = slot; +} + +extern "C" void WechatBridgeHandleReq2BufExit(std::uint64_t task_id) { + std::shared_ptr completed; + { + std::lock_guard lock(g_pending_mutex); + if (g_pending == nullptr || + static_cast(task_id) != g_pending->task_id) { + return; + } + if (g_pending->inserted_slot != nullptr && + *g_pending->inserted_slot == g_pending->wrapper.data()) { + *g_pending->inserted_slot = nullptr; + } + completed = std::move(g_pending); + g_retired_tasks.push_back(completed); + } + CompleteUnknown(completed); +} + +std::shared_ptr CreateLegacyMarsWeChatAdapter( + std::string* unsupported_reason) { + g_wechat_base = FindPinnedWeChatImage(); + if (g_wechat_base == 0 || + !WordMatches(g_wechat_base + kReq2BufEnterRva, + kReq2BufEnterSignature) || + !WordMatches(g_wechat_base + kReq2BufExitRva, + kReq2BufExitSignature) || + !WordMatches(g_wechat_base + kStartTaskRva, kStartTaskSignature) || + !WordMatches(g_wechat_base + kContextLookupRva, + kContextLookupSignature) || + !WordMatches(g_wechat_base + kNetworkCoreGetterRva, + kNetworkCoreGetterSignature)) { + if (unsupported_reason != nullptr) { + *unsupported_reason = "unsupported_wechat_build"; + } + return nullptr; + } + g_serialize_bytes = reinterpret_cast( + g_wechat_base + kSerializeBytesRva); + std::string hook_error; + if (!InstallReq2BufHooks(&hook_error)) { + if (unsupported_reason != nullptr) { + *unsupported_reason = hook_error.empty() + ? "wechat_hook_install_failed" + : hook_error; + } + return nullptr; + } + return std::make_shared(); +} + +} // namespace wechat_bridge diff --git a/native/tests/fake_host.mm b/native/tests/fake_host.mm new file mode 100644 index 0000000..6adaada --- /dev/null +++ b/native/tests/fake_host.mm @@ -0,0 +1,611 @@ +#include "wechat_bridge.hpp" + +#import + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +volatile sig_atomic_t g_stop_requested = 0; + +void HandleStop(int) { + g_stop_requested = 1; +} + +class FakeQueuedAdapter final : public wechat_bridge::SendAdapter { + public: + FakeQueuedAdapter(dispatch_queue_t message_queue, + wechat_bridge::AckState ack_state, + unsigned int delay_ms) + : message_queue_(message_queue), + ack_state_(ack_state), + delay_ms_(delay_ms) {} + + void Send(const wechat_bridge::SendRequest&, + wechat_bridge::SendCompletion completion) override { + dispatch_async(message_queue_, ^{ + const int active = active_.fetch_add(1) + 1; + calls_.fetch_add(1); + int observed = max_active_.load(); + while (active > observed && + !max_active_.compare_exchange_weak(observed, active)) { + } + if (delay_ms_ > 0) { + usleep(delay_ms_ * 1000); + } + wechat_bridge::SendReceipt receipt; + receipt.ack_state = ack_state_; + if (ack_state_ == wechat_bridge::AckState::kAcknowledged) { + receipt.local_id = 41; + receipt.server_id = 99; + } + active_.fetch_sub(1); + completion(std::move(receipt)); + }); + } + + int calls() const { return calls_.load(); } + int max_active() const { return max_active_.load(); } + + private: + dispatch_queue_t message_queue_; + wechat_bridge::AckState ack_state_; + unsigned int delay_ms_; + std::atomic calls_{0}; + std::atomic active_{0}; + std::atomic max_active_{0}; +}; + +enum class TransactionScenario { + kInsertFailed, + kNetworkUnknown, + kAccepted, +}; + +struct TransactionStats { + int insert_calls = 0; + int network_calls = 0; + int update_calls = 0; + std::uint64_t insert_client_id = 0; + std::uint64_t network_client_id = 0; + std::int64_t insert_local_id = 0; + std::int64_t update_local_id = 0; +}; + +class FakeLocalMessageGateway final + : public wechat_bridge::LocalMessageGateway { + public: + FakeLocalMessageGateway(TransactionScenario scenario, + TransactionStats* stats) + : scenario_(scenario), stats_(stats) {} + + void InsertOutgoing(const wechat_bridge::SendRequest&, + std::uint64_t client_id, + std::uint32_t create_time, + wechat_bridge::LocalInsertCompletion completion) + override { + ++stats_->insert_calls; + stats_->insert_client_id = client_id; + if (scenario_ == TransactionScenario::kInsertFailed) { + completion(std::nullopt); + return; + } + wechat_bridge::LocalMessageIdentity identity; + identity.local_id = 41; + identity.client_id = client_id; + identity.create_time = create_time; + stats_->insert_local_id = identity.local_id; + completion(identity); + } + + void MarkAccepted(const wechat_bridge::LocalMessageIdentity& identity, + std::int64_t, + wechat_bridge::LocalUpdateCompletion completion) + override { + ++stats_->update_calls; + stats_->update_local_id = identity.local_id; + completion(true); + } + + private: + TransactionScenario scenario_; + TransactionStats* stats_; +}; + +class FakeMarsGateway final : public wechat_bridge::MarsGateway { + public: + FakeMarsGateway(TransactionScenario scenario, TransactionStats* stats) + : scenario_(scenario), stats_(stats) {} + + void Submit(const wechat_bridge::SendRequest&, + const wechat_bridge::LocalMessageIdentity& identity, + wechat_bridge::NetworkCompletion completion) override { + ++stats_->network_calls; + stats_->network_client_id = identity.client_id; + wechat_bridge::NetworkAcceptance acceptance; + if (scenario_ == TransactionScenario::kAccepted) { + acceptance.accepted = true; + acceptance.server_id = 99; + } + completion(acceptance); + } + + private: + TransactionScenario scenario_; + TransactionStats* stats_; +}; + +constexpr std::uintptr_t kFakeWechatBase = 0x100000000ULL; +constexpr std::uintptr_t kMessageVtableRva = 0x8a100f0; +constexpr std::uint64_t kTaskMarker = 0x4f4646494349414cULL; +constexpr std::uint64_t kHandleMarker = 0x5355424d49545445ULL; + +struct alignas(8) FakeOfficialTask { + std::array bytes{}; +}; + +struct alignas(8) FakeOfficialHandle { + std::array bytes{}; +}; + +struct alignas(16) FakeEmptyCallback { + std::array bytes{}; +}; + +struct FakeSourceLocation { + const char* file = nullptr; + const char* function = nullptr; + std::uint64_t line = 0; + const void* return_address = nullptr; +}; + +static_assert(sizeof(FakeOfficialTask) == 0x38); +static_assert(sizeof(FakeOfficialHandle) == 0x28); +static_assert(sizeof(FakeEmptyCallback) == 0x20); +static_assert(sizeof(FakeSourceLocation) == 0x20); + +enum class OfficialTaskScenario { + kSubmitted, + kBuilderThrows, + kSubmitThrows, + kInvalidRequest, +}; + +struct OfficialTaskStats { + OfficialTaskScenario scenario = OfficialTaskScenario::kSubmitted; + int dispatch_calls = 0; + int builder_calls = 0; + int submit_calls = 0; + int completion_calls = 0; + bool in_dispatch = false; + bool builder_on_dispatch = false; + bool submit_on_dispatch = false; + bool single_message = false; + bool service_matches = false; + bool model_matches = false; + bool task_matches = false; + bool callbacks_empty = false; + bool source_location_matches = false; + std::weak_ptr model_owner; + std::vector calls; + std::optional receipt; +}; + +OfficialTaskStats* g_official_task_stats = nullptr; + +template +T LoadModelValue(const void* model, std::size_t offset) { + T value{}; + std::memcpy(&value, + static_cast(model) + offset, + sizeof(value)); + return value; +} + +bool AllZero(const void* bytes, std::size_t size) { + const auto* first = static_cast(bytes); + return std::all_of(first, first + size, + [](std::uint8_t value) { return value == 0; }); +} + +FakeOfficialTask FakeOfficialBuilder( + void* service, + std::vector>* messages, + bool single_message) { + OfficialTaskStats& stats = *g_official_task_stats; + ++stats.builder_calls; + stats.calls.emplace_back("builder"); + stats.builder_on_dispatch = stats.in_dispatch; + stats.single_message = single_message; + stats.service_matches = service == nullptr; + if (messages != nullptr && messages->size() == 1 && + messages->front() != nullptr) { + const auto& alias = messages->front(); + const void* model = alias.get(); + const auto* weak_self = reinterpret_cast*>( + static_cast(model) + 0x08); + const auto owned_self = weak_self->lock(); + const auto* recipient = reinterpret_cast( + static_cast(model) + 0x90); + const auto* request_id = reinterpret_cast( + static_cast(model) + 0x4c8); + const auto* text = reinterpret_cast( + static_cast(model) + 0x510); + const auto* trailing = reinterpret_cast( + static_cast(model) + 0x528); + stats.model_matches = + LoadModelValue(model, 0) == + kFakeWechatBase + kMessageVtableRva && + owned_self.get() == model && alias.get() == model && + *recipient == "59034084590@chatroom" && + *request_id == "12345678-1234-1234-1234-123456789abc" && + *text == "测试" && trailing->empty() && + LoadModelValue(model, 0x7c) == 1 && + LoadModelValue(model, 0x84) == 7 && + LoadModelValue(model, 0xb0) == 1 && + LoadModelValue(model, 0x138) == text->size(); + stats.model_owner = alias; + } + if (stats.scenario == OfficialTaskScenario::kBuilderThrows) { + throw std::runtime_error("fake builder failure"); + } + FakeOfficialTask task; + std::memcpy(task.bytes.data(), &kTaskMarker, sizeof(kTaskMarker)); + return task; +} + +FakeOfficialHandle FakeOfficialSubmit( + const FakeOfficialTask& task, + const FakeEmptyCallback& first_callback, + const FakeEmptyCallback& second_callback, + const FakeEmptyCallback& third_callback, + const FakeSourceLocation& location) { + OfficialTaskStats& stats = *g_official_task_stats; + ++stats.submit_calls; + stats.calls.emplace_back("submit"); + stats.submit_on_dispatch = stats.in_dispatch; + stats.task_matches = + LoadModelValue(&task, 0) == kTaskMarker; + stats.callbacks_empty = + AllZero(&first_callback, sizeof(first_callback)) && + AllZero(&second_callback, sizeof(second_callback)) && + AllZero(&third_callback, sizeof(third_callback)); + stats.source_location_matches = + location.file != nullptr && location.file[0] != '\0' && + location.function != nullptr && location.function[0] != '\0' && + location.line != 0 && location.return_address != nullptr; + if (stats.scenario == OfficialTaskScenario::kSubmitThrows) { + throw std::runtime_error("fake submit failure"); + } + FakeOfficialHandle handle; + std::memcpy(handle.bytes.data(), &kHandleMarker, sizeof(kHandleMarker)); + return handle; +} + +int VerifyOfficialTaskAdapter(const char* raw_scenario) { + OfficialTaskStats stats; + if (std::strcmp(raw_scenario, "submitted") == 0) { + stats.scenario = OfficialTaskScenario::kSubmitted; + } else if (std::strcmp(raw_scenario, "builder-throws") == 0) { + stats.scenario = OfficialTaskScenario::kBuilderThrows; + } else if (std::strcmp(raw_scenario, "submit-throws") == 0) { + stats.scenario = OfficialTaskScenario::kSubmitThrows; + } else if (std::strcmp(raw_scenario, "invalid-request") == 0) { + stats.scenario = OfficialTaskScenario::kInvalidRequest; + } else { + return 2; + } + + g_official_task_stats = &stats; + wechat_bridge::OfficialTaskBindings bindings; + bindings.image_base = kFakeWechatBase; + bindings.builder_address = + reinterpret_cast(&FakeOfficialBuilder); + bindings.submit_address = + reinterpret_cast(&FakeOfficialSubmit); + bindings.main_dispatch = [&stats](std::function work) { + ++stats.dispatch_calls; + stats.in_dispatch = true; + work(); + stats.in_dispatch = false; + }; + auto adapter = wechat_bridge::CreateOfficialTaskAdapter(bindings); + if (adapter == nullptr) { + return 20; + } + + wechat_bridge::SendRequest request; + request.request_id = "12345678-1234-1234-1234-123456789abc"; + request.group = "AI聊天群"; + request.username = "59034084590@chatroom"; + request.text = "测试"; + if (stats.scenario == OfficialTaskScenario::kInvalidRequest) { + request.username = "not-a-chatroom"; + } + adapter->Send(request, [&stats](wechat_bridge::SendReceipt receipt) { + ++stats.completion_calls; + stats.receipt = std::move(receipt); + }); + g_official_task_stats = nullptr; + + if (stats.scenario == OfficialTaskScenario::kInvalidRequest) { + return stats.dispatch_calls == 0 && stats.builder_calls == 0 && + stats.submit_calls == 0 && stats.completion_calls == 1 && + stats.receipt.has_value() && + stats.receipt->ack_state == wechat_bridge::AckState::kUnknown + ? 0 + : 21; + } + if (stats.dispatch_calls != 1 || stats.builder_calls != 1 || + stats.completion_calls != 1 || !stats.builder_on_dispatch || + !stats.single_message || !stats.service_matches || + !stats.model_matches || stats.model_owner.expired()) { + return 22; + } + if (stats.scenario == OfficialTaskScenario::kBuilderThrows) { + return stats.submit_calls == 0 && stats.calls == std::vector{ + "builder"} && stats.receipt.has_value() && + stats.receipt->ack_state == wechat_bridge::AckState::kUnknown + ? 0 + : 23; + } + if (stats.submit_calls != 1 || !stats.submit_on_dispatch || + stats.calls != std::vector{"builder", "submit"} || + !stats.task_matches || !stats.callbacks_empty || + !stats.source_location_matches || !stats.receipt.has_value()) { + return 24; + } + if (stats.scenario == OfficialTaskScenario::kSubmitThrows) { + return stats.receipt->ack_state == wechat_bridge::AckState::kUnknown + ? 0 + : 25; + } + return stats.receipt->ack_state == wechat_bridge::AckState::kSubmitted && + !stats.receipt->local_id.has_value() && + !stats.receipt->server_id.has_value() + ? 0 + : 26; +} + +void PrintOptionalId(const std::optional& value) { + if (value.has_value()) { + std::printf("%lld", static_cast(value.value())); + } else { + std::fputs("null", stdout); + } +} + +int VerifyLocalVisibleTransaction(const char* raw_scenario) { + TransactionScenario scenario; + if (std::strcmp(raw_scenario, "insert-failed") == 0) { + scenario = TransactionScenario::kInsertFailed; + } else if (std::strcmp(raw_scenario, "network-unknown") == 0) { + scenario = TransactionScenario::kNetworkUnknown; + } else if (std::strcmp(raw_scenario, "accepted") == 0) { + scenario = TransactionScenario::kAccepted; + } else { + return 2; + } + + TransactionStats stats; + auto local = std::make_shared(scenario, &stats); + auto mars = std::make_shared(scenario, &stats); + auto adapter = wechat_bridge::CreateLocalVisibleAdapter(local, mars); + if (adapter == nullptr) { + return 10; + } + wechat_bridge::SendRequest request; + request.request_id = "12345678-1234-1234-1234-123456789abc"; + request.group = "AI聊天群"; + request.username = "59034084590@chatroom"; + request.text = "测试"; + std::optional receipt; + adapter->Send(request, [&](wechat_bridge::SendReceipt value) { + receipt = std::move(value); + }); + if (!receipt.has_value()) { + return 11; + } + + std::printf( + "{\"insert_calls\":%d,\"network_calls\":%d," + "\"update_calls\":%d,\"insert_client_id\":%llu," + "\"network_client_id\":%llu,\"insert_local_id\":%lld," + "\"update_local_id\":%lld,\"receipt\":{\"ack_state\":\"%s\"," + "\"local_id\":", + stats.insert_calls, stats.network_calls, stats.update_calls, + static_cast(stats.insert_client_id), + static_cast(stats.network_client_id), + static_cast(stats.insert_local_id), + static_cast(stats.update_local_id), + receipt->ack_state == wechat_bridge::AckState::kAcknowledged + ? "acknowledged" + : "unknown"); + PrintOptionalId(receipt->local_id); + std::fputs(",\"server_id\":", stdout); + PrintOptionalId(receipt->server_id); + std::fputs("}}\n", stdout); + return 0; +} + +bool ParseUnsigned(const char* raw, unsigned int* value) { + if (raw == nullptr || *raw == '\0') { + return false; + } + char* end = nullptr; + errno = 0; + const unsigned long parsed = std::strtoul(raw, &end, 10); + if (errno != 0 || *end != '\0' || parsed > 60000) { + return false; + } + *value = static_cast(parsed); + return true; +} + +} // namespace + +int main(int argc, char** argv) { + @autoreleasepool { + if (argc == 2 && + std::strcmp(argv[1], "--verify-official-task-adapter") == 0) { + const int result = VerifyOfficialTaskAdapter("submitted"); + if (result == 0) { + std::fputs( + "{\"dispatch_calls\":1,\"builder_calls\":1," + "\"submit_calls\":1,\"builder_before_submit\":true," + "\"builder_service_is_null\":true,\"builder_mode\":1," + "\"model_fields_valid\":true," + "\"vector_aliases_model\":true," + "\"vector_shares_ownership\":true," + "\"callbacks_empty\":true," + "\"source_location_valid\":true," + "\"receipt\":{\"ack_state\":\"submitted\"," + "\"local_id\":null,\"server_id\":null}}\n", + stdout); + } + return result; + } + if (argc == 3 && + std::strcmp(argv[1], "--verify-official-task-adapter") == 0) { + return VerifyOfficialTaskAdapter(argv[2]); + } + if (argc == 3 && + std::strcmp(argv[1], "--verify-local-visible-transaction") == 0) { + return VerifyLocalVisibleTransaction(argv[2]); + } + if (argc == 2 && + std::strcmp(argv[1], "--verify-empty-response-status") == 0) { + return wechat_bridge::EmptyTextResponseStatus() == 0 ? 0 : 8; + } + if (argc == 2 && + std::strcmp(argv[1], "--verify-hook-write-protection") == 0) { + const int protection = wechat_bridge::HookWriteProtection(); + return (protection & VM_PROT_WRITE) != 0 && + (protection & VM_PROT_EXECUTE) == 0 + ? 0 + : 7; + } + if (argc == 2 && + std::strcmp(argv[1], "--verify-hook-island-signature") == 0) { + std::uint8_t unused[32] = {}; + std::uint8_t occupied[32] = {}; + occupied[17] = 1; + return wechat_bridge::VerifyUnusedHookIslandBytes( + unused, sizeof(unused)) && + !wechat_bridge::VerifyUnusedHookIslandBytes( + occupied, sizeof(occupied)) && + !wechat_bridge::VerifyUnusedHookIslandBytes( + unused, sizeof(unused) - 1) + ? 0 + : 6; + } + if (argc == 2 && + (std::strcmp(argv[1], "--verify-production-profile-incomplete") == 0 || + std::strcmp(argv[1], "--verify-production-image-rejected") == 0)) { + std::string reason; + const auto adapter = + wechat_bridge::CreateProductionWeChatAdapter(&reason); + return adapter == nullptr && + reason == "unsupported_wechat_build_image" + ? 0 + : 14; + } + std::string safe_directory; + wechat_bridge::AckState ack_state = + wechat_bridge::AckState::kAcknowledged; + unsigned int delay_ms = 0; + unsigned int adapter_timeout_ms = 3000; + std::string instance_tag; + bool start_once = false; + for (int index = 1; index < argc; ++index) { + if (std::strcmp(argv[index], "--safe-directory") == 0 && + index + 1 < argc) { + safe_directory = argv[++index]; + } else if (std::strcmp(argv[index], "--ack-state") == 0 && + index + 1 < argc) { + const char* value = argv[++index]; + if (std::strcmp(value, "acknowledged") == 0) { + ack_state = wechat_bridge::AckState::kAcknowledged; + } else if (std::strcmp(value, "submitted") == 0) { + ack_state = wechat_bridge::AckState::kSubmitted; + } else if (std::strcmp(value, "unknown") == 0) { + ack_state = wechat_bridge::AckState::kUnknown; + } else { + return 2; + } + } else if (std::strcmp(argv[index], "--delay-ms") == 0 && + index + 1 < argc) { + if (!ParseUnsigned(argv[++index], &delay_ms)) { + return 2; + } + } else if (std::strcmp(argv[index], "--adapter-timeout-ms") == 0 && + index + 1 < argc) { + if (!ParseUnsigned(argv[++index], &adapter_timeout_ms) || + adapter_timeout_ms == 0) { + return 2; + } + } else if (std::strcmp(argv[index], "--instance-tag") == 0 && + index + 1 < argc) { + instance_tag = argv[++index]; + } else if (std::strcmp(argv[index], "--start-once") == 0) { + start_once = true; + } else { + return 2; + } + } + if (safe_directory.empty()) { + return 2; + } + + struct sigaction action {}; + action.sa_handler = HandleStop; + sigemptyset(&action.sa_mask); + if (sigaction(SIGTERM, &action, nullptr) != 0 || + sigaction(SIGINT, &action, nullptr) != 0) { + return 3; + } + + dispatch_queue_t message_queue = + dispatch_queue_create("wechat.bridge.fake-message-queue", + DISPATCH_QUEUE_SERIAL); + auto adapter = std::make_shared( + message_queue, ack_state, delay_ms); + wechat_bridge::BridgeService service; + wechat_bridge::BridgeConfig config; + config.safe_directory = safe_directory; + config.instance_tag = instance_tag; + config.adapter_timeout_ms = adapter_timeout_ms; + std::string error; + if (!service.Start(config, adapter, &error)) { + std::fputs("native fake host could not start\n", stderr); + return 4; + } + if (start_once) { + service.Stop(); + return 0; + } + + while (!g_stop_requested) { + usleep(10000); + } + service.Stop(); + std::printf("STATS %d %d\n", adapter->calls(), adapter->max_active()); + std::fflush(stdout); + return 0; + } +} diff --git a/npm/platforms/darwin-arm64/package.json b/npm/platforms/darwin-arm64/package.json index b5884ed..6e6d04b 100644 --- a/npm/platforms/darwin-arm64/package.json +++ b/npm/platforms/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@canghe_ai/wechat-cli-darwin-arm64", - "version": "0.2.4", + "version": "0.3.0", "description": "wechat-cli binary for macOS arm64", "os": ["darwin"], "cpu": ["arm64"], diff --git a/npm/wechat-cli/package.json b/npm/wechat-cli/package.json index 9743320..5d1de99 100644 --- a/npm/wechat-cli/package.json +++ b/npm/wechat-cli/package.json @@ -1,7 +1,7 @@ { "name": "@canghe_ai/wechat-cli", - "version": "0.2.4", - "description": "WeChat data query CLI — chat history, contacts, sessions, favorites, and more. Designed for LLM integration.", + "version": "0.3.0", + "description": "WeChat local data CLI with experimental background group text sending on macOS ARM64 WeChat 4.1.8.", "bin": { "wechat-cli": "bin/wechat-cli.js" }, @@ -13,7 +13,7 @@ "install.js" ], "optionalDependencies": { - "@canghe_ai/wechat-cli-darwin-arm64": "0.2.4" + "@canghe_ai/wechat-cli-darwin-arm64": "0.3.0" }, "engines": { "node": ">=14" diff --git a/pyproject.toml b/pyproject.toml index 032555e..bda6fe2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "wechat-cli" -version = "0.2.4" +version = "0.3.0" description = "WeChat data query CLI for LLMs" requires-python = ">=3.10" dependencies = [ diff --git a/tests/test_native_bridge_host.py b/tests/test_native_bridge_host.py new file mode 100644 index 0000000..670053b --- /dev/null +++ b/tests/test_native_bridge_host.py @@ -0,0 +1,827 @@ +import json +import os +import platform +import socket +import stat +import struct +import subprocess +import tempfile +import threading +import time +from contextlib import contextmanager +from pathlib import Path + +import pytest + +from wechat_cli.core.send_bridge import AuthenticatedUnixBridgeClient +from wechat_cli.core.sending import SendRequest + + +pytestmark = pytest.mark.skipif( + platform.system() != "Darwin" or platform.machine() != "arm64", + reason="native bridge is a macOS ARM64 component", +) + +ROOT = Path(__file__).resolve().parents[1] +MAX_FRAME_BYTES = 1024 * 1024 + + +@pytest.fixture(scope="session") +def native_build(tmp_path_factory): + output = tmp_path_factory.mktemp("native-bridge-build") + subprocess.run( + [str(ROOT / "native" / "build.sh"), str(output)], + cwd=ROOT, + check=True, + text=True, + capture_output=True, + ) + dylib = output / "libwechat_bridge_core.dylib" + host = output / "native_fake_host" + assert dylib.is_file() + assert host.is_file() + file_output = subprocess.run( + ["file", str(dylib), str(host)], + check=True, + text=True, + capture_output=True, + ).stdout + assert file_output.count("arm64") == 2 + return host, dylib + + +class RunningHost: + def __init__(self, process, safe_directory, instance_tag=""): + self.process = process + self.safe_directory = safe_directory + self.pid = process.pid + suffix = f"-{instance_tag}" if instance_tag else "" + self.metadata_path = ( + safe_directory / f"bridge{suffix}-{self.pid}.json" + ) + self.socket_path = ( + safe_directory / f"wechat-bridge{suffix}-{self.pid}.sock" + ) + self.stats = None + self.stdout = None + self.stderr = None + + def stop(self): + if self.stats is not None: + return + if self.process.poll() is None: + self.process.terminate() + stdout, stderr = self.process.communicate(timeout=5) + self.stdout = stdout + self.stderr = stderr + assert self.process.returncode == 0, stderr + lines = [line for line in stdout.splitlines() if line] + assert len(lines) == 1 + label, calls, max_active = lines[0].split() + assert label == "STATS" + self.stats = (int(calls), int(max_active)) + + +@contextmanager +def _running_host( + native_build, + tmp_path, + *, + ack_state="acknowledged", + delay_ms=0, + adapter_timeout_ms=3000, + instance_tag="", + directory_prefix="wb-", +): + host, _ = native_build + safe_directory = Path( + tempfile.mkdtemp(prefix=directory_prefix, dir="/tmp") + ).resolve() + safe_directory.chmod(0o700) + arguments = [ + str(host), + "--safe-directory", + str(safe_directory), + "--ack-state", + ack_state, + "--delay-ms", + str(delay_ms), + "--adapter-timeout-ms", + str(adapter_timeout_ms), + ] + if instance_tag: + arguments.extend(["--instance-tag", instance_tag]) + process = subprocess.Popen( + arguments, + cwd=ROOT, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + running = RunningHost(process, safe_directory, instance_tag) + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if running.metadata_path.exists(): + try: + running.socket_path = Path( + json.loads( + running.metadata_path.read_text(encoding="utf-8") + )["socket_path"] + ) + except (OSError, KeyError, TypeError, ValueError): + pass + if running.socket_path.exists(): + break + if process.poll() is not None: + stdout, stderr = process.communicate() + pytest.fail(f"native host exited before ready: {stdout!r} {stderr!r}") + time.sleep(0.01) + else: + process.kill() + stdout, stderr = process.communicate() + pytest.fail(f"native host did not become ready: {stdout!r} {stderr!r}") + try: + yield running + finally: + running.stop() + safe_directory.rmdir() + + +def _metadata(host): + return json.loads(host.metadata_path.read_text(encoding="utf-8")) + + +def _request_payload(host, **overrides): + metadata = _metadata(host) + payload = { + "version": 1, + "type": "send_text", + "auth_token": metadata["token"], + "request_id": "req-native-fixed", + "group": "精确群名", + "username": "room@chatroom", + "text": "你好 👋\n第二行", + } + payload.update(overrides) + return payload + + +def _encode_frame(payload): + encoded = json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + return struct.pack(">I", len(encoded)) + encoded + + +def _receive_exact(sock, size): + chunks = [] + while sum(map(len, chunks)) < size: + chunk = sock.recv(size - sum(map(len, chunks))) + if not chunk: + raise EOFError + chunks.append(chunk) + return b"".join(chunks) + + +def _transact(host, frame, *, chunks=None): + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(3) + try: + client.connect(str(host.socket_path)) + if chunks is None: + client.sendall(frame) + else: + offset = 0 + for size in chunks: + client.sendall(frame[offset : offset + size]) + offset += size + client.sendall(frame[offset:]) + prefix = _receive_exact(client, 4) + size = struct.unpack(">I", prefix)[0] + return json.loads(_receive_exact(client, size)) + finally: + client.close() + + +def _assert_rejected(host, frame): + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client.settimeout(3) + try: + client.connect(str(host.socket_path)) + client.sendall(frame) + assert client.recv(1) == b"" + finally: + client.close() + + +def test_builds_arm64_dylib_and_pid_scoped_private_metadata(native_build, tmp_path): + with _running_host(native_build, tmp_path) as host: + metadata = _metadata(host) + mode = stat.S_IMODE(host.metadata_path.stat().st_mode) + + assert set(metadata) == {"version", "pid", "socket_path", "token"} + assert metadata["version"] == 1 + assert metadata["pid"] == host.pid + assert metadata["socket_path"] == str(host.socket_path) + assert len(metadata["token"]) == 64 + assert metadata["token"].isascii() + assert mode == 0o600 + assert host.metadata_path.stat().st_uid == os.geteuid() + assert stat.S_ISSOCK(host.socket_path.stat().st_mode) + + +def test_supports_distinct_instance_tag_paths(native_build, tmp_path): + with _running_host( + native_build, tmp_path, instance_tag="lv6" + ) as host: + metadata = _metadata(host) + + assert host.metadata_path.name.startswith( + "bridge-lv6-" + ) + assert host.socket_path.name.startswith( + "wechat-bridge-lv6-" + ) + assert metadata["socket_path"] == str(host.socket_path) + + +def test_long_safe_directory_uses_compact_socket_name(native_build, tmp_path): + from wechat_cli.core.send_bridge import read_bridge_metadata + + with _running_host( + native_build, + tmp_path, + instance_tag="official-task-v1", + directory_prefix="wb-" + "x" * 55, + ) as host: + metadata = _metadata(host) + + assert host.socket_path.name.startswith("w") + assert len(os.fsencode(metadata["socket_path"])) < 104 + assert read_bridge_metadata(host.metadata_path, host.pid).socket_path == ( + host.socket_path + ) + + +def test_python_client_round_trips_unicode_newline_and_raw_ack(native_build, tmp_path): + with _running_host(native_build, tmp_path) as host: + token = _metadata(host)["token"] + client = AuthenticatedUnixBridgeClient( + pid=host.pid, + metadata_path=host.metadata_path, + request_id_factory=lambda: "req-native-fixed", + ) + receipt = client.send_text( + SendRequest( + group="精确群名", + username="room@chatroom", + text="你好 👋\n第二行", + timeout=3, + ) + ) + + assert receipt.ack_state.value == "acknowledged" + assert receipt.local_id == 41 + assert receipt.server_id == 99 + assert receipt.request_id == "req-native-fixed" + + assert host.stats == (1, 1) + assert token not in host.stdout + assert token not in host.stderr + assert "你好" not in host.stdout + assert "第二行" not in host.stdout + assert host.stderr == "" + + +def test_fragmented_big_endian_frame_is_accepted(native_build, tmp_path): + with _running_host(native_build, tmp_path) as host: + frame = _encode_frame(_request_payload(host)) + receipt = _transact(host, frame, chunks=[1, 2, 1, 7, 13]) + + assert receipt == { + "version": 1, + "type": "send_receipt", + "ack_state": "acknowledged", + "request_id": "req-native-fixed", + "group": "精确群名", + "username": "room@chatroom", + "local_id": 41, + "server_id": 99, + } + + +def test_invalid_and_duplicate_auth_never_invoke_adapter(native_build, tmp_path): + with _running_host(native_build, tmp_path) as host: + invalid = _encode_frame(_request_payload(host, auth_token="x" * 64)) + _assert_rejected(host, invalid) + + payload = _request_payload(host) + raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + duplicated = raw[:-1] + ',"auth_token":"x"}' + duplicate_frame = struct.pack(">I", len(duplicated.encode())) + duplicated.encode() + _assert_rejected(host, duplicate_frame) + + escaped_duplicate = raw[:-1] + ',"auth\\u005ftoken":"x"}' + escaped_duplicate_frame = ( + struct.pack(">I", len(escaped_duplicate.encode())) + + escaped_duplicate.encode() + ) + _assert_rejected(host, escaped_duplicate_frame) + + assert host.stats == (0, 0) + + +def test_malformed_and_oversize_frames_never_invoke_adapter(native_build, tmp_path): + with _running_host(native_build, tmp_path) as host: + malformed = b'{"version":1,"type":' + _assert_rejected(host, struct.pack(">I", len(malformed)) + malformed) + _assert_rejected(host, struct.pack(">I", 0)) + _assert_rejected(host, struct.pack(">I", MAX_FRAME_BYTES + 1)) + + assert host.stats == (0, 0) + + +@pytest.mark.parametrize("wrong_version", [2, 1.0, True]) +def test_wrong_version_and_unexpected_fields_never_invoke_adapter( + native_build, tmp_path, wrong_version +): + with _running_host(native_build, tmp_path) as host: + _assert_rejected( + host, + _encode_frame(_request_payload(host, version=wrong_version)), + ) + unexpected = _request_payload(host) + unexpected["extra"] = "not allowed" + _assert_rejected(host, _encode_frame(unexpected)) + + assert host.stats == (0, 0) + + +@pytest.mark.parametrize( + "overrides", + [ + {"group": ""}, + {"group": "含\u0000群名"}, + {"username": "wxid_not_a_group"}, + {"username": "room@chatroom\u0000"}, + {"text": ""}, + {"text": " \n\t"}, + {"text": "含\u0000文本"}, + ], +) +def test_invalid_target_or_text_never_invokes_adapter( + native_build, + tmp_path, + overrides, +): + with _running_host(native_build, tmp_path) as host: + _assert_rejected( + host, + _encode_frame(_request_payload(host, **overrides)), + ) + + assert host.stats == (0, 0) + + +def test_deeply_nested_json_is_rejected_without_crashing_host( + native_build, + tmp_path, +): + with _running_host(native_build, tmp_path) as host: + metadata = _metadata(host) + prefix = ( + '{"version":1,"type":"send_text","auth_token":"' + + metadata["token"] + + '","request_id":"req-deep","group":"精确群名",' + '"username":"room@chatroom","text":' + ).encode("utf-8") + nested = b"[" * 20000 + b"null" + b"]" * 20000 + raw = prefix + nested + b"}" + _assert_rejected(host, struct.pack(">I", len(raw)) + raw) + + receipt = _transact(host, _encode_frame(_request_payload(host))) + assert receipt["ack_state"] == "acknowledged" + + assert host.stats == (1, 1) + + +def test_host_rejects_non_private_runtime_directory(native_build, tmp_path): + host, _ = native_build + unsafe_directory = tmp_path / "unsafe-runtime" + unsafe_directory.mkdir(mode=0o755) + unsafe_directory.chmod(0o755) + result = subprocess.run( + [ + str(host), + "--safe-directory", + str(unsafe_directory), + "--ack-state", + "acknowledged", + "--delay-ms", + "0", + ], + cwd=ROOT, + stdin=subprocess.DEVNULL, + text=True, + capture_output=True, + ) + + assert result.returncode == 4 + assert list(unsafe_directory.iterdir()) == [] + + +def test_host_rejects_symlinked_runtime_directory(native_build, tmp_path): + host, _ = native_build + short_root = Path(tempfile.mkdtemp(prefix="wb-link-", dir="/tmp")) + real_directory = short_root / "real" + linked_directory = short_root / "link" + real_directory.mkdir(mode=0o700) + linked_directory.symlink_to(real_directory, target_is_directory=True) + + result = subprocess.run( + [ + str(host), + "--safe-directory", + str(linked_directory), + "--ack-state", + "acknowledged", + "--delay-ms", + "0", + "--adapter-timeout-ms", + "3000", + "--start-once", + ], + cwd=ROOT, + stdin=subprocess.DEVNULL, + text=True, + capture_output=True, + timeout=10, + ) + try: + assert result.returncode == 4 + assert list(real_directory.iterdir()) == [] + finally: + linked_directory.unlink(missing_ok=True) + real_directory.rmdir() + short_root.rmdir() + + +def test_concurrent_clients_are_processed_single_flight(native_build, tmp_path): + with _running_host(native_build, tmp_path, delay_ms=120) as host: + barrier = threading.Barrier(4) + receipts = [] + errors = [] + + def worker(index): + try: + barrier.wait() + receipts.append( + _transact( + host, + _encode_frame( + _request_payload(host, request_id=f"req-{index}") + ), + ) + ) + except Exception as error: + errors.append(error) + + threads = [threading.Thread(target=worker, args=(index,)) for index in range(3)] + for thread in threads: + thread.start() + started = time.monotonic() + barrier.wait() + for thread in threads: + thread.join(timeout=3) + elapsed = time.monotonic() - started + + assert errors == [] + assert len(receipts) == 3 + assert all(receipt["ack_state"] == "acknowledged" for receipt in receipts) + assert elapsed >= 0.30 + + assert host.stats == (3, 1) + + +def test_unknown_adapter_result_preserves_raw_unknown(native_build, tmp_path): + with _running_host(native_build, tmp_path, ack_state="unknown") as host: + receipt = _transact(host, _encode_frame(_request_payload(host))) + + assert receipt["ack_state"] == "unknown" + assert receipt["local_id"] is None + assert receipt["server_id"] is None + + assert host.stats == (1, 1) + + +def test_submitted_adapter_result_preserves_raw_submitted( + native_build, + tmp_path, +): + with _running_host( + native_build, + tmp_path, + ack_state="submitted", + ) as host: + receipt = _transact(host, _encode_frame(_request_payload(host))) + + assert receipt["ack_state"] == "submitted" + assert receipt["local_id"] is None + assert receipt["server_id"] is None + + assert host.stats == (1, 1) + + +def test_adapter_timeout_retires_socket_and_metadata(native_build, tmp_path): + with _running_host( + native_build, + tmp_path, + delay_ms=200, + adapter_timeout_ms=20, + ) as host: + receipt = _transact(host, _encode_frame(_request_payload(host))) + assert receipt["ack_state"] == "unknown" + + deadline = time.monotonic() + 1 + while time.monotonic() < deadline: + if not host.socket_path.exists() and not host.metadata_path.exists(): + break + time.sleep(0.01) + assert not host.socket_path.exists() + assert not host.metadata_path.exists() + + +def test_stop_interrupts_adapter_wait_promptly(native_build, tmp_path): + with _running_host( + native_build, + tmp_path, + delay_ms=3000, + adapter_timeout_ms=3000, + ) as host: + errors = [] + + def transact(): + try: + _transact(host, _encode_frame(_request_payload(host))) + except Exception as error: + errors.append(error) + + worker = threading.Thread(target=transact, daemon=True) + worker.start() + time.sleep(0.1) + started = time.monotonic() + host.stop() + elapsed = time.monotonic() - started + + assert elapsed < 1.0 + + +def test_teardown_removes_only_owned_exact_paths(native_build, tmp_path): + sentinel = tmp_path / "sentinel" + sentinel.write_text("keep", encoding="utf-8") + + with _running_host(native_build, tmp_path) as host: + metadata_path = host.metadata_path + socket_path = host.socket_path + assert metadata_path.exists() + assert socket_path.exists() + + assert not metadata_path.exists() + assert not socket_path.exists() + assert sentinel.read_text(encoding="utf-8") == "keep" + + +def test_production_factory_rejects_process_without_pinned_wechat_image( + native_build, +): + host, _ = native_build + completed = subprocess.run( + [str(host), "--verify-production-image-rejected"], + cwd=ROOT, + stdin=subprocess.DEVNULL, + text=True, + capture_output=True, + ) + assert completed.returncode == 0, completed.stderr + + +def test_official_task_adapter_contract(native_build): + host, _ = native_build + completed = subprocess.run( + [str(host), "--verify-official-task-adapter"], + cwd=ROOT, + stdin=subprocess.DEVNULL, + text=True, + capture_output=True, + ) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "dispatch_calls": 1, + "builder_calls": 1, + "submit_calls": 1, + "builder_before_submit": True, + "builder_service_is_null": True, + "builder_mode": 1, + "model_fields_valid": True, + "vector_aliases_model": True, + "vector_shares_ownership": True, + "callbacks_empty": True, + "source_location_valid": True, + "receipt": { + "ack_state": "submitted", + "local_id": None, + "server_id": None, + }, + } + + +def test_official_task_adapter_uses_exact_sret_storage_sizes(): + source = (ROOT / "native" / "src" / "official_task_adapter.mm").read_text( + encoding="utf-8" + ) + + assert "static_assert(sizeof(OpaqueTask) == 0x38);" in source + assert "static_assert(sizeof(OpaqueHandle) == 0x28);" in source + + +def test_injector_keeps_loader_code_off_pthread_storage_page(native_build): + host, _ = native_build + injector = host.parent / "wechat_send_injector" + + result = subprocess.run( + [str(injector), "--verify-memory-layout"], + cwd=ROOT, + stdin=subprocess.DEVNULL, + text=True, + capture_output=True, + ) + + assert result.returncode == 0 + assert result.stdout == "" + assert result.stderr == "" + + +def test_injector_loader_stub_exits_the_created_pthread(native_build): + host, _ = native_build + injector = host.parent / "wechat_send_injector" + + result = subprocess.run( + [str(injector), "--verify-loader-stub"], + cwd=ROOT, + stdin=subprocess.DEVNULL, + text=True, + capture_output=True, + ) + + assert result.returncode == 0 + assert result.stdout == "" + assert result.stderr == "" + + +def test_fixed_hook_island_signature_is_fail_closed(native_build): + host, _ = native_build + + result = subprocess.run( + [str(host), "--verify-hook-island-signature"], + cwd=ROOT, + stdin=subprocess.DEVNULL, + text=True, + capture_output=True, + ) + + assert result.returncode == 0 + assert result.stdout == "" + assert result.stderr == "" + + +def test_hook_writes_never_request_write_and_execute_together(native_build): + host, _ = native_build + + result = subprocess.run( + [str(host), "--verify-hook-write-protection"], + cwd=ROOT, + stdin=subprocess.DEVNULL, + text=True, + capture_output=True, + ) + + assert result.returncode == 0 + assert result.stdout == "" + assert result.stderr == "" + + +def test_empty_text_response_reports_decode_success(native_build): + host, _ = native_build + + result = subprocess.run( + [str(host), "--verify-empty-response-status"], + cwd=ROOT, + stdin=subprocess.DEVNULL, + text=True, + capture_output=True, + ) + + assert result.returncode == 0 + assert result.stdout == "" + assert result.stderr == "" + + +def test_production_build_excludes_crashing_high_level_send(native_build): + host, _ = native_build + dylib = host.parent / "libwechat_official_task_bridge_v2.dylib" + assert dylib.is_file() + symbols = subprocess.run( + ["nm", "-nm", str(dylib)], + check=True, + text=True, + capture_output=True, + ).stdout + build_script = (ROOT / "native" / "build.sh").read_text(encoding="utf-8") + source = "\n".join( + path.read_text(encoding="utf-8") + for path in (ROOT / "native" / "src").glob("*.mm") + ) + + assert "OwlHighLevelSendAdapter" not in symbols + assert "OwlHighLevelSendAdapter" not in source + assert "VerifyOwlMainScopeRunner" not in symbols + assert "CreateLocalVisibleAdapter" not in symbols + assert "CreateLegacyMarsWeChatAdapter" not in symbols + assert "WechatBridgeReq2Buf" not in symbols + assert "WechatBridgeHandleReq2Buf" not in symbols + assert "wechat_local_visible.mm" not in build_script + assert "libwechat_official_task_bridge_v2.dylib" in build_script + assert "official_task_adapter.mm" in build_script + assert "0x25cd228" not in source.lower() + assert "0x3399194" not in source.lower() + unsafe_artifacts = ( + "libwechat_local_visible_bridge.dylib", + "libwechat_local_visible_bridge_v3.dylib", + "libwechat_local_visible_bridge_v4.dylib", + "libwechat_local_visible_bridge_v5.dylib", + "libwechat_local_visible_bridge_v6.dylib", + ) + assert all(not (ROOT / "wechat_cli" / "bin" / name).exists() + for name in unsafe_artifacts) + + +def test_release_package_excludes_legacy_mars_bridge(): + assert not ( + ROOT / "wechat_cli" / "bin" / "libwechat_send_bridge.dylib" + ).exists() + + +def _run_transaction_scenario(native_build, scenario): + host, _ = native_build + result = subprocess.run( + [str(host), "--verify-local-visible-transaction", scenario], + cwd=ROOT, + stdin=subprocess.DEVNULL, + text=True, + capture_output=True, + ) + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +@pytest.mark.parametrize( + ( + "scenario", + "expected_local_calls", + "expected_network_calls", + "expected_update_calls", + ), + [ + ("insert-failed", 1, 0, 0), + ("network-unknown", 1, 1, 0), + ("accepted", 1, 1, 1), + ], +) +def test_local_visible_transaction_order( + native_build, + scenario, + expected_local_calls, + expected_network_calls, + expected_update_calls, +): + result = _run_transaction_scenario(native_build, scenario) + + assert result["insert_calls"] == expected_local_calls + assert result["network_calls"] == expected_network_calls + assert result["update_calls"] == expected_update_calls + + +def test_local_visible_transaction_reuses_identifiers(native_build): + result = _run_transaction_scenario(native_build, "accepted") + + assert result["insert_client_id"] == result["network_client_id"] + assert result["insert_local_id"] == result["update_local_id"] == 41 + assert result["receipt"] == { + "ack_state": "acknowledged", + "local_id": 41, + "server_id": 99, + } diff --git a/tests/test_native_bridge_provider.py b/tests/test_native_bridge_provider.py new file mode 100644 index 0000000..789038d --- /dev/null +++ b/tests/test_native_bridge_provider.py @@ -0,0 +1,94 @@ +from types import SimpleNamespace + +from wechat_cli.core import native_bridge_provider + + +def _reader(path, pid): + reader = getattr( + native_bridge_provider, + "_read_bridge_startup_error", + lambda _path, _pid: None, + ) + return reader(path, pid) + + +def test_reads_safe_pid_scoped_bridge_startup_reason(tmp_path): + path = tmp_path / "bridge-official-task-v1-123.error" + path.write_text("wechat_hook_install_failed\n", encoding="ascii") + path.chmod(0o600) + + assert _reader(path, 123) == ( + "wechat_hook_install_failed" + ) + + +def test_ignores_unsafe_bridge_startup_reason(tmp_path): + path = tmp_path / "bridge-official-task-v1-123.error" + path.write_text("unsafe detail with spaces", encoding="ascii") + path.chmod(0o644) + + assert _reader(path, 123) is None + + +def test_uses_official_task_bridge_artifacts(tmp_path): + metadata, startup_error = native_bridge_provider._bridge_paths( + tmp_path, 123 + ) + + assert metadata.name == "bridge-official-task-v1-123.json" + assert startup_error.name == "bridge-official-task-v1-123.error" + assert native_bridge_provider.BRIDGE_INSTANCE_TAG == "official-task-v1" + assert native_bridge_provider.BRIDGE_DYLIB_NAME == ( + "libwechat_official_task_bridge_v2.dylib" + ) + + +def test_prepare_native_bridge_does_not_require_profile_manifest( + monkeypatch, + tmp_path, +): + injector = tmp_path / "wechat_send_injector" + bridge_dylib = tmp_path / "libwechat_official_task_bridge_v2.dylib" + injector.write_bytes(b"injector") + bridge_dylib.write_bytes(b"bridge") + usable_calls = [] + run_calls = [] + + def usable_metadata(path, pid): + usable_calls.append((path, pid)) + return len(usable_calls) > 1 + + def fake_run(args, **kwargs): + run_calls.append((args, kwargs)) + return SimpleNamespace(returncode=0, stderr="") + + monkeypatch.setattr( + native_bridge_provider, + "_safe_bridge_directory", + lambda: tmp_path, + ) + monkeypatch.setattr( + native_bridge_provider, + "_usable_metadata", + usable_metadata, + ) + monkeypatch.setattr( + native_bridge_provider, + "_package_binary", + lambda name: tmp_path / name, + ) + monkeypatch.setattr( + native_bridge_provider.subprocess, + "run", + fake_run, + ) + + client = native_bridge_provider.prepare_native_bridge( + SimpleNamespace(pid=123), + "12345678-1234-1234-1234-123456789abc", + ) + + assert client.pid == 123 + assert client.metadata_path == tmp_path / "bridge-official-task-v1-123.json" + assert len(run_calls) == 1 + assert run_calls[0][0] == [str(injector), "123", str(bridge_dylib)] diff --git a/tests/test_native_send_service.py b/tests/test_native_send_service.py new file mode 100644 index 0000000..5600bc8 --- /dev/null +++ b/tests/test_native_send_service.py @@ -0,0 +1,421 @@ +from types import SimpleNamespace + +import pytest + +from wechat_cli.core.send_bridge import BridgeAckState, BridgeReceipt +from wechat_cli.core.send_confirmation import ( + MessageBaseline, + MessageConfirmation, + MessageConfirmationUnavailable, +) +from wechat_cli.core.sending import ( + SendRequest, + SendStatus, + SendUnavailableError, + SendUnknownError, +) + + +REQUEST_ID = "2d13ea6e-10e6-4706-8d84-39ca0998ace4" + + +def _request(timeout=15.0): + return SendRequest( + group="精确群名", + username="room@chatroom", + text="你好 👋\n第二行", + timeout=timeout, + ) + + +def _receipt(**overrides): + values = { + "request_id": REQUEST_ID, + "ack_state": BridgeAckState.ACKNOWLEDGED, + "group": "精确群名", + "username": "room@chatroom", + "local_id": 41, + "server_id": 99, + } + values.update(overrides) + return BridgeReceipt(**values) + + +class FakeBridge: + def __init__(self, *, receipt=None, error=None): + self.receipt = receipt if receipt is not None else _receipt() + self.error = error + self.requests = [] + + def send_text(self, request): + self.requests.append(request) + if self.error is not None: + raise self.error + return self.receipt + + +class FakeConfirmationStore: + def __init__( + self, + *, + confirmation=None, + baseline_error=None, + poll_error=None, + empty_baseline=False, + ): + self.confirmation = ( + confirmation + if confirmation is not None + else MessageConfirmation(local_id=41, server_id=99) + ) + self.baseline_error = baseline_error + self.poll_error = poll_error + self.empty_baseline = empty_baseline + self.baseline_calls = [] + self.poll_calls = [] + + def capture_baseline(self, username): + self.baseline_calls.append(username) + if self.baseline_error is not None: + raise self.baseline_error + maxima = {} if self.empty_baseline else {("message.db", "table", 1, 2): 0} + return MessageBaseline(username=username, max_local_ids=maxima) + + def poll_confirmation(self, **kwargs): + self.poll_calls.append(kwargs) + if self.poll_error is not None: + raise self.poll_error + return self.confirmation + + +def _service( + *, + bridge=None, + confirmation_store=None, + preflight=None, + self_username_loader=None, + bridge_provider=None, + monotonic=None, + request_id_factory=None, +): + from wechat_cli.core.native_sending import NativeSendService + + bridge = bridge or FakeBridge() + confirmation_store = confirmation_store or FakeConfirmationStore() + profile = SimpleNamespace(pid=4242) + provider_calls = [] + + def default_provider(actual_profile, request_id): + provider_calls.append((actual_profile, request_id)) + return bridge + + service = NativeSendService( + app_context=SimpleNamespace(), + preflight=preflight or (lambda app: profile), + self_username_loader=self_username_loader or (lambda app: "wxid_me"), + confirmation_store=confirmation_store, + bridge_provider=bridge_provider or default_provider, + request_id_factory=request_id_factory or (lambda: REQUEST_ID), + monotonic=monotonic or (lambda: 100.0), + ) + return service, bridge, confirmation_store, provider_calls + + +def test_native_send_reports_success_only_after_ack_and_database_confirmation(): + service, bridge, store, provider_calls = _service() + + result = service.send_text(_request()) + + assert result.success is True + assert result.status is SendStatus.SERVER_ACCEPTED + assert result.request_id == REQUEST_ID + assert result.group == "精确群名" + assert result.username == "room@chatroom" + assert result.local_id == 41 + assert result.server_id == 99 + assert store.baseline_calls == ["room@chatroom"] + assert len(store.poll_calls) == 1 + assert store.poll_calls[0]["username"] == "room@chatroom" + assert store.poll_calls[0]["self_username"] == "wxid_me" + assert store.poll_calls[0]["local_id"] == 41 + assert store.poll_calls[0]["text"] == "你好 👋\n第二行" + assert provider_calls[0][1] == REQUEST_ID + assert len(bridge.requests) == 1 + assert bridge.requests[0].text == "你好 👋\n第二行" + + +def test_native_send_discovers_ids_after_submitted_receipt_without_ids(): + bridge = FakeBridge( + receipt=_receipt( + ack_state=BridgeAckState.SUBMITTED, + local_id=None, + server_id=None, + ) + ) + store = FakeConfirmationStore( + confirmation=MessageConfirmation(local_id=43, server_id=101) + ) + service, _, _, _ = _service( + bridge=bridge, + confirmation_store=store, + ) + + result = service.send_text(_request()) + + assert result.success is True + assert result.status is SendStatus.SERVER_ACCEPTED + assert result.local_id == 43 + assert result.server_id == 101 + assert len(bridge.requests) == 1 + assert len(store.poll_calls) == 1 + assert store.poll_calls[0]["local_id"] is None + assert store.poll_calls[0]["text"] == "你好 👋\n第二行" + assert store.poll_calls[0]["baseline"].max_local_ids == { + ("message.db", "table", 1, 2): 0 + } + + +@pytest.mark.parametrize( + "local_id,server_id", + [(None, None), (41, None), (None, 99)], +) +def test_native_send_never_accepts_receipt_without_both_ids( + local_id, + server_id, +): + bridge = FakeBridge( + receipt=_receipt( + ack_state=BridgeAckState.ACKNOWLEDGED, + local_id=local_id, + server_id=server_id, + ) + ) + service, _, store, _ = _service(bridge=bridge) + + result = service.send_text(_request()) + + assert result.status is SendStatus.UNKNOWN + assert store.poll_calls == [] + + +def test_native_send_passes_remaining_deadline_to_bridge_and_confirmation(): + times = iter([10.0, 11.25, 12.0]) + service, bridge, store, _ = _service(monotonic=lambda: next(times)) + + result = service.send_text(_request(timeout=5.0)) + + assert result.success is True + assert bridge.requests[0].timeout == pytest.approx(3.75) + assert store.poll_calls[0]["timeout"] == pytest.approx(3.0) + + +@pytest.mark.parametrize( + "receipt", + [ + _receipt( + ack_state=BridgeAckState.UNKNOWN, + local_id=None, + server_id=None, + ), + _receipt(request_id="wrong-request"), + _receipt(group="错误群"), + _receipt(username="other@chatroom"), + _receipt(local_id=None), + ], +) +def test_native_send_maps_unconfirmed_or_mismatched_receipt_to_unknown(receipt): + bridge = FakeBridge(receipt=receipt) + service, _, store, _ = _service(bridge=bridge) + + result = service.send_text(_request()) + + assert result.success is False + assert result.status is SendStatus.UNKNOWN + assert result.request_id == REQUEST_ID + assert len(bridge.requests) == 1 + assert store.poll_calls == [] + + +def test_native_send_keeps_request_id_when_transport_is_ambiguous(): + bridge = FakeBridge( + error=SendUnknownError("提交状态未知,禁止自动重试") + ) + service, _, store, _ = _service(bridge=bridge) + + result = service.send_text(_request()) + + assert result.status is SendStatus.UNKNOWN + assert result.request_id == REQUEST_ID + assert result.local_id is None + assert len(bridge.requests) == 1 + assert store.poll_calls == [] + + +def test_native_send_propagates_confirmed_pre_submit_unavailable(): + bridge = FakeBridge( + error=SendUnavailableError("一个字节都未提交,未发送任何消息") + ) + service, _, store, _ = _service(bridge=bridge) + + with pytest.raises(SendUnavailableError, match="未发送"): + service.send_text(_request()) + + assert len(bridge.requests) == 1 + assert store.poll_calls == [] + + +def test_native_send_database_timeout_after_ack_is_unknown_without_retry(): + store = FakeConfirmationStore(confirmation=None) + store.confirmation = None + service, bridge, _, _ = _service(confirmation_store=store) + + result = service.send_text(_request()) + + assert result.status is SendStatus.UNKNOWN + assert result.request_id == REQUEST_ID + assert result.local_id == 41 + assert result.server_id == 99 + assert len(bridge.requests) == 1 + assert len(store.poll_calls) == 1 + + +def test_native_send_submitted_confirmation_timeout_is_unknown_without_retry(): + bridge = FakeBridge( + receipt=_receipt( + ack_state=BridgeAckState.SUBMITTED, + local_id=None, + server_id=None, + ) + ) + store = FakeConfirmationStore(confirmation=None) + store.confirmation = None + service, _, _, _ = _service( + bridge=bridge, + confirmation_store=store, + ) + + result = service.send_text(_request()) + + assert result.status is SendStatus.UNKNOWN + assert result.local_id is None + assert result.server_id is None + assert len(bridge.requests) == 1 + assert len(store.poll_calls) == 1 + + +def test_native_send_database_error_after_ack_is_unknown_without_retry(): + store = FakeConfirmationStore( + poll_error=MessageConfirmationUnavailable("刷新失败") + ) + service, bridge, _, _ = _service(confirmation_store=store) + + result = service.send_text(_request()) + + assert result.status is SendStatus.UNKNOWN + assert result.request_id == REQUEST_ID + assert len(bridge.requests) == 1 + + +def test_native_send_rejects_bridge_and_database_server_id_disagreement(): + store = FakeConfirmationStore( + confirmation=MessageConfirmation(local_id=41, server_id=100) + ) + service, bridge, _, _ = _service(confirmation_store=store) + + result = service.send_text(_request()) + + assert result.status is SendStatus.UNKNOWN + assert result.server_id == 99 + assert len(bridge.requests) == 1 + + +def test_native_send_preflight_failure_stops_before_baseline_and_bridge(): + bridge = FakeBridge() + store = FakeConfirmationStore() + + def fail_preflight(app): + raise SendUnavailableError("unsupported_wechat_build;未发送任何消息") + + service, _, _, provider_calls = _service( + bridge=bridge, + confirmation_store=store, + preflight=fail_preflight, + ) + + with pytest.raises(SendUnavailableError, match="unsupported"): + service.send_text(_request()) + + assert store.baseline_calls == [] + assert provider_calls == [] + assert bridge.requests == [] + + +def test_native_send_requires_current_account_identity_before_baseline(): + service, bridge, store, provider_calls = _service( + self_username_loader=lambda app: "", + ) + + with pytest.raises(SendUnavailableError, match="账号"): + service.send_text(_request()) + + assert store.baseline_calls == [] + assert provider_calls == [] + assert bridge.requests == [] + + +def test_native_send_baseline_failure_stops_before_bridge_injection(): + store = FakeConfirmationStore( + baseline_error=MessageConfirmationUnavailable("无法读取基线") + ) + service, bridge, _, provider_calls = _service( + confirmation_store=store, + ) + + with pytest.raises(SendUnavailableError, match="基线"): + service.send_text(_request()) + + assert provider_calls == [] + assert bridge.requests == [] + + +def test_native_send_empty_baseline_stops_before_bridge_injection(): + store = FakeConfirmationStore(empty_baseline=True) + service, bridge, _, provider_calls = _service( + confirmation_store=store, + ) + + with pytest.raises(SendUnavailableError, match="基线"): + service.send_text(_request()) + + assert provider_calls == [] + assert bridge.requests == [] + + +def test_native_send_timeout_before_submission_is_unavailable(): + times = iter([10.0, 15.0]) + service, bridge, _, provider_calls = _service( + monotonic=lambda: next(times), + ) + + with pytest.raises(SendUnavailableError, match="超时"): + service.send_text(_request(timeout=5.0)) + + assert provider_calls == [] + assert bridge.requests == [] + + +@pytest.mark.parametrize("request_id", ["", "not-a-uuid", None, True]) +def test_native_send_rejects_invalid_request_id_before_preflight(request_id): + preflight_calls = [] + service, bridge, store, provider_calls = _service( + request_id_factory=lambda: request_id, + preflight=lambda app: preflight_calls.append(app), + ) + + with pytest.raises(SendUnavailableError, match="request_id"): + service.send_text(_request()) + + assert preflight_calls == [] + assert store.baseline_calls == [] + assert provider_calls == [] + assert bridge.requests == [] diff --git a/tests/test_send.py b/tests/test_send.py new file mode 100644 index 0000000..f2487d9 --- /dev/null +++ b/tests/test_send.py @@ -0,0 +1,714 @@ +import importlib +import json +from types import SimpleNamespace + +import pytest +from click.testing import CliRunner + +import wechat_cli.main as main_module + + +def _sending_module(): + try: + return importlib.import_module("wechat_cli.core.sending") + except ModuleNotFoundError: + pytest.fail("wechat_cli.core.sending 尚未实现") + + +def _contact_rows(*rows): + return [ + {"username": username, "nick_name": nick_name, "remark": remark} + for username, nick_name, remark in rows + ] + + +def _install_contacts(monkeypatch, names, full=None): + sending = _sending_module() + monkeypatch.setattr(sending, "get_contact_names", lambda cache, decrypted_dir: names) + monkeypatch.setattr( + sending, + "get_contact_full", + lambda cache, decrypted_dir: full if full is not None else [], + ) + return sending + + +def _invoke_send(monkeypatch, service, args, names=None, full=None): + _install_contacts( + monkeypatch, + names if names is not None else {"room@chatroom": "精确群名"}, + full=full, + ) + app = SimpleNamespace( + cache=object(), + decrypted_dir="/unused", + send_service=service, + ) + monkeypatch.setattr(main_module, "AppContext", lambda config_path=None: app) + return CliRunner().invoke(main_module.cli, ["send", *args]) + + +class RecordingSendService: + def __init__(self, result=None, error=None): + self.result = result + self.error = error + self.requests = [] + + def send_text(self, request): + self.requests.append(request) + if self.error is not None: + raise self.error + return self.result + + +def test_send_resolver_accepts_existing_chatroom_username(monkeypatch): + sending = _install_contacts( + monkeypatch, + {"room@chatroom": "研发群", "wxid_alice": "Alice"}, + ) + + target = sending.resolve_send_group( + "room@chatroom", + cache=object(), + decrypted_dir="/unused", + ) + + assert target.group == "研发群" + assert target.username == "room@chatroom" + + +def test_send_resolver_accepts_only_exact_group_display_name(monkeypatch): + sending = _install_contacts( + monkeypatch, + {"room@chatroom": "研发群"}, + ) + + target = sending.resolve_send_group("研发群", object(), "/unused") + + assert target.username == "room@chatroom" + with pytest.raises(sending.SendTargetError): + sending.resolve_send_group("研发", object(), "/unused") + with pytest.raises(sending.SendTargetError): + sending.resolve_send_group("研发群 ", object(), "/unused") + + +def test_send_resolver_rejects_duplicate_group_display_name(monkeypatch): + sending = _install_contacts( + monkeypatch, + { + "room-one@chatroom": "同名群", + "room-two@chatroom": "同名群", + }, + ) + + with pytest.raises(sending.SendTargetError, match="多个群聊"): + sending.resolve_send_group("同名群", object(), "/unused") + + +def test_send_resolver_deduplicates_repeated_contact_rows_by_username(monkeypatch): + sending = _install_contacts( + monkeypatch, + {}, + full=_contact_rows( + ("room@chatroom", "唯一群", ""), + ("room@chatroom", "唯一群", ""), + ), + ) + + target = sending.resolve_send_group("唯一群", object(), "/unused") + + assert target.username == "room@chatroom" + + +def test_send_resolver_uses_existing_remark_then_nickname_display_rule(monkeypatch): + sending = _install_contacts( + monkeypatch, + {}, + full=_contact_rows( + ("remark@chatroom", "昵称群", "备注群"), + ("nick@chatroom", "昵称唯一群", ""), + ), + ) + + remark_target = sending.resolve_send_group("备注群", object(), "/unused") + nick_target = sending.resolve_send_group("昵称唯一群", object(), "/unused") + + assert remark_target.username == "remark@chatroom" + assert nick_target.username == "nick@chatroom" + + +def test_send_resolver_rejects_forged_chatroom_username(monkeypatch): + sending = _install_contacts( + monkeypatch, + {"real@chatroom": "fake@chatroom"}, + ) + + with pytest.raises(sending.SendTargetError, match="联系人库"): + sending.resolve_send_group("fake@chatroom", object(), "/unused") + + +@pytest.mark.parametrize("query", ["wxid_alice", "Alice"]) +def test_send_resolver_rejects_non_group_contact(monkeypatch, query): + sending = _install_contacts( + monkeypatch, + {"wxid_alice": "Alice"}, + ) + + with pytest.raises(sending.SendTargetError): + sending.resolve_send_group(query, object(), "/unused") + + +@pytest.mark.parametrize("text", ["", " ", "\n\t"]) +def test_send_rejects_empty_or_whitespace_text_with_exit_2(monkeypatch, text): + service = RecordingSendService() + + result = _invoke_send( + monkeypatch, + service, + ["精确群名", text, "--format", "json"], + ) + + assert result.exit_code == 2 + assert json.loads(result.stdout)["status"] == "input_error" + assert result.stderr == "" + assert service.requests == [] + + +@pytest.mark.parametrize("timeout", ["0", "-1", "nan", "inf"]) +def test_send_rejects_non_positive_or_non_finite_timeout_with_exit_2( + monkeypatch, + timeout, +): + service = RecordingSendService() + + result = _invoke_send( + monkeypatch, + service, + ["精确群名", "hello", "--timeout", timeout, "--format", "json"], + ) + + assert result.exit_code == 2 + assert json.loads(result.stdout)["status"] == "input_error" + assert service.requests == [] + + +def test_send_preserves_unicode_newlines_and_shell_special_characters(monkeypatch): + sending = _sending_module() + text = "你好 👋\n第二行 $HOME `echo nope` ; & | < > \" '" + service = RecordingSendService( + result=sending.SendResult.sent( + request_id="req-1", + group="精确群名", + username="room@chatroom", + local_id=123, + server_id=456, + ) + ) + + result = _invoke_send( + monkeypatch, + service, + ["精确群名", text, "--timeout", "2.5", "--format", "json"], + ) + + assert result.exit_code == 0 + assert len(service.requests) == 1 + assert service.requests[0].text == text + assert service.requests[0].timeout == 2.5 + + +def test_send_success_json_contract(monkeypatch): + sending = _sending_module() + service = RecordingSendService( + result=sending.SendResult.sent( + request_id="req-success", + group="精确群名", + username="room@chatroom", + local_id=41, + server_id=99, + ) + ) + + result = _invoke_send( + monkeypatch, + service, + ["精确群名", "原样消息", "--format", "json"], + ) + + assert result.exit_code == 0 + assert json.loads(result.stdout) == { + "success": True, + "status": "server_accepted", + "request_id": "req-success", + "group": "精确群名", + "username": "room@chatroom", + "local_id": 41, + "server_id": 99, + } + assert result.stderr == "" + + +def test_send_target_error_is_json_and_exit_1(monkeypatch): + service = RecordingSendService() + + result = _invoke_send( + monkeypatch, + service, + ["不存在", "hello", "--format", "json"], + ) + + payload = json.loads(result.stdout) + assert result.exit_code == 1 + assert payload["success"] is False + assert payload["status"] == "target_error" + assert service.requests == [] + + +@pytest.mark.parametrize( + "contact_failure", + ["empty", "permission-error"], +) +def test_send_contact_store_failure_is_pre_send_unavailable_exit_3( + monkeypatch, + contact_failure, +): + sending = _sending_module() + service = RecordingSendService() + if contact_failure == "permission-error": + def fail_contact_names(cache, decrypted_dir): + raise PermissionError("contact.db 不可读") + + monkeypatch.setattr(sending, "get_contact_names", fail_contact_names) + monkeypatch.setattr( + sending, + "get_contact_full", + lambda cache, decrypted_dir: [], + ) + else: + monkeypatch.setattr( + sending, + "get_contact_names", + lambda cache, decrypted_dir: {}, + ) + monkeypatch.setattr( + sending, + "get_contact_full", + lambda cache, decrypted_dir: [], + ) + app = SimpleNamespace( + cache=object(), + decrypted_dir="/unused", + send_service=service, + ) + monkeypatch.setattr(main_module, "AppContext", lambda config_path=None: app) + + result = CliRunner().invoke( + main_module.cli, + ["send", "精确群名", "hello", "--format", "json"], + ) + + payload = json.loads(result.stdout) + assert result.exit_code == 3 + assert payload["success"] is False + assert payload["status"] == "unavailable" + assert "联系人" in payload["error"] + assert "未发送" in payload["error"] + assert service.requests == [] + + +def test_send_default_backend_fails_closed_with_exit_3(monkeypatch): + _install_contacts(monkeypatch, {"room@chatroom": "精确群名"}) + app = SimpleNamespace(cache=object(), decrypted_dir="/unused") + monkeypatch.setattr(main_module, "AppContext", lambda config_path=None: app) + + result = CliRunner().invoke( + main_module.cli, + ["send", "精确群名", "hello", "--format", "json"], + ) + + payload = json.loads(result.stdout) + assert result.exit_code == 3 + assert payload["success"] is False + assert payload["status"] == "unavailable" + assert "当前构建" in payload["error"] + assert "bridge" in payload["error"] + assert "未发送" in payload["error"] + + +@pytest.mark.parametrize( + "init_error", + [ + FileNotFoundError("密钥文件不存在"), + RuntimeError("配置损坏"), + ], + ids=["missing-keys", "unexpected-init-error"], +) +@pytest.mark.parametrize("fmt", ["json", "text"]) +def test_send_app_context_failure_is_pre_send_unavailable_exit_3( + monkeypatch, + init_error, + fmt, +): + def fail_app_context(config_path=None): + raise type(init_error)(str(init_error)) + + service_lookups = [] + send_command = importlib.import_module("wechat_cli.commands.send") + monkeypatch.setattr(main_module, "AppContext", fail_app_context) + monkeypatch.setattr( + send_command, + "get_send_service", + lambda app: service_lookups.append(app), + ) + + result = CliRunner().invoke( + main_module.cli, + ["send", "精确群名", "hello", "--format", fmt], + ) + + assert result.exit_code == 3 + assert service_lookups == [] + if fmt == "json": + payload = json.loads(result.stdout) + assert payload["success"] is False + assert payload["status"] == "unavailable" + assert "未发送" in payload["error"] + assert result.stderr == "" + else: + assert result.stdout == "" + assert "发送失败" in result.stderr + assert "未发送" in result.stderr + + +def test_send_input_validation_precedes_app_context_failure(monkeypatch): + def fail_app_context(config_path=None): + raise FileNotFoundError("密钥文件不存在") + + monkeypatch.setattr(main_module, "AppContext", fail_app_context) + + result = CliRunner().invoke( + main_module.cli, + ["send", "精确群名", " ", "--format", "json"], + ) + + assert result.exit_code == 2 + assert json.loads(result.stdout)["status"] == "input_error" + assert result.stderr == "" + + +def test_other_commands_keep_existing_app_context_failure_exit_1(monkeypatch): + def fail_app_context(config_path=None): + raise FileNotFoundError("密钥文件不存在") + + monkeypatch.setattr(main_module, "AppContext", fail_app_context) + + result = CliRunner().invoke(main_module.cli, ["sessions"]) + + assert result.exit_code == 1 + assert result.stdout == "" + assert result.stderr == "密钥文件不存在\n" + + +def test_send_unknown_result_exits_4_and_forbids_automatic_retry(monkeypatch): + sending = _sending_module() + service = RecordingSendService( + result=sending.SendResult.unknown( + request_id="req-unknown", + group="精确群名", + username="room@chatroom", + local_id=7, + server_id=None, + ) + ) + + result = _invoke_send( + monkeypatch, + service, + ["精确群名", "hello", "--format", "json"], + ) + + payload = json.loads(result.stdout) + assert result.exit_code == 4 + assert payload["success"] is False + assert payload["status"] == "unknown" + assert payload["auto_retry"] is False + assert len(service.requests) == 1 + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("request_id", object()), + ("request_id", ""), + ("group", object()), + ("group", "另一个群"), + ("username", object()), + ("username", "other@chatroom"), + ("local_id", object()), + ("local_id", 0), + ("server_id", {"not": "serializable contract"}), + ("server_id", -1), + ], +) +def test_send_sanitizes_malformed_unknown_result_as_exit_4_json( + monkeypatch, + field, + value, +): + sending = _sending_module() + receipt = { + "request_id": "req-unknown", + "group": "精确群名", + "username": "room@chatroom", + "local_id": 7, + "server_id": None, + } + receipt[field] = value + service = RecordingSendService( + result=sending.SendResult.unknown(**receipt) + ) + + result = _invoke_send( + monkeypatch, + service, + ["精确群名", "hello", "--format", "json"], + ) + + payload = json.loads(result.stdout) + assert result.exit_code == 4 + assert payload["success"] is False + assert payload["status"] == "unknown" + assert payload["auto_retry"] is False + assert "error" in payload + assert result.stderr == "" + assert len(service.requests) == 1 + + +def test_send_sanitizes_unbounded_unknown_message_id_as_exit_4_json(monkeypatch): + sending = _sending_module() + service = RecordingSendService( + result=sending.SendResult.unknown( + request_id="req-unknown", + group="精确群名", + username="room@chatroom", + local_id=10 ** 5000, + ) + ) + + result = _invoke_send( + monkeypatch, + service, + ["精确群名", "hello", "--format", "json"], + ) + + payload = json.loads(result.stdout) + assert result.exit_code == 4 + assert payload["status"] == "unknown" + assert payload["auto_retry"] is False + assert "error" in payload + + +def test_send_rejects_status_object_that_only_compares_equal_to_unknown(monkeypatch): + sending = _sending_module() + + class PretendsToBeUnknown: + def __eq__(self, other): + return other is sending.SendStatus.UNKNOWN + + def __str__(self): + raise RuntimeError("must never be serialized") + + service = RecordingSendService( + result=sending.SendResult( + success=False, + status=PretendsToBeUnknown(), + request_id="req-unknown", + group="精确群名", + username="room@chatroom", + local_id=None, + server_id=None, + ) + ) + + result = _invoke_send( + monkeypatch, + service, + ["精确群名", "hello", "--format", "json"], + ) + + payload = json.loads(result.stdout) + assert result.exit_code == 4 + assert payload["status"] == "unknown" + assert payload["auto_retry"] is False + assert "error" in payload + + +@pytest.mark.parametrize( + ("error_name", "expected_exit", "expected_status"), + [ + ("unavailable", 3, "unavailable"), + ("unknown", 4, "unknown"), + ("unexpected", 4, "unknown"), + ], +) +def test_send_maps_injected_backend_errors( + monkeypatch, + error_name, + expected_exit, + expected_status, +): + sending = _sending_module() + errors = { + "unavailable": sending.SendUnavailableError("bridge 不可用,未发送"), + "unknown": sending.SendUnknownError("结果未知,禁止自动重试"), + "unexpected": RuntimeError("bridge unexpected failure"), + } + service = RecordingSendService(error=errors[error_name]) + + result = _invoke_send( + monkeypatch, + service, + ["精确群名", "hello", "--format", "json"], + ) + + payload = json.loads(result.stdout) + assert result.exit_code == expected_exit + assert payload["success"] is False + assert payload["status"] == expected_status + if expected_exit == 4: + assert payload["auto_retry"] is False + assert len(service.requests) == 1 + + +@pytest.mark.parametrize( + "backend_result_kind", + ["non-result", "unsupported-status"], +) +def test_send_rejects_unrecognized_backend_result_as_unknown( + monkeypatch, + backend_result_kind, +): + sending = _sending_module() + if backend_result_kind == "unsupported-status": + backend_result = sending.SendResult( + success=True, + status="unsupported", + request_id="req-bad", + group="精确群名", + username="room@chatroom", + local_id=1, + server_id=2, + ) + else: + backend_result = {"status": "server_accepted"} + service = RecordingSendService(result=backend_result) + + result = _invoke_send( + monkeypatch, + service, + ["精确群名", "hello", "--format", "json"], + ) + + payload = json.loads(result.stdout) + assert result.exit_code == 4 + assert payload["status"] == "unknown" + assert payload["auto_retry"] is False + assert len(service.requests) == 1 + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("request_id", ""), + ("group", "另一个群"), + ("username", "other@chatroom"), + ("local_id", None), + ("server_id", None), + ("local_id", 0), + ("server_id", 0), + ], +) +def test_send_rejects_malformed_or_mismatched_success_receipt_as_unknown( + monkeypatch, + field, + value, +): + sending = _sending_module() + receipt = { + "request_id": "req-valid", + "group": "精确群名", + "username": "room@chatroom", + "local_id": 11, + "server_id": 22, + } + receipt[field] = value + service = RecordingSendService(result=sending.SendResult.sent(**receipt)) + + result = _invoke_send( + monkeypatch, + service, + ["精确群名", "hello", "--format", "json"], + ) + + payload = json.loads(result.stdout) + assert result.exit_code == 4 + assert payload["success"] is False + assert payload["status"] == "unknown" + assert payload["auto_retry"] is False + assert len(service.requests) == 1 + + +def test_send_text_output_is_concise_chinese(monkeypatch): + sending = _sending_module() + service = RecordingSendService( + result=sending.SendResult.sent( + request_id="req-text", + group="精确群名", + username="room@chatroom", + local_id=1, + server_id=2, + ) + ) + + result = _invoke_send( + monkeypatch, + service, + ["精确群名", "hello", "--format", "text"], + ) + + assert result.exit_code == 0 + expected = "服务器已接受发往“精确群名”的消息(request_id: req-text)\n" + assert result.stdout == expected + + +def test_existing_query_resolver_remains_fuzzy(monkeypatch): + from wechat_cli.core import contacts + + monkeypatch.setattr( + contacts, + "get_contact_names", + lambda cache, decrypted_dir: {"room@chatroom": "研发交流群"}, + ) + + assert contacts.resolve_username("研发", object(), "/unused") == "room@chatroom" + + +def test_send_is_registered_in_root_help_with_example(): + result = CliRunner().invoke(main_module.cli, ["--help"]) + + assert result.exit_code == 0 + assert "send" in result.stdout + assert 'wechat-cli send "AI交流群" "大家好"' in result.stdout + + +def test_send_help_documents_contract_without_confirmation_flag(monkeypatch): + app = SimpleNamespace(cache=object(), decrypted_dir="/unused") + monkeypatch.setattr(main_module, "AppContext", lambda config_path=None: app) + + result = CliRunner().invoke(main_module.cli, ["send", "--help"]) + + assert result.exit_code == 0 + assert "GROUP TEXT" in result.stdout + assert "--timeout" in result.stdout + assert "--format" in result.stdout + assert "--yes" not in result.stdout diff --git a/tests/test_send_bridge.py b/tests/test_send_bridge.py new file mode 100644 index 0000000..c14272f --- /dev/null +++ b/tests/test_send_bridge.py @@ -0,0 +1,607 @@ +import json +import os +import socket +import struct +import tempfile +from pathlib import Path + +import pytest + +from wechat_cli.core.sending import ( + SendRequest, + SendUnavailableError, + SendUnknownError, +) + + +PID = 4242 +TOKEN = "secret-token-value-that-is-never-logged" + + +class FakeSocket: + def __init__( + self, + response=b"", + *, + connect_error=None, + send_plan=None, + recv_error=None, + ): + self.response = bytearray(response) + self.connect_error = connect_error + self.send_plan = list(send_plan or []) + self.recv_error = recv_error + self.connected_to = None + self.sent = bytearray() + self.timeouts = [] + self.closed = False + self.connect_calls = 0 + + def settimeout(self, value): + self.timeouts.append(value) + + def connect(self, path): + self.connect_calls += 1 + if self.connect_error: + raise self.connect_error + self.connected_to = path + + def send(self, data): + if self.send_plan: + action = self.send_plan.pop(0) + if isinstance(action, BaseException): + raise action + count = min(action, len(data)) + else: + count = len(data) + self.sent.extend(bytes(data[:count])) + return count + + def recv(self, size): + if self.recv_error: + raise self.recv_error + if not self.response: + return b"" + result = bytes(self.response[:size]) + del self.response[:size] + return result + + def close(self): + self.closed = True + + +def _metadata(tmp_path): + from wechat_cli.core.send_bridge import BridgeMetadata + + return BridgeMetadata( + version=1, + pid=PID, + socket_path=tmp_path / f"wechat-bridge-{PID}.sock", + token=TOKEN, + ) + + +def _response(payload): + encoded = json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + return struct.pack(">I", len(encoded)) + encoded + + +def _success_payload(**overrides): + payload = { + "version": 1, + "type": "send_receipt", + "ack_state": "acknowledged", + "request_id": "req-fixed", + "group": "精确群名", + "username": "room@chatroom", + "local_id": 11, + "server_id": 22, + } + payload.update(overrides) + return payload + + +def _client(tmp_path, fake_socket, **overrides): + from wechat_cli.core.send_bridge import AuthenticatedUnixBridgeClient + + options = { + "pid": PID, + "metadata_path": tmp_path / f"bridge-{PID}.json", + "metadata_reader": lambda path, pid: _metadata(tmp_path), + "socket_factory": lambda: fake_socket, + "peer_verifier": lambda sock, metadata: None, + "request_id_factory": lambda: "req-fixed", + } + options.update(overrides) + return AuthenticatedUnixBridgeClient(**options) + + +def _request(text="原样消息"): + return SendRequest( + group="精确群名", + username="room@chatroom", + text=text, + timeout=2.5, + ) + + +def test_metadata_reader_requires_pid_specific_0600_owned_regular_file_and_socket( + tmp_path, +): + from wechat_cli.core.send_bridge import read_bridge_metadata + + with tempfile.TemporaryDirectory(prefix="wechat-bridge-test-", dir="/tmp") as raw: + short_dir = Path(raw) + socket_path = short_dir / f"wechat-bridge-{PID}.sock" + unix_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + unix_socket.bind(str(socket_path)) + socket_path.chmod(0o600) + metadata_path = short_dir / f"bridge-{PID}.json" + metadata_path.write_text( + json.dumps( + { + "version": 1, + "pid": PID, + "socket_path": str(socket_path), + "token": TOKEN, + } + ), + encoding="utf-8", + ) + metadata_path.chmod(0o600) + try: + metadata = read_bridge_metadata(metadata_path, PID) + finally: + unix_socket.close() + + assert metadata.pid == PID + assert metadata.socket_path == socket_path + assert metadata.token == TOKEN + + +def test_metadata_reader_accepts_exact_legacy_compact_socket_name(): + from wechat_cli.core.send_bridge import read_bridge_metadata + + with tempfile.TemporaryDirectory(prefix="wechat-bridge-test-", dir="/tmp") as raw: + short_dir = Path(raw) + socket_path = short_dir / f"w-0123456789abcde4{PID}" + unix_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + unix_socket.bind(str(socket_path)) + socket_path.chmod(0o600) + metadata_path = short_dir / f"bridge-{PID}.json" + metadata_path.write_text( + json.dumps( + { + "version": 1, + "pid": PID, + "socket_path": str(socket_path), + "token": TOKEN, + } + ), + encoding="utf-8", + ) + metadata_path.chmod(0o600) + try: + metadata = read_bridge_metadata(metadata_path, PID) + finally: + unix_socket.close() + + assert metadata.socket_path == socket_path + + +def test_metadata_reader_rejects_socket_mode_other_than_0600(tmp_path): + from wechat_cli.core.send_bridge import read_bridge_metadata + + with tempfile.TemporaryDirectory(prefix="wechat-bridge-test-", dir="/tmp") as raw: + short_dir = Path(raw) + socket_path = short_dir / f"wechat-bridge-{PID}.sock" + unix_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + unix_socket.bind(str(socket_path)) + socket_path.chmod(0o777) + metadata_path = short_dir / f"bridge-{PID}.json" + metadata_path.write_text( + json.dumps( + { + "version": 1, + "pid": PID, + "socket_path": str(socket_path), + "token": TOKEN, + } + ), + encoding="utf-8", + ) + metadata_path.chmod(0o600) + try: + with pytest.raises(SendUnavailableError, match="socket"): + read_bridge_metadata(metadata_path, PID) + finally: + unix_socket.close() + + +def test_connected_peer_matches_uid_path_and_inspected_socket_inode(): + from wechat_cli.core.send_bridge import ( + read_bridge_metadata, + verify_connected_bridge_peer, + ) + + with tempfile.TemporaryDirectory(prefix="wechat-bridge-test-", dir="/tmp") as raw: + short_dir = Path(raw) + socket_path = short_dir / f"wechat-bridge-{PID}.sock" + metadata_path = short_dir / f"bridge-{PID}.json" + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + accepted = None + server.bind(str(socket_path)) + socket_path.chmod(0o600) + server.listen(1) + metadata_path.write_text( + json.dumps( + { + "version": 1, + "pid": PID, + "socket_path": str(socket_path), + "token": TOKEN, + } + ), + encoding="utf-8", + ) + metadata_path.chmod(0o600) + metadata = read_bridge_metadata(metadata_path, PID) + try: + client.connect(str(socket_path)) + accepted, _ = server.accept() + + verify_connected_bridge_peer(client, metadata) + finally: + client.close() + if accepted is not None: + accepted.close() + server.close() + + +def test_connected_peer_rejects_endpoint_replaced_after_metadata_read(): + from wechat_cli.core.send_bridge import ( + read_bridge_metadata, + verify_connected_bridge_peer, + ) + + with tempfile.TemporaryDirectory(prefix="wechat-bridge-test-", dir="/tmp") as raw: + short_dir = Path(raw) + socket_path = short_dir / f"wechat-bridge-{PID}.sock" + metadata_path = short_dir / f"bridge-{PID}.json" + original = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + original.bind(str(socket_path)) + socket_path.chmod(0o600) + metadata_path.write_text( + json.dumps( + { + "version": 1, + "pid": PID, + "socket_path": str(socket_path), + "token": TOKEN, + } + ), + encoding="utf-8", + ) + metadata_path.chmod(0o600) + metadata = read_bridge_metadata(metadata_path, PID) + original.close() + socket_path.unlink() + + replacement = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + accepted = None + replacement.bind(str(socket_path)) + socket_path.chmod(0o600) + replacement.listen(1) + try: + client.connect(str(socket_path)) + accepted, _ = replacement.accept() + + with pytest.raises(SendUnavailableError, match="socket"): + verify_connected_bridge_peer(client, metadata) + finally: + client.close() + if accepted is not None: + accepted.close() + replacement.close() + + +@pytest.mark.parametrize("mode", [0o644, 0o400, 0o666]) +def test_metadata_reader_rejects_any_mode_other_than_0600(tmp_path, mode): + from wechat_cli.core.send_bridge import read_bridge_metadata + + metadata_path = tmp_path / f"bridge-{PID}.json" + metadata_path.write_text("{}", encoding="utf-8") + metadata_path.chmod(mode) + + with pytest.raises(SendUnavailableError, match="凭据"): + read_bridge_metadata(metadata_path, PID) + + +def test_metadata_reader_rejects_symlink_without_disclosing_contents(tmp_path): + from wechat_cli.core.send_bridge import read_bridge_metadata + + target = tmp_path / "target.json" + target.write_text(TOKEN, encoding="utf-8") + target.chmod(0o600) + metadata_path = tmp_path / f"bridge-{PID}.json" + metadata_path.symlink_to(target) + + with pytest.raises(SendUnavailableError) as raised: + read_bridge_metadata(metadata_path, PID) + + assert TOKEN not in str(raised.value) + + +@pytest.mark.parametrize( + "mutation", + [ + {"pid": PID + 1}, + {"version": 2}, + {"token": "short"}, + {"socket_path": "/tmp/not-pid-specific.sock"}, + ], +) +def test_metadata_reader_rejects_mismatched_or_weak_metadata(tmp_path, mutation): + from wechat_cli.core.send_bridge import read_bridge_metadata + + metadata_path = tmp_path / f"bridge-{PID}.json" + payload = { + "version": 1, + "pid": PID, + "socket_path": str(tmp_path / f"bridge-{PID}.sock"), + "token": TOKEN, + } + payload.update(mutation) + metadata_path.write_text(json.dumps(payload), encoding="utf-8") + metadata_path.chmod(0o600) + + with pytest.raises(SendUnavailableError) as raised: + read_bridge_metadata(metadata_path, PID) + + assert TOKEN not in str(raised.value) + + +def test_client_sends_one_authenticated_length_prefixed_json_frame(tmp_path): + fake_socket = FakeSocket(_response(_success_payload())) + client = _client(tmp_path, fake_socket) + + result = client.send_text(_request("你好 👋\n$HOME `nope`")) + + size = struct.unpack(">I", fake_socket.sent[:4])[0] + payload = json.loads(fake_socket.sent[4 : 4 + size]) + assert len(fake_socket.sent) == size + 4 + assert payload == { + "version": 1, + "type": "send_text", + "auth_token": TOKEN, + "request_id": "req-fixed", + "group": "精确群名", + "username": "room@chatroom", + "text": "你好 👋\n$HOME `nope`", + } + assert result.ack_state.value == "acknowledged" + assert result.local_id == 11 + assert result.server_id == 22 + assert fake_socket.connect_calls == 1 + assert fake_socket.connected_to.endswith(f"wechat-bridge-{PID}.sock") + assert fake_socket.closed is True + assert fake_socket.timeouts + + +def test_client_checks_connected_peer_before_submitting_any_bytes(tmp_path): + fake_socket = FakeSocket(_response(_success_payload())) + + def reject_peer(sock, metadata): + raise SendUnavailableError( + "bridge socket 对端身份不可信,未发送任何消息" + ) + + client = _client( + tmp_path, + fake_socket, + peer_verifier=reject_peer, + ) + + with pytest.raises(SendUnavailableError, match="对端身份"): + client.send_text(_request()) + + assert fake_socket.connect_calls == 1 + assert fake_socket.sent == b"" + assert fake_socket.closed is True + + +@pytest.mark.parametrize( + ("failure", "expected_error"), + [ + ("connect", SendUnavailableError), + ("first-send", SendUnavailableError), + ("partial-send", SendUnknownError), + ("response-timeout", SendUnknownError), + ("response-eof", SendUnknownError), + ], +) +def test_client_classifies_transport_failure_from_bytes_submitted( + tmp_path, + failure, + expected_error, +): + kwargs = {} + if failure == "connect": + kwargs["connect_error"] = ConnectionRefusedError() + elif failure == "first-send": + kwargs["send_plan"] = [BrokenPipeError()] + elif failure == "partial-send": + kwargs["send_plan"] = [2, BrokenPipeError()] + elif failure == "response-timeout": + kwargs["recv_error"] = TimeoutError() + fake_socket = FakeSocket(**kwargs) + client = _client(tmp_path, fake_socket) + + with pytest.raises(expected_error) as raised: + client.send_text(_request()) + + assert fake_socket.connect_calls == 1 + assert fake_socket.closed is True + assert TOKEN not in str(raised.value) + assert "原样消息" not in str(raised.value) + + +@pytest.mark.parametrize( + "response", + [ + struct.pack(">I", 0), + struct.pack(">I", 1024 * 1024 + 1), + _response({"not": "a receipt"}), + _response(_success_payload(request_id="another-request")), + _response(_success_payload(username="other@chatroom")), + _response(_success_payload(local_id=0)), + _response(_success_payload(local_id=None)), + _response(_success_payload(server_id=None)), + _response( + _success_payload( + ack_state="submitted", + local_id=11, + server_id=None, + ) + ), + _response( + _success_payload( + ack_state="submitted", + local_id=None, + server_id=22, + ) + ), + ], +) +def test_client_rejects_oversized_malformed_or_mismatched_response_as_unknown( + tmp_path, + response, +): + fake_socket = FakeSocket(response) + client = _client(tmp_path, fake_socket) + + with pytest.raises(SendUnknownError): + client.send_text(_request()) + + +def test_client_maps_valid_explicit_unknown_receipt_without_retry(tmp_path): + fake_socket = FakeSocket( + _response( + _success_payload( + ack_state="unknown", + local_id=None, + server_id=None, + ) + ) + ) + client = _client(tmp_path, fake_socket) + + result = client.send_text(_request()) + + assert result.ack_state.value == "unknown" + assert result.request_id == "req-fixed" + + +def test_client_accepts_submitted_receipt_without_ids(tmp_path): + fake_socket = FakeSocket( + _response( + _success_payload( + ack_state="submitted", + local_id=None, + server_id=None, + ) + ) + ) + client = _client(tmp_path, fake_socket) + + result = client.send_text(_request()) + + assert result.ack_state.value == "submitted" + assert result.local_id is None + assert result.server_id is None + assert fake_socket.connect_calls == 1 + + +def test_client_rejects_oversized_outbound_frame_before_opening_socket(tmp_path): + from wechat_cli.core.send_bridge import MAX_FRAME_BYTES + + socket_creations = [] + client = _client( + tmp_path, + FakeSocket(), + socket_factory=lambda: socket_creations.append(True), + ) + + with pytest.raises(SendUnavailableError, match="过大"): + client.send_text(_request("x" * (MAX_FRAME_BYTES + 1))) + + assert socket_creations == [] + + +def test_client_does_not_retry_after_connect_or_transaction_failure(tmp_path): + sockets = [] + + def make_socket(): + value = FakeSocket(connect_error=ConnectionRefusedError()) + sockets.append(value) + return value + + client = _client(tmp_path, FakeSocket(), socket_factory=make_socket) + + with pytest.raises(SendUnavailableError): + client.send_text(_request()) + + assert len(sockets) == 1 + assert sockets[0].connect_calls == 1 + + +@pytest.mark.parametrize("failure_point", ["socket-factory", "set-timeout"]) +def test_client_classifies_socket_setup_failure_as_pre_send_unavailable( + tmp_path, + failure_point, +): + if failure_point == "socket-factory": + def make_socket(): + raise OSError("socket unavailable") + else: + fake_socket = FakeSocket() + + def fail_timeout(value): + raise OSError("timeout setup unavailable") + + fake_socket.settimeout = fail_timeout + make_socket = lambda: fake_socket + client = _client(tmp_path, FakeSocket(), socket_factory=make_socket) + + with pytest.raises(SendUnavailableError, match="未发送") as raised: + client.send_text(_request()) + + assert TOKEN not in str(raised.value) + + +@pytest.mark.parametrize("dependency", ["request-id", "metadata"]) +def test_client_classifies_pre_transport_dependency_failure_as_unavailable( + tmp_path, + dependency, +): + def fail(): + raise RuntimeError("dependency failed") + + overrides = {} + if dependency == "request-id": + overrides["request_id_factory"] = fail + else: + def fail_metadata(path, pid): + raise RuntimeError("dependency failed") + + overrides["metadata_reader"] = fail_metadata + client = _client(tmp_path, FakeSocket(), **overrides) + + with pytest.raises(SendUnavailableError, match="未发送"): + client.send_text(_request()) diff --git a/tests/test_send_confirmation.py b/tests/test_send_confirmation.py new file mode 100644 index 0000000..bea09e4 --- /dev/null +++ b/tests/test_send_confirmation.py @@ -0,0 +1,636 @@ +import hashlib +import sqlite3 + +import pytest + +from wechat_cli.core.send_confirmation import ( + MessageBaseline, + MessageConfirmation, + MessageConfirmationUnavailable, + MessageConfirmationStore, +) + + +USERNAME = "room@chatroom" +TABLE = f"Msg_{hashlib.md5(USERNAME.encode()).hexdigest()}" +OTHER_TABLE = f"Msg_{hashlib.md5(b'other@chatroom').hexdigest()}" +MESSAGE_TEXT = "消息" + + +def _create_message_db(path, rows=(), names=(), table_name=TABLE): + conn = sqlite3.connect(path) + try: + conn.execute( + "CREATE TABLE Name2Id (user_name TEXT)" + ) + for rowid, username in names: + conn.execute( + "INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", + (rowid, username), + ) + conn.execute( + f""" + CREATE TABLE [{table_name}] ( + local_id INTEGER, + server_id INTEGER, + local_type INTEGER, + real_sender_id INTEGER, + status INTEGER, + message_content TEXT + ) + """ + ) + for row in rows: + values = row if len(row) == 6 else (*row, MESSAGE_TEXT) + conn.execute( + f""" + INSERT INTO [{table_name}] + (local_id, server_id, local_type, real_sender_id, status, + message_content) + VALUES (?, ?, ?, ?, ?, ?) + """, + values, + ) + conn.commit() + finally: + conn.close() + + +def _locator_for(path): + return lambda username: [ + {"db_path": str(path), "table_name": TABLE} + ] + + +def _identity(path, table_name=TABLE): + resolved = path.resolve() + metadata = resolved.stat() + return ( + str(resolved), + table_name, + metadata.st_dev, + metadata.st_ino, + ) + + +def test_capture_baseline_records_max_local_id_per_database_table(tmp_path): + db_path = tmp_path / "message.db" + _create_message_db( + db_path, + rows=[ + (10, 100, 1, 1, 2), + (17, 101, 1, 1, 2), + ], + names=[(1, "wxid_me")], + ) + store = MessageConfirmationStore(_locator_for(db_path)) + + baseline = store.capture_baseline("room@chatroom") + + assert baseline == MessageBaseline( + username="room@chatroom", + max_local_ids={_identity(db_path): 17}, + ) + + +def test_confirmation_requires_new_outgoing_text_status_and_server_id(tmp_path): + db_path = tmp_path / "message.db" + _create_message_db( + db_path, + rows=[ + (20, 200, 1, 7, 2), + (21, 201, 1, 7, 2), + ], + names=[(7, "wxid_me")], + ) + store = MessageConfirmationStore(_locator_for(db_path)) + baseline = MessageBaseline( + username="room@chatroom", + max_local_ids={_identity(db_path): 20}, + ) + + confirmation = store.find_confirmation( + username="room@chatroom", + self_username="wxid_me", + local_id=21, + text=MESSAGE_TEXT, + baseline=baseline, + ) + + assert confirmation == MessageConfirmation(local_id=21, server_id=201) + + +def test_confirmation_discovers_only_unique_new_matching_text_without_local_id( + tmp_path, +): + db_path = tmp_path / "message.db" + _create_message_db( + db_path, + rows=[ + (20, 200, 1, 7, 2), + (21, 201, 1, 8, 2), + (22, 0, 1, 7, 2), + (23, 203, 1, 7, 1), + (24, 204, 3, 7, 2), + (25, 205, 1, 7, 2, "另一条消息"), + (26, 206, 1, 7, 2), + ], + names=[(7, "wxid_me"), (8, "wxid_other")], + ) + store = MessageConfirmationStore(_locator_for(db_path)) + + confirmation = store.find_confirmation( + username=USERNAME, + self_username="wxid_me", + local_id=None, + text=MESSAGE_TEXT, + baseline=MessageBaseline( + username=USERNAME, + max_local_ids={_identity(db_path): 20}, + ), + ) + + assert confirmation == MessageConfirmation(local_id=26, server_id=206) + + +def test_confirmation_uses_each_table_baseline_when_local_id_is_missing( + tmp_path, +): + first_db = tmp_path / "first.db" + second_db = tmp_path / "second.db" + _create_message_db( + first_db, + rows=[(11, 101, 1, 7, 2)], + names=[(7, "wxid_me")], + ) + _create_message_db( + second_db, + rows=[(50, 500, 1, 7, 2)], + names=[(7, "wxid_me")], + ) + store = MessageConfirmationStore( + lambda username: [ + {"db_path": str(first_db), "table_name": TABLE}, + {"db_path": str(second_db), "table_name": TABLE}, + ] + ) + + confirmation = store.find_confirmation( + username=USERNAME, + self_username="wxid_me", + local_id=None, + text=MESSAGE_TEXT, + baseline=MessageBaseline( + username=USERNAME, + max_local_ids={ + _identity(first_db): 10, + _identity(second_db): 50, + }, + ), + ) + + assert confirmation == MessageConfirmation(local_id=11, server_id=101) + + +def test_confirmation_returns_none_when_missing_local_id_match_is_ambiguous( + tmp_path, +): + first_db = tmp_path / "first.db" + second_db = tmp_path / "second.db" + _create_message_db( + first_db, + rows=[(11, 101, 1, 7, 2)], + names=[(7, "wxid_me")], + ) + _create_message_db( + second_db, + rows=[(51, 501, 1, 7, 2)], + names=[(7, "wxid_me")], + ) + store = MessageConfirmationStore( + lambda username: [ + {"db_path": str(first_db), "table_name": TABLE}, + {"db_path": str(second_db), "table_name": TABLE}, + ] + ) + + confirmation = store.find_confirmation( + username=USERNAME, + self_username="wxid_me", + local_id=None, + text=MESSAGE_TEXT, + baseline=MessageBaseline( + username=USERNAME, + max_local_ids={ + _identity(first_db): 10, + _identity(second_db): 50, + }, + ), + ) + + assert confirmation is None + + +def test_confirmation_rejects_matching_id_with_different_text(tmp_path): + db_path = tmp_path / "message.db" + _create_message_db( + db_path, + rows=[(21, 201, 1, 7, 2, "另一条消息")], + names=[(7, "wxid_me")], + ) + store = MessageConfirmationStore(_locator_for(db_path)) + baseline = MessageBaseline( + username=USERNAME, + max_local_ids={_identity(db_path): 20}, + ) + + confirmation = store.find_confirmation( + username=USERNAME, + self_username="wxid_me", + local_id=21, + text="预期消息", + baseline=baseline, + ) + + assert confirmation is None + + +@pytest.mark.parametrize( + "row", + [ + (21, 201, 1, 8, 2), # another sender + (21, 0, 1, 7, 2), # no server acknowledgement + (21, 201, 1, 7, 1), # not in final sent state + (21, 201, 3, 7, 2), # not a text message + (20, 201, 1, 7, 2), # existed at or before the baseline + ], +) +def test_confirmation_fails_closed_for_non_matching_rows(tmp_path, row): + db_path = tmp_path / "message.db" + _create_message_db( + db_path, + rows=[row], + names=[(7, "wxid_me"), (8, "wxid_other")], + ) + store = MessageConfirmationStore(_locator_for(db_path)) + baseline = MessageBaseline( + username="room@chatroom", + max_local_ids={_identity(db_path): 20}, + ) + + confirmation = store.find_confirmation( + username="room@chatroom", + self_username="wxid_me", + local_id=row[0], + text=MESSAGE_TEXT, + baseline=baseline, + ) + + assert confirmation is None + + +def test_confirmation_rejects_wrong_target_baseline(tmp_path): + db_path = tmp_path / "message.db" + _create_message_db( + db_path, + rows=[(21, 201, 1, 7, 2)], + names=[(7, "wxid_me")], + ) + store = MessageConfirmationStore(_locator_for(db_path)) + + with pytest.raises(ValueError, match="目标"): + store.find_confirmation( + username="room@chatroom", + self_username="wxid_me", + local_id=21, + text=MESSAGE_TEXT, + baseline=MessageBaseline( + username="other@chatroom", + max_local_ids={}, + ), + ) + + +def test_confirmation_rejects_unsafe_table_name_before_sql(tmp_path): + db_path = tmp_path / "message.db" + _create_message_db(db_path) + store = MessageConfirmationStore( + lambda username: [ + { + "db_path": str(db_path), + "table_name": f"{TABLE}] WHERE 1=1 --", + } + ] + ) + + with pytest.raises(MessageConfirmationUnavailable, match="消息表"): + store.capture_baseline("room@chatroom") + + +def test_capture_baseline_rejects_safe_looking_table_for_another_target(tmp_path): + db_path = tmp_path / "message.db" + _create_message_db( + db_path, + rows=[(21, 201, 1, 7, 2)], + names=[(7, "wxid_me")], + table_name=OTHER_TABLE, + ) + store = MessageConfirmationStore( + lambda username: [ + {"db_path": str(db_path), "table_name": OTHER_TABLE} + ] + ) + + with pytest.raises(MessageConfirmationUnavailable, match="目标"): + store.capture_baseline(USERNAME) + + +def test_capture_baseline_rejects_empty_target_table_set(): + store = MessageConfirmationStore(lambda username: []) + + with pytest.raises(MessageConfirmationUnavailable, match="消息表"): + store.capture_baseline(USERNAME) + + +def test_capture_baseline_rejects_empty_target_table_iterator(): + store = MessageConfirmationStore(lambda username: iter(())) + + with pytest.raises(MessageConfirmationUnavailable, match="消息表"): + store.capture_baseline(USERNAME) + + +def test_capture_baseline_rejects_non_integer_maximum(tmp_path): + db_path = tmp_path / "message.db" + _create_message_db( + db_path, + rows=[("corrupt-local-id", 201, 1, 7, 2)], + names=[(7, "wxid_me")], + ) + store = MessageConfirmationStore(_locator_for(db_path)) + + with pytest.raises(MessageConfirmationUnavailable, match="基线"): + store.capture_baseline(USERNAME) + + +def test_confirmation_never_accepts_table_discovered_after_baseline(tmp_path): + baseline_db = tmp_path / "baseline.db" + late_db = tmp_path / "late.db" + _create_message_db( + baseline_db, + rows=[(20, 200, 1, 7, 2)], + names=[(7, "wxid_me")], + ) + _create_message_db( + late_db, + rows=[(21, 201, 1, 7, 2)], + names=[(7, "wxid_me")], + ) + calls = 0 + + def locator(username): + nonlocal calls + calls += 1 + path = baseline_db if calls == 1 else late_db + return [{"db_path": str(path), "table_name": TABLE}] + + store = MessageConfirmationStore(locator) + baseline = store.capture_baseline(USERNAME) + + assert store.find_confirmation( + username=USERNAME, + self_username="wxid_me", + local_id=21, + text=MESSAGE_TEXT, + baseline=baseline, + ) is None + + +def test_confirmation_never_accepts_database_replaced_at_baseline_path(tmp_path): + db_path = tmp_path / "message.db" + original_path = tmp_path / "message-before-send.db" + _create_message_db( + db_path, + rows=[(20, 200, 1, 7, 2)], + names=[(7, "wxid_me")], + ) + store = MessageConfirmationStore(_locator_for(db_path)) + baseline = store.capture_baseline(USERNAME) + + db_path.rename(original_path) + _create_message_db( + db_path, + rows=[(21, 999, 1, 7, 2)], + names=[(7, "wxid_me")], + ) + + assert store.find_confirmation( + username=USERNAME, + self_username="wxid_me", + local_id=21, + text=MESSAGE_TEXT, + baseline=baseline, + ) is None + + +def test_confirmation_rejects_empty_refreshed_table_iterator(tmp_path): + db_path = tmp_path / "message.db" + _create_message_db( + db_path, + rows=[(20, 200, 1, 7, 2)], + names=[(7, "wxid_me")], + ) + calls = 0 + + def locator(username): + nonlocal calls + calls += 1 + if calls == 1: + return iter(({"db_path": str(db_path), "table_name": TABLE},)) + return iter(()) + + store = MessageConfirmationStore(locator) + baseline = store.capture_baseline(USERNAME) + + with pytest.raises(MessageConfirmationUnavailable, match="消息表"): + store.find_confirmation( + username=USERNAME, + self_username="wxid_me", + local_id=21, + text=MESSAGE_TEXT, + baseline=baseline, + ) + + +def test_confirmation_rejects_empty_or_wrong_table_baseline(tmp_path): + db_path = tmp_path / "message.db" + _create_message_db( + db_path, + rows=[(21, 201, 1, 7, 2)], + names=[(7, "wxid_me")], + ) + store = MessageConfirmationStore(_locator_for(db_path)) + + with pytest.raises(MessageConfirmationUnavailable, match="基线"): + store.find_confirmation( + username=USERNAME, + self_username="wxid_me", + local_id=21, + text=MESSAGE_TEXT, + baseline=MessageBaseline(username=USERNAME, max_local_ids={}), + ) + + with pytest.raises(MessageConfirmationUnavailable, match="目标"): + store.find_confirmation( + username=USERNAME, + self_username="wxid_me", + local_id=21, + text=MESSAGE_TEXT, + baseline=MessageBaseline( + username=USERNAME, + max_local_ids={_identity(db_path, OTHER_TABLE): 20}, + ), + ) + + +def test_capture_baseline_surfaces_locator_failure_before_send(): + def fail_locator(username): + raise PermissionError("cache unavailable") + + store = MessageConfirmationStore(fail_locator) + + with pytest.raises(MessageConfirmationUnavailable, match="消息表"): + store.capture_baseline("room@chatroom") + + +def test_capture_baseline_surfaces_database_failure_before_send(tmp_path): + db_path = tmp_path / "not-sqlite.db" + db_path.write_text("not sqlite", encoding="utf-8") + store = MessageConfirmationStore(_locator_for(db_path)) + + with pytest.raises(MessageConfirmationUnavailable, match="基线"): + store.capture_baseline("room@chatroom") + + +def test_poll_refreshes_locator_until_row_is_server_accepted(tmp_path): + db_path = tmp_path / "message.db" + _create_message_db( + db_path, + rows=[(21, 0, 1, 7, 1)], + names=[(7, "wxid_me")], + ) + calls = 0 + + def locator(username): + nonlocal calls + calls += 1 + if calls == 3: + conn = sqlite3.connect(db_path) + try: + conn.execute( + f""" + UPDATE [{TABLE}] + SET server_id = 501, status = 2 + WHERE local_id = 21 + """ + ) + conn.commit() + finally: + conn.close() + return [{"db_path": str(db_path), "table_name": TABLE}] + + now = 0.0 + + def monotonic(): + return now + + def sleep(seconds): + nonlocal now + now += seconds + + store = MessageConfirmationStore( + locator, + monotonic=monotonic, + sleep=sleep, + poll_interval=0.1, + ) + + confirmation = store.poll_confirmation( + username="room@chatroom", + self_username="wxid_me", + local_id=21, + text=MESSAGE_TEXT, + baseline=MessageBaseline( + username="room@chatroom", + max_local_ids={_identity(db_path): 20}, + ), + timeout=1.0, + ) + + assert confirmation == MessageConfirmation(local_id=21, server_id=501) + assert calls == 3 + + +def test_poll_timeout_returns_none_without_retrying_send(tmp_path): + db_path = tmp_path / "message.db" + _create_message_db( + db_path, + rows=[(21, 0, 1, 7, 1)], + names=[(7, "wxid_me")], + ) + calls = 0 + + def locator(username): + nonlocal calls + calls += 1 + return [{"db_path": str(db_path), "table_name": TABLE}] + + now = 0.0 + + def monotonic(): + return now + + def sleep(seconds): + nonlocal now + now += seconds + + store = MessageConfirmationStore( + locator, + monotonic=monotonic, + sleep=sleep, + poll_interval=0.1, + ) + + confirmation = store.poll_confirmation( + username="room@chatroom", + self_username="wxid_me", + local_id=21, + text=MESSAGE_TEXT, + baseline=MessageBaseline( + username="room@chatroom", + max_local_ids={_identity(db_path): 20}, + ), + timeout=0.25, + ) + + assert confirmation is None + assert calls == 4 + + +@pytest.mark.parametrize("local_id", [0, -1, True, "21"]) +def test_confirmation_rejects_invalid_local_id_without_query(tmp_path, local_id): + calls = [] + store = MessageConfirmationStore( + lambda username: calls.append(username) or [] + ) + + assert store.find_confirmation( + username="room@chatroom", + self_username="wxid_me", + local_id=local_id, + text=MESSAGE_TEXT, + baseline=MessageBaseline( + username="room@chatroom", + max_local_ids={}, + ), + ) is None + assert calls == [] diff --git a/tests/test_send_preflight.py b/tests/test_send_preflight.py new file mode 100644 index 0000000..260404d --- /dev/null +++ b/tests/test_send_preflight.py @@ -0,0 +1,570 @@ +import hashlib +import plistlib +import struct +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from wechat_cli.core.sending import SendUnavailableError + + +ARM64 = 0x0100000C +EXPECTED_UUID = "ABD88EC8-4590-3FDB-AAB5-4E5865B86B2A" +MH_EXECUTE = 2 +MH_DYLIB = 6 + + +def _thin_macho( + cpu_type=ARM64, + uuid=EXPECTED_UUID, + *, + cpu_subtype=0, + file_type=MH_EXECUTE, + is_64=True, +): + uuid_bytes = bytes.fromhex(uuid.replace("-", "")) + command = struct.pack("IIIII", cpu_type, 0, offset, len(data), 0)) + payload.append(data) + offset += len(data) + return ( + struct.pack(">II", 0xCAFEBABE, len(slices)) + + b"".join(entries) + + b"".join(payload) + ) + + +def _fat64_macho(data, *, reserved=0): + header_size = 8 + 32 + entry = struct.pack( + ">IIQQII", + ARM64, + 0, + header_size, + len(data), + 3, + reserved, + ) + return struct.pack(">II", 0xCAFEBABF, 1) + entry + data + + +def test_macho_parser_reads_arm64_uuid_from_fat_binary(): + from wechat_cli.core.send_preflight import arm64_uuid + + data = _fat_macho( + (0x01000007, _thin_macho(0x01000007, "00112233-4455-6677-8899-AABBCCDDEEFF")), + (ARM64, _thin_macho()), + ) + + assert arm64_uuid(data) == EXPECTED_UUID + + +@pytest.mark.parametrize( + "data", + [ + b"", + struct.pack(">II", 0xCAFEBABE, 1), + _thin_macho()[:-1], + _fat_macho((ARM64, b"not-a-mach-o")), + ], +) +def test_macho_parser_fails_closed_on_truncated_or_invalid_data(data): + from wechat_cli.core.send_preflight import MachOError, arm64_uuid + + with pytest.raises(MachOError): + arm64_uuid(data) + + +def test_macho_parser_rejects_nonzero_fat64_reserved_field(): + from wechat_cli.core.send_preflight import MachOError, parse_macho + + with pytest.raises(MachOError, match="reserved"): + parse_macho(_fat64_macho(_thin_macho(), reserved=1)) + + +class FakeRunner: + def __init__( + self, + bundle_path, + db_path, + pids=(4242,), + executable_path=None, + process_uid=501, + change_identity=False, + ): + self.bundle_path = Path(bundle_path) + self.db_path = Path(db_path) + self.pids = pids + self.executable_path = executable_path + self.process_uid = process_uid + self.change_identity = change_identity + self.identity_calls = 0 + self.calls = [] + + def __call__(self, args, *, timeout): + self.calls.append((tuple(args), timeout)) + if args == ["/usr/bin/pgrep", "-x", "WeChat"]: + if not self.pids: + return SimpleNamespace(returncode=1, stdout=b"", stderr=b"") + output = "".join(f"{pid}\n" for pid in self.pids).encode() + return SimpleNamespace(returncode=0, stdout=output, stderr=b"") + if args[:3] == ["/bin/ps", "-p", str(self.pids[0])]: + self.identity_calls += 1 + executable = self.executable_path or ( + self.bundle_path / "Contents/MacOS/WeChat" + ) + started_at = ( + "Thu Jul 30 16:52:06 2026" + if self.change_identity and self.identity_calls > 1 + else "Thu Jul 30 16:52:05 2026" + ) + return SimpleNamespace( + returncode=0, + stdout=( + f"{self.pids[0]} {self.process_uid} " + f"{started_at} {executable}\n" + ).encode(), + stderr=b"", + ) + if args[:5] == [ + "/usr/sbin/lsof", + "-n", + "-P", + "-a", + "-p", + ]: + return SimpleNamespace( + returncode=0, + stdout=f"p{self.pids[0]}\nfcwd\nn/\nf10\nn{self.db_path}\n".encode(), + stderr=b"", + ) + raise AssertionError(f"unexpected command: {args!r}") + + +def _make_bundle(tmp_path): + bundle = tmp_path / "Applications/WeChat.app" + executable = bundle / "Contents/MacOS/WeChat" + dylib = bundle / "Contents/Frameworks/wechat.dylib" + executable.parent.mkdir(parents=True) + dylib.parent.mkdir(parents=True) + executable.write_bytes(_thin_macho(file_type=MH_EXECUTE)) + dylib.write_bytes(_thin_macho(file_type=MH_DYLIB)) + with (bundle / "Contents/Info.plist").open("wb") as file: + plistlib.dump( + { + "CFBundleIdentifier": "com.tencent.xinWeChat", + "CFBundleExecutable": "WeChat", + "CFBundleShortVersionString": "4.1.8", + "CFBundleVersion": "36571", + "WeChatBundleVersion": "4.1.8.28", + }, + file, + ) + db_dir = tmp_path / "db" + db_dir.mkdir() + db_path = db_dir / "message_0.db" + db_path.write_bytes(b"db") + return bundle, db_dir, db_path + + +def _inspector(bundle, runner, **overrides): + from wechat_cli.core.send_preflight import PreflightInspector + + options = { + "system": lambda: "Darwin", + "machine": lambda: "arm64", + "euid": lambda: 501, + "command_runner": runner, + "expected_bundle_path": bundle, + } + options.update(overrides) + return PreflightInspector(**options) + + +def test_preflight_accepts_only_exact_supported_profile_and_open_database( + tmp_path, + monkeypatch, +): + from wechat_cli.core import send_preflight + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, db_path = _make_bundle(tmp_path) + dylib = bundle / "Contents/Frameworks/wechat.dylib" + monkeypatch.setattr( + send_preflight, + "EXPECTED_DYLIB_SHA256", + hashlib.sha256(dylib.read_bytes()).hexdigest(), + ) + runner = FakeRunner(bundle, db_path) + + profile = verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(bundle, runner), + ) + + assert profile.pid == 4242 + assert profile.bundle_path == bundle + assert profile.executable_path == bundle / "Contents/MacOS/WeChat" + assert profile.dylib_path == bundle / "Contents/Frameworks/wechat.dylib" + assert profile.arch == "arm64" + assert profile.process_uid == 501 + assert profile.process_started_at == "Thu Jul 30 16:52:05 2026" + assert profile.dylib_uuid == EXPECTED_UUID + assert profile.open_db_paths == (db_path.resolve(),) + assert runner.identity_calls == 2 + assert all(isinstance(call[0], tuple) for call in runner.calls) + + +@pytest.mark.parametrize( + ("system", "machine"), + [("Linux", "arm64"), ("Darwin", "x86_64")], +) +def test_preflight_rejects_unsupported_host_before_running_commands( + tmp_path, + system, + machine, +): + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, db_path = _make_bundle(tmp_path) + runner = FakeRunner(bundle, db_path) + + with pytest.raises(SendUnavailableError, match="macOS arm64"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector( + bundle, + runner, + system=lambda: system, + machine=lambda: machine, + ), + ) + + assert runner.calls == [] + + +@pytest.mark.parametrize("pids", [(), (1, 2)]) +def test_preflight_requires_exactly_one_main_wechat_process(tmp_path, pids): + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, db_path = _make_bundle(tmp_path) + runner = FakeRunner(bundle, db_path, pids=pids) + + with pytest.raises(SendUnavailableError, match="恰好一个"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(bundle, runner), + ) + + +def test_preflight_rejects_process_running_from_another_bundle(tmp_path): + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, db_path = _make_bundle(tmp_path) + runner = FakeRunner( + bundle, + db_path, + executable_path=tmp_path / "Other.app/Contents/MacOS/WeChat", + ) + + with pytest.raises(SendUnavailableError, match="可执行文件路径"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(bundle, runner), + ) + + +@pytest.mark.parametrize( + ("key", "value"), + [ + ("CFBundleIdentifier", "invalid.bundle"), + ("CFBundleExecutable", "WeChatBeta"), + ("CFBundleShortVersionString", "4.1.9"), + ("CFBundleVersion", "36572"), + ("WeChatBundleVersion", "4.1.8.29"), + ], +) +def test_preflight_rejects_every_info_plist_profile_mismatch( + tmp_path, + key, + value, +): + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, db_path = _make_bundle(tmp_path) + plist_path = bundle / "Contents/Info.plist" + with plist_path.open("rb") as file: + info = plistlib.load(file) + info[key] = value + with plist_path.open("wb") as file: + plistlib.dump(info, file) + runner = FakeRunner(bundle, db_path) + + with pytest.raises(SendUnavailableError, match="Info.plist"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(bundle, runner), + ) + + +def test_preflight_rejects_unapproved_dylib_hash_before_any_send( + tmp_path, + monkeypatch, +): + from wechat_cli.core import send_preflight + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, db_path = _make_bundle(tmp_path) + runner = FakeRunner(bundle, db_path) + monkeypatch.setattr(send_preflight, "EXPECTED_DYLIB_SHA256", "0" * 64) + + with pytest.raises(SendUnavailableError, match="SHA-256"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(bundle, runner), + ) + + +def test_preflight_rejects_symlinked_approved_bundle_file(tmp_path): + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, db_path = _make_bundle(tmp_path) + dylib = bundle / "Contents/Frameworks/wechat.dylib" + target = tmp_path / "copied-wechat.dylib" + target.write_bytes(dylib.read_bytes()) + dylib.unlink() + dylib.symlink_to(target) + runner = FakeRunner(bundle, db_path) + + with pytest.raises(SendUnavailableError, match="安全读取"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(bundle, runner), + ) + + +def test_preflight_rejects_symlinked_approved_bundle_directory(tmp_path): + from wechat_cli.core.send_preflight import verify_pre_injection + + real_bundle, db_dir, db_path = _make_bundle(tmp_path / "real") + linked_bundle = tmp_path / "Applications/WeChat.app" + linked_bundle.parent.mkdir(parents=True) + linked_bundle.symlink_to(real_bundle, target_is_directory=True) + runner = FakeRunner(linked_bundle, db_path) + + with pytest.raises(SendUnavailableError, match="安全读取"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(linked_bundle, runner), + ) + + +def test_preflight_rejects_missing_arm64_architecture_or_uuid( + tmp_path, + monkeypatch, +): + from wechat_cli.core import send_preflight + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, db_path = _make_bundle(tmp_path) + dylib = bundle / "Contents/Frameworks/wechat.dylib" + dylib.write_bytes( + _thin_macho( + 0x01000007, + "00112233-4455-6677-8899-AABBCCDDEEFF", + file_type=MH_DYLIB, + ) + ) + monkeypatch.setattr( + send_preflight, + "EXPECTED_DYLIB_SHA256", + hashlib.sha256(dylib.read_bytes()).hexdigest(), + ) + runner = FakeRunner(bundle, db_path) + + with pytest.raises(SendUnavailableError, match="arm64"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(bundle, runner), + ) + + +def test_preflight_requires_wechat_to_have_database_file_open_under_db_dir( + tmp_path, +): + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, _ = _make_bundle(tmp_path) + outside_db = tmp_path / "outside/message.db" + outside_db.parent.mkdir() + outside_db.write_bytes(b"db") + runner = FakeRunner(bundle, outside_db) + + with pytest.raises(SendUnavailableError, match="数据库"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(bundle, runner), + ) + + +def test_preflight_does_not_treat_directory_named_like_database_as_db_file( + tmp_path, +): + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, _ = _make_bundle(tmp_path) + fake_db_directory = db_dir / "not-a-file.db" + fake_db_directory.mkdir() + runner = FakeRunner(bundle, fake_db_directory) + + with pytest.raises(SendUnavailableError, match="数据库"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(bundle, runner), + ) + + +@pytest.mark.parametrize( + "bad_executable", + [ + _thin_macho(is_64=False, file_type=1), + _thin_macho(file_type=1), + _thin_macho(cpu_subtype=2, file_type=MH_EXECUTE), + ], +) +def test_preflight_rejects_non_arm64_all_64_bit_executable( + tmp_path, + monkeypatch, + bad_executable, +): + from wechat_cli.core import send_preflight + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, db_path = _make_bundle(tmp_path) + dylib = bundle / "Contents/Frameworks/wechat.dylib" + monkeypatch.setattr( + send_preflight, + "EXPECTED_DYLIB_SHA256", + hashlib.sha256(dylib.read_bytes()).hexdigest(), + ) + (bundle / "Contents/MacOS/WeChat").write_bytes(bad_executable) + runner = FakeRunner(bundle, db_path) + + with pytest.raises(SendUnavailableError, match="arm64"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(bundle, runner), + ) + + +def test_preflight_rejects_process_owned_by_another_uid(tmp_path): + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, db_path = _make_bundle(tmp_path) + runner = FakeRunner(bundle, db_path, process_uid=502) + + with pytest.raises(SendUnavailableError, match="进程身份"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(bundle, runner), + ) + + +def test_preflight_revalidates_process_start_identity_at_end(tmp_path): + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, db_path = _make_bundle(tmp_path) + runner = FakeRunner(bundle, db_path, change_identity=True) + + with pytest.raises(SendUnavailableError, match="进程已变化"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(bundle, runner), + ) + + assert runner.identity_calls == 2 + + +def test_preflight_rejects_path_replaced_after_safe_open( + tmp_path, + monkeypatch, +): + from wechat_cli.core import send_preflight + from wechat_cli.core.send_preflight import verify_pre_injection + + bundle, db_dir, db_path = _make_bundle(tmp_path) + dylib = bundle / "Contents/Frameworks/wechat.dylib" + moved = tmp_path / "moved-wechat.dylib" + approved_hash = hashlib.sha256(dylib.read_bytes()).hexdigest() + monkeypatch.setattr( + send_preflight, + "EXPECTED_DYLIB_SHA256", + approved_hash, + ) + real_open = send_preflight.os.open + raced = False + + def racing_open(path, flags, *, dir_fd=None): + nonlocal raced + if dir_fd is None: + descriptor = real_open(path, flags) + else: + descriptor = real_open(path, flags, dir_fd=dir_fd) + if Path(path).name == dylib.name and dir_fd is not None and not raced: + raced = True + dylib.rename(moved) + dylib.symlink_to(moved) + return descriptor + + monkeypatch.setattr(send_preflight.os, "open", racing_open) + runner = FakeRunner(bundle, db_path) + + with pytest.raises(SendUnavailableError, match="安全读取"): + verify_pre_injection( + SimpleNamespace(db_dir=str(db_dir)), + inspector=_inspector(bundle, runner), + ) diff --git a/tests/test_wechat_profile_capture.py b/tests/test_wechat_profile_capture.py new file mode 100644 index 0000000..7b20b81 --- /dev/null +++ b/tests/test_wechat_profile_capture.py @@ -0,0 +1,865 @@ +import inspect +import stat +import sys +from types import SimpleNamespace + +import pytest + +from tools import wechat_profile_capture as capture + + +def test_probe_plan_never_uses_more_than_four_hardware_breakpoints(): + phases = capture.build_probe_phases() + assert phases + assert all(1 <= len(phase.probes) <= 4 for phase in phases) + assert { + (probe.label, probe.rva) + for phase in phases + for probe in phase.probes + } == { + ("insert", 0x26E620C), + ("insert_return", 0x253B85C), + ("insert_result_check", 0x253B904), + ("mars_submit", 0x498D2E0), + } + + +def test_snapshot_pointer_regions_include_the_frame_pointer(): + assert "x29" in capture._SNAPSHOT_POINTER_REGISTERS + + +def test_every_runtime_probe_has_a_resolved_positive_rva(): + probes = [ + probe + for phase in capture.build_probe_phases() + for probe in phase.probes + ] + assert all(0 < probe.rva < 0x10000000 for probe in probes) + assert capture._unresolved_probe_labels() == [] + + +def test_capture_source_cannot_call_or_evaluate_wechat_functions(): + source = inspect.getsource(capture) + for forbidden in ( + "EvaluateExpression", + "SBExpressionOptions", + "CallFunction", + "process.Call", + "thread.Call", + "write_memory", + "WriteMemory", + ): + assert forbidden not in source + + +def test_capture_source_excludes_known_crashing_and_ui_trigger_rvas(): + source = inspect.getsource(capture).lower() + assert "0x25cd228" not in source + assert "0x3399194" not in source + + +def test_lldb_arguments_use_one_owned_lifecycle_command_and_never_send(tmp_path): + arguments = capture.build_lldb_arguments( + pid=123, + marker="WCPROFILE-20260802-01", + output=tmp_path / "events.jsonl", + ) + lldb_commands = [ + arguments[index + 1] + for index, value in enumerate(arguments[:-1]) + if value == "-o" + ] + assert len(lldb_commands) == 2 + assert lldb_commands[1].startswith("wechat-profile-run ") + assert all("process call" not in command.lower() for command in lldb_commands) + assert all("expression" not in command.lower() for command in lldb_commands) + assert all(command != "process continue" for command in lldb_commands) + assert all(command != "process detach" for command in lldb_commands) + + +def test_raw_capture_output_is_owner_only_and_rejects_symlinks(tmp_path): + output = tmp_path / "events.jsonl" + capture._prepare_output(output) + + assert stat.S_IMODE(output.stat().st_mode) == 0o600 + + target = tmp_path / "target.jsonl" + target.write_text("do not truncate", encoding="utf-8") + link = tmp_path / "link.jsonl" + link.symlink_to(target) + + with pytest.raises(OSError): + capture._prepare_output(link) + + assert target.read_text(encoding="utf-8") == "do not truncate" + + +def test_command_output_path_does_not_resolve_symlinks(tmp_path): + target = tmp_path / "target.jsonl" + link = tmp_path / "link.jsonl" + link.symlink_to(target) + + assert capture._command_output_path(str(link)) == link + + +class _CaptureResult: + def __init__(self): + self.error = None + self.messages = [] + + def SetError(self, message): + self.error = message + + def AppendMessage(self, message): + self.messages.append(message) + + +class _CommandDebugger: + def __init__(self): + self.commands = [] + + def HandleCommand(self, command): + self.commands.append(command) + + +def test_lldb_module_registers_owned_lifecycle_command(): + debugger = _CommandDebugger() + + capture.__lldb_init_module(debugger, {}) + + assert debugger.commands == [ + f"command script add -f {capture.__name__}.run_capture " + "wechat-profile-run", + ] + + +class _Process: + def __init__(self, *, interrupt_result=True, interrupt_error=None): + self.interrupts = 0 + self.stops = 0 + self.interrupt_result = interrupt_result + self.interrupt_error = interrupt_error + + def SendAsyncInterrupt(self): + self.interrupts += 1 + if self.interrupt_error is not None: + raise self.interrupt_error + return self.interrupt_result + + def Stop(self): + self.stops += 1 + return None + + +class _Timer: + def __init__(self, interval, function, args): + self.interval = interval + self.function = function + self.args = args + self.daemon = False + self.started = False + + def start(self): + self.started = True + + +def test_capture_timeout_allows_a_two_minute_manual_send_window(monkeypatch): + assert capture.CAPTURE_TIMEOUT_SECONDS == 120.0 + created = [] + + def fake_timer(interval, function, args): + timer = _Timer(interval, function, args) + created.append(timer) + return timer + + monkeypatch.setattr(capture.threading, "Timer", fake_timer) + monkeypatch.setattr(capture, "_capture_deadline", 130.0) + monkeypatch.setattr(capture.time, "monotonic", lambda: 10.0) + process = _Process() + + timer = capture._schedule_interrupt(process) + + assert timer is created[0] + assert timer.interval == 120.0 + assert timer.daemon is True + assert timer.started is True + timer.function(*timer.args) + assert process.interrupts == 1 + + +def test_capture_timeout_falls_back_to_process_stop(): + process = _Process(interrupt_result=False) + + capture._interrupt_process(process) + + assert process.interrupts == 1 + assert process.stops == 1 + + +def test_capture_timeout_exception_also_falls_back_to_process_stop(): + process = _Process(interrupt_error=RuntimeError("interrupt failed")) + + capture._interrupt_process(process) + + assert process.interrupts == 1 + assert process.stops == 1 + + +def test_capture_timeout_uses_only_remaining_deadline(monkeypatch): + created = [] + + def fake_timer(interval, function, args): + timer = _Timer(interval, function, args) + created.append(timer) + return timer + + monkeypatch.setattr(capture.threading, "Timer", fake_timer) + monkeypatch.setattr(capture, "_capture_deadline", 15.0) + monkeypatch.setattr(capture.time, "monotonic", lambda: 10.0) + + capture._schedule_interrupt(_Process()) + + assert created[0].interval == 5.0 + + +def test_capture_refuses_to_continue_after_setup_consumes_deadline(monkeypatch): + monkeypatch.setattr(capture, "_capture_deadline", 10.0) + monkeypatch.setattr(capture.time, "monotonic", lambda: 10.0) + + with pytest.raises(RuntimeError, match="capture_deadline_expired"): + capture._schedule_interrupt(_Process()) + + +class _Breakpoint: + def __init__(self, breakpoint_id): + self.breakpoint_id = breakpoint_id + + def GetID(self): + return self.breakpoint_id + + +class _BreakpointLocation: + def __init__(self, breakpoint_id): + self.breakpoint = _Breakpoint(breakpoint_id) + + def GetBreakpoint(self): + return self.breakpoint + + +def test_expired_deadline_stops_callback_before_memory_scan(monkeypatch): + monkeypatch.setattr(capture, "_capture_deadline", 10.0) + monkeypatch.setattr(capture.time, "monotonic", lambda: 10.0) + monkeypatch.setattr(capture, "_breakpoint_labels", {7: "insert"}) + monkeypatch.setattr( + capture, + "_marker_reachable", + lambda _frame: pytest.fail("expired callback must not scan memory"), + ) + + assert capture.capture_breakpoint( + object(), _BreakpointLocation(7), {} + ) is True + + +class _ScanThread: + def GetProcess(self): + return object() + + +class _ScanFrame: + def GetThread(self): + return _ScanThread() + + def FindRegister(self, name): + return _Register(0x100000000 if name == "x0" else 0) + + +def test_marker_scan_honors_absolute_deadline_before_each_read(monkeypatch): + monkeypatch.setattr(capture, "_capture_deadline", 10.0) + monkeypatch.setattr(capture.time, "monotonic", lambda: 10.0) + monkeypatch.setattr( + capture, + "_read", + lambda *_args: pytest.fail("expired scan must not read memory"), + ) + + assert capture._marker_reachable(_ScanFrame()) is False + + +class _Register: + def __init__(self, value): + self.value = value + + def GetValueAsUnsigned(self): + return self.value + + +class _IdentityThread: + def __init__(self, thread_id): + self.thread_id = thread_id + + def GetThreadID(self): + return self.thread_id + + def GetProcess(self): + return object() + + +class _IdentityFrame: + def __init__(self, *, thread_id, x1, x0=0, x8=0, sp=0): + self.thread = _IdentityThread(thread_id) + self.registers = { + "x0": _Register(x0), + "x1": _Register(x1), + "x8": _Register(x8), + "sp": _Register(sp), + } + + def GetThread(self): + return self.thread + + def FindRegister(self, name): + return self.registers[name] + + +def test_marker_and_downstream_events_require_one_transaction_identity(monkeypatch): + monkeypatch.setattr(capture, "_tracked_thread_id", 11) + monkeypatch.setattr(capture, "_tracked_model_pointer", 0x1234) + monkeypatch.setattr( + capture, + "_tracked_insert_sret_pointer", + 0x5678, + raising=False, + ) + monkeypatch.setattr(capture, "_seen_labels", {"insert"}) + same = _IdentityFrame(thread_id=11, x1=0x1234) + other_thread = _IdentityFrame(thread_id=12, x1=0x1234) + other_model = _IdentityFrame(thread_id=11, x1=0x9999) + monkeypatch.setattr(capture, "_marker_reachable", lambda _frame: True) + monkeypatch.setattr(capture, "_mars_task_command", lambda _frame: 0x20A) + + assert capture._can_arm_from_marker("insert") is True + assert capture._can_arm_from_marker("insert_return") is False + assert capture._can_arm_from_marker("insert_result_check") is False + assert capture._can_arm_from_marker("mars_submit") is False + assert capture._can_arm_from_marker("update") is False + assert capture._event_is_correlated(same, "insert_return") is True + assert capture._event_is_correlated(same, "mars_submit") is True + assert capture._event_is_correlated(same, "update") is False + assert capture._event_is_correlated(other_thread, "insert_return") is False + assert capture._event_is_correlated(other_thread, "mars_submit") is False + assert capture._event_is_correlated(other_model, "update") is False + capture._seen_labels.add("insert_return") + assert capture._event_is_correlated(same, "insert_return") is False + assert capture._event_is_correlated(same, "insert_result_check") is True + capture._seen_labels.add("insert_result_check") + assert capture._event_is_correlated(same, "insert_result_check") is False + monkeypatch.setattr(capture, "_marker_reachable", lambda _frame: False) + assert capture._event_is_correlated(same, "mars_submit") is False + + +def test_mars_submit_rejects_non_newsendmsg_tasks(monkeypatch): + frame = _IdentityFrame(thread_id=11, x1=0x1234) + monkeypatch.setattr(capture, "_tracked_thread_id", 11) + monkeypatch.setattr(capture, "_seen_labels", {"insert"}) + monkeypatch.setattr(capture, "_marker_reachable", lambda _frame: True) + monkeypatch.setattr(capture, "_mars_task_command", lambda _frame: 0x5595) + + assert capture._event_is_correlated(frame, "mars_submit") is False + + monkeypatch.setattr(capture, "_mars_task_command", lambda _frame: 0x20A) + assert capture._event_is_correlated(frame, "mars_submit") is True + capture._seen_labels.add("mars_submit") + assert capture._event_is_correlated(frame, "mars_submit") is False + + +def test_insert_marker_tracks_model_and_sret_for_one_correlated_return(monkeypatch): + frame = _IdentityFrame(thread_id=11, x1=0x1234, x8=0x5678) + monkeypatch.setattr(capture, "_capture_deadline", 100.0) + monkeypatch.setattr(capture.time, "monotonic", lambda: 1.0) + monkeypatch.setattr(capture, "_breakpoint_labels", {7: "insert"}) + monkeypatch.setattr(capture, "_marker_seen", False) + monkeypatch.setattr(capture, "_capture_error", False) + monkeypatch.setattr(capture, "_tracked_thread_id", 0) + monkeypatch.setattr(capture, "_tracked_model_pointer", 0) + monkeypatch.setattr( + capture, + "_tracked_insert_sret_pointer", + 0, + raising=False, + ) + monkeypatch.setattr(capture, "_seen_labels", set()) + monkeypatch.setattr(capture, "_active_until", 10.0) + monkeypatch.setattr(capture, "_marker_reachable", lambda _frame: True) + monkeypatch.setattr(capture, "_snapshot", lambda *_args: True) + + assert capture.capture_breakpoint( + frame, _BreakpointLocation(7), {} + ) is False + assert capture._marker_seen is True + assert capture._tracked_thread_id == 11 + assert capture._tracked_model_pointer == 0x1234 + assert capture._tracked_insert_sret_pointer == 0x5678 + assert capture._seen_labels == {"insert"} + + +def test_insert_return_snapshot_uses_tracked_model_and_sret(monkeypatch): + frame = _IdentityFrame(thread_id=11, x1=0, x8=0) + monkeypatch.setattr(capture, "_tracked_model_pointer", 0x1234) + monkeypatch.setattr( + capture, + "_tracked_insert_sret_pointer", + 0x5678, + raising=False, + ) + + assert capture._semantic_region_addresses(frame, "insert_return") == { + "model": 0x1234, + "insert_sret": 0x5678, + } + + +def test_insert_result_check_reads_the_normalized_result_from_x8(monkeypatch): + frame = _IdentityFrame(thread_id=11, x1=0, x8=0x100009000) + monkeypatch.setattr(capture, "_tracked_model_pointer", 0x1234) + + assert capture._semantic_region_addresses(frame, "insert_result_check") == { + "model": 0x1234, + "insert_result": 0x100009000, + } + + +def test_invalid_marker_snapshot_cannot_arm_or_count_transaction(monkeypatch): + frame = _IdentityFrame(thread_id=11, x1=0x1234) + monkeypatch.setattr(capture, "_capture_deadline", 100.0) + monkeypatch.setattr(capture.time, "monotonic", lambda: 1.0) + monkeypatch.setattr(capture, "_breakpoint_labels", {7: "insert"}) + monkeypatch.setattr(capture, "_marker_seen", False) + monkeypatch.setattr(capture, "_capture_error", False) + monkeypatch.setattr(capture, "_seen_labels", set()) + monkeypatch.setattr(capture, "_marker_reachable", lambda _frame: True) + monkeypatch.setattr( + capture, + "_snapshot", + lambda *_args: (_ for _ in ()).throw(RuntimeError("missing_regions")), + ) + + assert capture.capture_breakpoint( + frame, _BreakpointLocation(7), {} + ) is False + assert capture._capture_error is True + assert capture._marker_seen is False + assert capture._seen_labels == set() + + +class _SnapshotProcess: + def GetTarget(self): + return object() + + +class _SnapshotThread: + def __init__(self): + self.process = _SnapshotProcess() + + def GetProcess(self): + return self.process + + def GetNumFrames(self): + return 0 + + def GetThreadID(self): + return 11 + + def GetName(self): + return "worker" + + def GetQueueName(self): + return "queue" + + +class _SnapshotFrame: + def __init__(self): + self.thread = _SnapshotThread() + + def GetThread(self): + return self.thread + + def FindRegister(self, name): + index = 29 if name == "sp" else int(name[1:]) + return _Register(0x100001000 + index * 0x1000) + + +def test_snapshot_rejects_empty_backtrace(monkeypatch): + image_base = 0x100000000 + rva, signature = capture._EXPECTED_PROBES["insert"] + monkeypatch.setattr(capture, "_wechat_base", image_base) + monkeypatch.setattr( + capture, + "_read", + lambda _process, address, size: ( + bytes.fromhex(signature) + if address == image_base + rva + else b"x" * size + ), + ) + monkeypatch.setattr(capture, "_append_record", lambda _record: None) + + with pytest.raises(RuntimeError, match="empty_backtrace:insert"): + capture._snapshot(_SnapshotFrame(), "insert", True) + + +class _RuntimeModule: + def __init__(self, image_uuid): + self.image_uuid = image_uuid + + def GetUUIDString(self): + return self.image_uuid + + +class _RuntimeTarget: + def __init__(self, triple): + self.triple = triple + + def GetTriple(self): + return self.triple + + +def test_runtime_image_requires_pinned_uuid_architecture_and_signatures(monkeypatch): + image_base = 0x100000000 + signatures = { + image_base + rva: bytes.fromhex(signature) + for _label, (rva, signature) in capture._EXPECTED_PROBES.items() + } + monkeypatch.setattr( + capture, + "_read", + lambda _process, address, _size: signatures.get(address), + ) + target = _RuntimeTarget("arm64-apple-macosx") + module = _RuntimeModule(capture.EXPECTED_IMAGE_UUID) + + capture._validate_runtime_image(target, object(), module, image_base) + + with pytest.raises(RuntimeError, match="unsupported_wechat_image_uuid"): + capture._validate_runtime_image( + target, + object(), + _RuntimeModule("00000000-0000-0000-0000-000000000000"), + image_base, + ) + + with pytest.raises(RuntimeError, match="unsupported_wechat_architecture"): + capture._validate_runtime_image( + _RuntimeTarget("x86_64-apple-macosx"), + object(), + module, + image_base, + ) + + signatures[image_base + 0x26E620C] = b"\0" * 16 + with pytest.raises(RuntimeError, match="signature_mismatch:insert"): + capture._validate_runtime_image(target, object(), module, image_base) + + +class _CommandReturn: + def Succeeded(self): + return True + + +class _CallbackError: + def Fail(self): + return True + + def GetCString(self): + return "callback rejected" + + +class _CallbackBreakpoint: + def GetID(self): + return 42 + + def SetScriptCallbackFunction(self, _callback): + return _CallbackError() + + +class _InstallTarget: + def __init__(self): + self.breakpoint_count = 0 + + def GetProcess(self): + return object() + + def GetNumBreakpoints(self): + return self.breakpoint_count + + def GetBreakpointAtIndex(self, _index): + return _CallbackBreakpoint() + + +class _InstallInterpreter: + def __init__(self, target): + self.target = target + + def HandleCommand(self, _command, _result): + self.target.breakpoint_count += 1 + + +class _InstallDebugger: + def __init__(self, target): + self.interpreter = _InstallInterpreter(target) + + def GetCommandInterpreter(self): + return self.interpreter + + +def test_partial_breakpoint_install_is_tracked_before_callback_setup(monkeypatch): + target = _InstallTarget() + monkeypatch.setattr(capture, "_installed_breakpoint_ids", set()) + monkeypatch.setattr( + capture, + "_find_wechat_module", + lambda _target: (object(), 0x100000000), + ) + monkeypatch.setattr(capture, "_validate_runtime_image", lambda *_args: None) + monkeypatch.setitem( + sys.modules, + "lldb", + SimpleNamespace(SBCommandReturnObject=_CommandReturn), + ) + + with pytest.raises(RuntimeError, match="breakpoint_callback:insert_failed"): + capture._install_breakpoints(_InstallDebugger(target), target) + + assert capture._installed_breakpoint_ids == {42} + + +class _CancelableTimer: + def __init__(self): + self.cancelled = False + + def cancel(self): + self.cancelled = True + + +class _FinishProcess: + def __init__(self): + self.continues = 0 + self.detaches = 0 + + def GetProcessID(self): + return 321 + + def Continue(self): + self.continues += 1 + return None + + def Detach(self): + self.detaches += 1 + return None + + +class _FinishTarget: + def __init__(self): + self.process = _FinishProcess() + self.deleted = [] + + def GetProcess(self): + return self.process + + def BreakpointDelete(self, breakpoint_id): + self.deleted.append(breakpoint_id) + return True + + +class _FinishDebugger: + def __init__(self, target): + self.target = target + self.async_mode = True + self.async_updates = [] + + def GetSelectedTarget(self): + return self.target + + def GetAsync(self): + return self.async_mode + + def SetAsync(self, value): + self.async_updates.append(value) + self.async_mode = value + + +def test_owned_lifecycle_finalizes_and_detaches_after_setup_failure(monkeypatch): + target = _FinishTarget() + finalized = [] + monkeypatch.setattr( + capture, + "_install_breakpoints", + lambda _debugger, _target: (_ for _ in ()).throw( + RuntimeError("breakpoint_setup_failed") + ), + ) + monkeypatch.setattr( + capture, + "_finalize_capture", + lambda _target, _process, *, force_error: finalized.append(force_error), + ) + + problems = capture._run_capture_lifecycle(object(), target, target.process) + + assert problems == ["breakpoint_setup_failed"] + assert finalized == [True] + assert target.process.continues == 0 + assert target.process.detaches == 1 + + +def test_repeated_capture_refuses_before_truncating_output(monkeypatch, tmp_path): + output = tmp_path / "events.jsonl" + output.write_text("existing", encoding="utf-8") + monkeypatch.setattr(capture, "_capture_active", True) + + with pytest.raises(RuntimeError, match="capture_already_active"): + capture._initialize_capture_state(b"marker", output) + + assert output.read_text(encoding="utf-8") == "existing" + + +def test_owned_lifecycle_announces_ready_before_synchronous_continue(monkeypatch): + target = _FinishTarget() + debugger = _FinishDebugger(target) + events = [] + monkeypatch.setattr( + capture, + "_install_breakpoints", + lambda _debugger, _target: events.append("installed"), + ) + monkeypatch.setattr( + capture, + "_schedule_interrupt", + lambda _process: events.append("scheduled") or _CancelableTimer(), + ) + monkeypatch.setattr( + target.process, + "Continue", + lambda: events.append("continued"), + ) + monkeypatch.setattr( + capture, + "_finalize_capture", + lambda _target, _process, *, force_error: events.append("finalized"), + ) + monkeypatch.setattr( + target.process, + "Detach", + lambda: events.append("detached"), + ) + + problems = capture._run_capture_lifecycle( + debugger, + target, + target.process, + on_ready=lambda: events.append("ready"), + ) + + assert problems == [] + assert events == [ + "installed", + "scheduled", + "ready", + "continued", + "finalized", + "detached", + ] + assert debugger.async_updates == [False, True] + + +def test_run_command_detaches_before_reporting_setup_error(monkeypatch): + target = _FinishTarget() + debugger = _FinishDebugger(target) + result = _CaptureResult() + monkeypatch.setattr(capture, "_capture_active", False) + + capture.run_capture(debugger, "invalid", result, {}) + + assert target.process.detaches == 1 + assert result.error == "usage: wechat-profile-run MARKER_BASE64 OUTPUT" + + +def test_finish_capture_cleans_up_and_writes_complete_terminal( + monkeypatch, tmp_path +): + output = tmp_path / "events.jsonl" + timer = _CancelableTimer() + target = _FinishTarget() + capture._prepare_output(output) + monkeypatch.setattr(capture, "_output_path", str(output)) + monkeypatch.setattr(capture, "_capture_started_at", 10.0) + monkeypatch.setattr(capture, "_capture_error", False) + monkeypatch.setattr(capture, "_marker_seen", True) + monkeypatch.setattr( + capture, + "_seen_labels", + {"insert", "insert_return", "insert_result_check", "mars_submit"}, + ) + monkeypatch.setattr(capture, "_installed_breakpoint_ids", {8, 4}) + monkeypatch.setattr(capture, "_timeout_timer", timer) + monkeypatch.setattr(capture, "_terminal_written", False) + monkeypatch.setattr(capture.time, "monotonic", lambda: 10.25) + + status = capture._finalize_capture( + target, + target.process, + force_error=False, + ) + + assert timer.cancelled is True + assert target.deleted == [4, 8] + assert status == "complete" + assert output.read_text(encoding="utf-8") == ( + '{"label":"capture_terminal","status":"complete",' + '"missing_labels":[],"pid":321,"duration_ms":250}\n' + ) + + +def test_main_executes_arguments_from_safe_builder(monkeypatch, tmp_path): + expected = ["lldb", "--safe-test-arguments"] + observed = {} + monkeypatch.setattr(capture, "build_lldb_arguments", lambda **_kwargs: expected) + + def fake_execvp(executable, arguments): + observed["executable"] = executable + observed["arguments"] = arguments + + monkeypatch.setattr(capture.os, "execvp", fake_execvp) + + capture.main( + [ + "--pid", + "123", + "--marker", + "WCPROFILE-20260802-01", + "--output", + str(tmp_path / "events.jsonl"), + ] + ) + + assert observed == {"executable": "lldb", "arguments": expected} + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"pid": 1}, "invalid_pid"), + ({"marker": ""}, "invalid_marker"), + ({"output": "events.jsonl"}, "output_must_be_absolute"), + ], +) +def test_lldb_arguments_reject_unsafe_inputs(tmp_path, kwargs, message): + arguments = { + "pid": 123, + "marker": "WCPROFILE-20260802-01", + "output": tmp_path / "events.jsonl", + } + arguments.update(kwargs) + + with pytest.raises(ValueError, match=message): + capture.build_lldb_arguments(**arguments) diff --git a/tests/test_wechat_profile_validation.py b/tests/test_wechat_profile_validation.py new file mode 100644 index 0000000..450875d --- /dev/null +++ b/tests/test_wechat_profile_validation.py @@ -0,0 +1,338 @@ +import copy +import hashlib +import json +from pathlib import Path +import struct +import uuid + +import pytest + +from tools import validate_wechat_profile as validator + + +EXPECTED_UUID = "ABD88EC8-4590-3FDB-AAB5-4E5865B86B2A" +EXPECTED_SHA256 = ( + "b4a740135f3f1e937bca10caf0a95cff986ddf27fa6bb15ccaf64001fc651c93" +) +EXPECTED_ARM64_SLICE_SHA256 = ( + "0e8c932461b883a4e4dc90313a14a30ea69a190fedf1083d014259bec56da2e0" +) + + +def _measured_profile(*, adapter_ready=False): + return { + "schema_version": 2, + "evidence_kind": "read_only_lldb", + "adapter_ready": adapter_ready, + "capture_sha256": "1" * 64, + "marker_sha256": "2" * 64, + "image": { + "path": "/Applications/WeChat.app/Contents/Frameworks/wechat.dylib", + "uuid": EXPECTED_UUID, + "sha256": EXPECTED_SHA256, + "arm64_slice_sha256": EXPECTED_ARM64_SLICE_SHA256, + }, + "runtime_ready": { + "entry_rva": 0x3EF9964, + "signature": "e98202f029b143f9490000b420011fd6", + "ready_value": 1, + }, + "execution_context": { + "thread_name": "coroutine", + "queue_label": "owl.main", + "dispatch_rva": 0x57B30, + "signature": "f85fbca9f65701a9f44f02a9fd7b03a9", + }, + "model": { + "factory_rva": 0x1010, + "factory_signature": "3" * 32, + "recipient_setter_rva": 0x1020, + "recipient_setter_signature": "4" * 32, + "text_setter_rva": 0x1030, + "text_setter_signature": "5" * 32, + "client_id_setter_rva": 0x1040, + "client_id_setter_signature": "6" * 32, + "create_time_setter_rva": 0x1050, + "create_time_setter_signature": "7" * 32, + "ownership": "shared_ptr", + }, + "insert": { + "entry_rva": 0x26E6264, + "signature": "e93c0390294d42f9490000b420011fd6", + "local_id_before": 0, + "local_id_after": 41, + "client_id": 202608020001, + }, + "mars": {"client_id": 202608020001, "submit_count": 1}, + "response": { + "decode_rva": 0x372FD60, + "signature": "8" * 32, + "server_id": 99, + }, + "update": { + "entry_rva": 0x26EA4FC, + "signature": "c93c0390296142f9490000b420011fd6", + "local_id": 41, + "server_id_before": 0, + "server_id_after": 99, + }, + "notification": { + "entry_rva": 0x202906C, + "signature": "9" * 32, + "local_id": 41, + }, + } + + +def test_measured_profile_is_valid_but_cannot_enable_adapter(): + assert validator.validate_profile(_measured_profile()) == ( + "profile_valid_not_ready" + ) + assert validator.validate_profile(_measured_profile(adapter_ready=True)) == ( + "adapter_must_remain_disabled" + ) + + +@pytest.mark.parametrize( + "section", + [ + "image", + "runtime_ready", + "execution_context", + "model", + "insert", + "mars", + "response", + "update", + "notification", + ], +) +def test_profile_rejects_every_incomplete_nested_section(section): + profile = _measured_profile() + profile[section].pop(next(iter(profile[section]))) + + assert validator.validate_profile(profile) == f"invalid_{section}_fields" + + +@pytest.mark.parametrize( + ("mutation", "expected"), + [ + (("mars", "client_id", 202608020002), "client_id_disagreement"), + (("update", "local_id", 42), "local_id_disagreement"), + (("notification", "local_id", 42), "local_id_disagreement"), + (("update", "server_id_after", 100), "server_id_disagreement"), + ], +) +def test_profile_rejects_identifier_disagreement(mutation, expected): + section, field, value = mutation + profile = _measured_profile() + profile[section][field] = value + + assert validator.validate_profile(profile) == expected + + +@pytest.mark.parametrize("unsafe_rva", [0x25CD228, 0x3399194]) +def test_profile_rejects_unsafe_high_level_rvas(unsafe_rva): + profile = _measured_profile() + profile["insert"]["entry_rva"] = unsafe_rva + + assert validator.validate_profile(profile) == "unsafe_rva" + + +def test_profile_rejects_wrong_raw_capture_digest(): + profile = _measured_profile() + + assert validator.validate_profile(profile, raw_capture=b"raw capture") == ( + "capture_digest_mismatch" + ) + + +def _thin_macho(image_uuid): + header = struct.pack( + "<8I", + validator.MACHO_64_MAGIC, + 0, + 0, + 0, + 1, + 24, + 0, + 0, + ) + command = struct.pack( + " 0 + + +def _zero_plain_int(value): + return type(value) is int and value == 0 + + +def _valid_digest(value): + return isinstance(value, str) and _SHA256.fullmatch(value) is not None + + +def _valid_signature(value): + return isinstance(value, str) and _SIGNATURE.fullmatch(value) is not None + + +def validate_profile(profile, *, raw_capture=None): + """Return ``profile_valid_not_ready`` or one stable failure reason.""" + + if not isinstance(profile, dict) or set(profile) != _TOP_LEVEL_FIELDS: + return "invalid_profile_fields" + for section, expected_fields in _SECTION_FIELDS.items(): + value = profile[section] + if not isinstance(value, dict) or set(value) != expected_fields: + return f"invalid_{section}_fields" + + if type(profile["schema_version"]) is not int or profile["schema_version"] != 2: + return "unsupported_schema_version" + if profile["evidence_kind"] != "read_only_lldb": + return "invalid_evidence_kind" + if type(profile["adapter_ready"]) is not bool: + return "invalid_adapter_ready" + if profile["adapter_ready"]: + return "adapter_must_remain_disabled" + + if not _valid_digest(profile["capture_sha256"]): + return "invalid_capture_sha256" + if raw_capture is not None: + if type(raw_capture) is not bytes: + return "invalid_raw_capture" + if hashlib.sha256(raw_capture).hexdigest() != profile["capture_sha256"]: + return "capture_digest_mismatch" + if not _valid_digest(profile["marker_sha256"]): + return "invalid_marker_sha256" + + image = profile["image"] + if ( + image["path"] != EXPECTED_IMAGE_PATH + or image["uuid"] != EXPECTED_UUID + or image["sha256"] != EXPECTED_SHA256 + or image["arm64_slice_sha256"] != EXPECTED_ARM64_SLICE_SHA256 + ): + return "unsupported_wechat_build" + + runtime_ready = profile["runtime_ready"] + if type(runtime_ready["ready_value"]) is not int or runtime_ready["ready_value"] != 1: + return "runtime_not_ready" + + context = profile["execution_context"] + if not isinstance(context["thread_name"], str) or not context[ + "thread_name" + ].strip(): + return "invalid_execution_context" + if not isinstance(context["queue_label"], str) or not context[ + "queue_label" + ].strip(): + return "invalid_execution_context" + + model = profile["model"] + if model["ownership"] != "shared_ptr": + return "invalid_model_ownership" + + for _, _, section, rva_field, signature_field in _MEASURED_ENTRIES: + rva = profile[section][rva_field] + if not _positive_plain_int(rva) or rva >= _MAX_RVA: + return "invalid_rva" + if rva in _UNSAFE_RVAS: + return "unsafe_rva" + if not _valid_signature(profile[section][signature_field]): + return "invalid_signature" + + insert = profile["insert"] + mars = profile["mars"] + response = profile["response"] + update = profile["update"] + notification = profile["notification"] + + if not _zero_plain_int(insert["local_id_before"]): + return "invalid_local_id_baseline" + if not _zero_plain_int(update["server_id_before"]): + return "invalid_server_id_baseline" + + if not _positive_plain_int(insert["client_id"]) or not _positive_plain_int( + mars["client_id"] + ): + return "invalid_client_id" + if insert["client_id"] != mars["client_id"]: + return "client_id_disagreement" + if type(mars["submit_count"]) is not int or mars["submit_count"] != 1: + return "invalid_mars_submit_count" + + local_ids = ( + insert["local_id_after"], + update["local_id"], + notification["local_id"], + ) + if not all(_positive_plain_int(value) for value in local_ids): + return "invalid_local_id" + if len(set(local_ids)) != 1: + return "local_id_disagreement" + + server_ids = (response["server_id"], update["server_id_after"]) + if not all(_positive_plain_int(value) for value in server_ids): + return "invalid_server_id" + if len(set(server_ids)) != 1: + return "server_id_disagreement" + + return "profile_valid_not_ready" + + +def _jsonl_records(raw_capture): + if type(raw_capture) is not bytes or not raw_capture: + raise ValueError("invalid_raw_capture") + try: + decoded = raw_capture.decode("utf-8") + except UnicodeDecodeError as error: + raise ValueError("invalid_raw_capture_encoding") from error + lines = decoded.splitlines() + if not lines or any(not line for line in lines): + raise ValueError("invalid_jsonl") + records = [] + for line in lines: + try: + record = json.loads( + line, + parse_constant=lambda _value: (_ for _ in ()).throw( + ValueError("invalid_json_constant") + ), + ) + except (json.JSONDecodeError, ValueError) as error: + raise ValueError("invalid_jsonl") from error + if not isinstance(record, dict): + raise ValueError("invalid_jsonl_record") + records.append(record) + return records + + +def _copy_profile_fields(observed): + if not isinstance(observed, dict): + raise ValueError("invalid_profile_observation") + profile = { + key: observed[key] + for key in _TOP_LEVEL_FIELDS - frozenset(("image",)) + if key in observed + } + for section, fields in _SECTION_FIELDS.items(): + if section == "image" or section not in observed: + continue + value = observed[section] + if not isinstance(value, dict): + profile[section] = value + continue + profile[section] = {key: value[key] for key in fields if key in value} + return profile + + +def _contains_string_fragment(value, fragment): + if isinstance(value, str): + return fragment in value + if isinstance(value, dict): + return any( + _contains_string_fragment(item, fragment) for item in value.values() + ) + if isinstance(value, (list, tuple)): + return any(_contains_string_fragment(item, fragment) for item in value) + return False + + +def sanitize_capture(raw_capture, *, image_path, arm64_slice_path, marker): + """Return a schema-v2 profile reconstructed without raw capture secrets.""" + + if not isinstance(marker, str) or not marker: + raise ValueError("invalid_marker") + records = _jsonl_records(raw_capture) + observations = [ + record for record in records if record.get("label") == "profile_observation" + ] + if len(observations) != 1 or "profile" not in observations[0]: + raise ValueError("invalid_profile_observation_count") + + image = Path(image_path) + arm64_slice = Path(arm64_slice_path) + if str(image) != EXPECTED_IMAGE_PATH: + raise ValueError("unsupported_wechat_build") + try: + image_sha256 = hashlib.sha256(image.read_bytes()).hexdigest() + slice_bytes = arm64_slice.read_bytes() + except OSError as error: + raise ValueError("unreadable_wechat_image") from error + slice_sha256 = hashlib.sha256(slice_bytes).hexdigest() + image_uuid = _macho_uuid_bytes(slice_bytes) + if ( + image_sha256 != EXPECTED_SHA256 + or slice_sha256 != EXPECTED_ARM64_SLICE_SHA256 + or image_uuid != EXPECTED_UUID + ): + raise ValueError("unsupported_wechat_build") + + profile = _copy_profile_fields(observations[0]["profile"]) + profile["capture_sha256"] = hashlib.sha256(raw_capture).hexdigest() + profile["marker_sha256"] = hashlib.sha256(marker.encode("utf-8")).hexdigest() + profile["image"] = { + "path": str(image), + "uuid": image_uuid, + "sha256": image_sha256, + "arm64_slice_sha256": slice_sha256, + } + if _contains_string_fragment(profile, marker): + raise ValueError("marker_text_in_profile") + result = validate_profile(profile, raw_capture=raw_capture) + if result != "profile_valid_not_ready": + raise ValueError(result) + return profile + + +def _signature_initializer(signature): + values = ", ".join(f"0x{byte:02x}" for byte in bytes.fromhex(signature)) + return "{" + values + "}" + + +def render_header(profile): + """Render every measured RVA/signature while keeping Phase 1 disabled.""" + + result = validate_profile(profile) + if result != "profile_valid_not_ready": + raise ValueError(result) + lines = [ + "#pragma once", + "", + "#include ", + "#include ", + "", + "namespace wechat_bridge::profile_4_1_8_28 {", + "", + "inline constexpr bool kAdapterReady = false;", + f'inline constexpr char kImageUuid[] = "{profile["image"]["uuid"]}";', + f'inline constexpr char kImageSha256[] = "{profile["image"]["sha256"]}";', + "inline constexpr char kArm64SliceSha256[] = " + f'"{profile["image"]["arm64_slice_sha256"]}";', + ] + for rva_name, signature_name, section, rva_field, signature_field in ( + _MEASURED_ENTRIES + ): + lines.extend( + ( + f"inline constexpr std::uintptr_t {rva_name} = " + f"0x{profile[section][rva_field]:x};", + f"inline constexpr std::array {signature_name} =", + f" {_signature_initializer(profile[section][signature_field])};", + ) + ) + lines.extend(("", "} // namespace wechat_bridge::profile_4_1_8_28", "")) + return "\n".join(lines) + + +def render_manifest(profile, *, dylib_name, dylib_sha256): + """Render the complete-but-disabled Phase 1 runtime manifest.""" + + result = validate_profile(profile) + if result != "profile_valid_not_ready": + raise ValueError(result) + if dylib_name != EXPECTED_BRIDGE_DYLIB_NAME: + raise ValueError("invalid_dylib_name") + if not _valid_digest(dylib_sha256): + raise ValueError("invalid_dylib_sha256") + return { + "version": 1, + "profile_complete": True, + "adapter_ready": False, + "capture_sha256": profile["capture_sha256"], + "image_uuid": profile["image"]["uuid"], + "image_sha256": profile["image"]["sha256"], + "dylib": dylib_name, + "dylib_sha256": dylib_sha256, + } + + +def _macho_uuid_bytes(data): + if len(data) < 32: + raise ValueError("invalid_macho") + magic, _, _, _, command_count, command_bytes, _, _ = struct.unpack_from( + "<8I", data, 0 + ) + if magic != MACHO_64_MAGIC: + raise ValueError("unsupported_macho") + if command_bytes > len(data) - 32: + raise ValueError("truncated_load_commands") + if command_count > command_bytes // 8: + raise ValueError("invalid_load_commands") + + offset = 32 + commands_end = offset + command_bytes + image_uuid = None + for _ in range(command_count): + if offset + 8 > commands_end: + raise ValueError("truncated_load_commands") + command, size = struct.unpack_from("<2I", data, offset) + if size < 8 or size % 8 != 0 or offset + size > commands_end: + raise ValueError("invalid_load_command") + if command == LC_UUID: + if size != 24: + raise ValueError("invalid_uuid_command") + if image_uuid is not None: + raise ValueError("duplicate_macho_uuid") + image_uuid = str(uuid.UUID(bytes=data[offset + 8 : offset + 24])).upper() + offset += size + if offset != commands_end: + raise ValueError("invalid_load_commands") + if image_uuid is None: + raise ValueError("missing_macho_uuid") + return image_uuid + + +def _macho_uuid(path): + return _macho_uuid_bytes(Path(path).read_bytes()) + + +def _write_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--raw-capture", required=True, type=Path) + parser.add_argument("--image", required=True, type=Path) + parser.add_argument("--arm64-slice", required=True, type=Path) + parser.add_argument("--marker", required=True) + parser.add_argument("--fixture", required=True, type=Path) + parser.add_argument("--header", required=True, type=Path) + parser.add_argument("--manifest", required=True, type=Path) + parser.add_argument("--bridge", required=True, type=Path) + args = parser.parse_args(argv) + + try: + profile = sanitize_capture( + args.raw_capture.read_bytes(), + image_path=args.image, + arm64_slice_path=args.arm64_slice, + marker=args.marker, + ) + bridge_sha256 = hashlib.sha256(args.bridge.read_bytes()).hexdigest() + manifest = render_manifest( + profile, + dylib_name=args.bridge.name, + dylib_sha256=bridge_sha256, + ) + except (OSError, ValueError) as error: + print(str(error)) + return 1 + + args.header.parent.mkdir(parents=True, exist_ok=True) + _write_json(args.fixture, profile) + args.header.write_text(render_header(profile), encoding="utf-8") + _write_json(args.manifest, manifest) + print("profile_valid_not_ready") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/wechat_profile_capture.py b/tools/wechat_profile_capture.py new file mode 100644 index 0000000..3f5d734 --- /dev/null +++ b/tools/wechat_profile_capture.py @@ -0,0 +1,683 @@ +#!/usr/bin/env python3 +"""Read-only LLDB capture for one user-performed WeChat marker send. + +Run normally to attach LLDB. The imported command uses hardware breakpoints, +records candidate calls, and auto-continues. It never calls a WeChat function. +""" + +from __future__ import annotations + +import argparse +import base64 +from dataclasses import dataclass +import json +import os +from pathlib import Path +import shlex +import stat +import struct +import threading +import time + + +ACTIVE_WINDOW_SECONDS = 20.0 +CAPTURE_TIMEOUT_SECONDS = 120.0 +_SNAPSHOT_POINTER_REGISTERS = ( + "x0", + "x1", + "x2", + "x3", + "x8", + "x9", + "x19", + "x20", + "x29", + "sp", +) +_REQUIRED_CAPTURE_LABELS = frozenset( + ("insert", "insert_return", "insert_result_check", "mars_submit") +) +EXPECTED_IMAGE_UUID = "ABD88EC8-4590-3FDB-AAB5-4E5865B86B2A" +_EXPECTED_PROBES = { + "insert": (0x26E620C, "e93c0390294942f9490000b420011fd6"), + "insert_return": (0x253B85C, "e8e30091e0030b91a14303d15f020094"), + "insert_result_check": ( + 0x253B904, + "09cd40b9692b0034890240f9f43f41f9", + ), + "mars_submit": (0x498D2E0, "ff0305d1fc6f11a9f44f12a9fd7b13a9"), +} + + +@dataclass(frozen=True) +class Probe: + label: str + rva: int + + +@dataclass(frozen=True) +class ProbePhase: + name: str + probes: tuple[Probe, ...] + + +def build_probe_phases(): + return ( + ProbePhase( + "message_transaction", + tuple( + Probe(label, rva) + for label, (rva, _signature) in _EXPECTED_PROBES.items() + ), + ), + ) + + +def _probe_rvas(): + return { + probe.label: probe.rva + for phase in build_probe_phases() + for probe in phase.probes + } + + +def _unresolved_probe_labels(): + return sorted(label for label, rva in _probe_rvas().items() if rva == 0) + + +def build_lldb_arguments(*, pid, marker, output): + if type(pid) is not int or pid <= 1: + raise ValueError("invalid_pid") + if not isinstance(marker, str): + raise ValueError("invalid_marker") + marker_bytes = marker.encode("utf-8") + if not marker_bytes or len(marker_bytes) > 256: + raise ValueError("invalid_marker") + output = Path(output) + if not output.is_absolute(): + raise ValueError("output_must_be_absolute") + + marker_base64 = base64.b64encode(marker_bytes).decode("ascii") + script = Path(__file__).resolve() + start_command = " ".join( + ( + "wechat-profile-run", + marker_base64, + shlex.quote(str(output)), + ) + ) + return [ + "lldb", + "--no-lldbinit", + "--batch", + "-p", + str(pid), + "-o", + f"command script import {shlex.quote(str(script))}", + "-o", + start_command, + ] + +_marker = b"" +_output_path = "" +_wechat_base = 0 +_active_until = 0.0 +_breakpoint_labels = {} +_installed_breakpoint_ids = set() +_seen_labels = set() +_marker_seen = False +_capture_error = False +_capture_started_at = 0.0 +_capture_deadline = 0.0 +_timeout_timer = None +_tracked_thread_id = 0 +_tracked_model_pointer = 0 +_tracked_insert_sret_pointer = 0 +_terminal_written = False +_capture_active = False + + +def _open_capture_output(path, flags): + flags |= getattr(os, "O_NOFOLLOW", 0) + descriptor = os.open(os.fspath(path), flags, 0o600) + try: + info = os.fstat(descriptor) + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid(): + raise OSError("unsafe_capture_output") + os.fchmod(descriptor, 0o600) + except Exception: + os.close(descriptor) + raise + return descriptor + + +def _prepare_output(path): + descriptor = _open_capture_output( + path, + os.O_WRONLY | os.O_CREAT | os.O_TRUNC, + ) + os.close(descriptor) + + +def _append_record(record): + descriptor = _open_capture_output(_output_path, os.O_WRONLY | os.O_APPEND) + with os.fdopen(descriptor, "a", encoding="utf-8") as output: + output.write(json.dumps(record, separators=(",", ":")) + "\n") + + +def _command_output_path(value): + path = Path(value).expanduser() + if not path.is_absolute(): + raise ValueError("output_must_be_absolute") + return path + + +def _read(process, address, size): + if not address: + return None + import lldb + + error = lldb.SBError() + data = process.ReadMemory(address, size, error) + return bytes(data) if error.Success() else None + + +def _register(frame, name): + return frame.FindRegister(name).GetValueAsUnsigned() + + +def _semantic_region_addresses(frame, label): + if label == "insert_return": + return { + "model": _tracked_model_pointer, + "insert_sret": _tracked_insert_sret_pointer, + } + if label == "insert_result_check": + result = _register(frame, "x8") + if not 0x100000000 <= result < 0x800000000000: + result = 0 + return { + "model": _tracked_model_pointer, + "insert_result": result, + } + return {} + + +def _mars_task_command(frame): + task = _register(frame, "x1") + raw_command = _read(frame.GetThread().GetProcess(), task + 4, 4) + return struct.unpack("= _capture_deadline: + return False + address, depth = pending.pop(0) + if address in visited or not 0x100000000 <= address < 0x800000000000: + continue + visited.add(address) + data = _read(process, address, 0x800) + if data is None: + continue + if _marker in data: + return True + if depth >= 4: + continue + for offset in range(0, min(len(data), 0x500) - 7, 8): + pointer = struct.unpack_from("= _capture_deadline: + return True + try: + breakpoint_id = breakpoint_location.GetBreakpoint().GetID() + label = _breakpoint_labels.get(breakpoint_id) + if label is None: + return False + now = time.monotonic() + if not _marker_seen: + if not _can_arm_from_marker(label) or not _marker_reachable(frame): + return time.monotonic() >= _capture_deadline + thread_id = frame.GetThread().GetThreadID() + model_pointer = _model_pointer(frame, label) + if not thread_id or not model_pointer: + raise RuntimeError("invalid_marker_identity") + _snapshot(frame, label, True) + _tracked_thread_id = thread_id + _tracked_model_pointer = model_pointer + _marker_seen = True + _active_until = min( + now + ACTIVE_WINDOW_SECONDS, + _capture_deadline, + ) + if label == "insert": + _tracked_insert_sret_pointer = _register(frame, "x8") + _record_seen_label(label) + elif now <= _active_until and _event_is_correlated(frame, label): + _snapshot(frame, label, False) + if label == "insert": + _tracked_insert_sret_pointer = _register(frame, "x8") + _record_seen_label(label) + except Exception: + _capture_error = True + return bool(_capture_deadline and time.monotonic() >= _capture_deadline) + + +def _find_wechat_module(target): + for module in target.module_iter(): + if module.GetFileSpec().GetFilename() != "wechat.dylib": + continue + address = module.GetObjectFileHeaderAddress().GetLoadAddress(target) + if address not in (0, -1): + return module, address + return None, 0 + + +def _validate_runtime_image(target, process, module, image_base): + if module.GetUUIDString().upper() != EXPECTED_IMAGE_UUID: + raise RuntimeError("unsupported_wechat_image_uuid") + architecture = target.GetTriple().split("-", 1)[0] + if architecture != "arm64": + raise RuntimeError("unsupported_wechat_architecture") + for label, (rva, signature) in _EXPECTED_PROBES.items(): + if _read(process, image_base + rva, 16) != bytes.fromhex(signature): + raise RuntimeError(f"signature_mismatch:{label}") + + +def _interrupt_process(process): + global _capture_error + try: + interrupted = process.SendAsyncInterrupt() + if interrupted is False: + process.Stop() + except Exception: + try: + process.Stop() + except Exception: + _capture_error = True + + +def _schedule_interrupt(process): + remaining = _capture_deadline - time.monotonic() + if remaining <= 0: + raise RuntimeError("capture_deadline_expired") + timer = threading.Timer( + remaining, + _interrupt_process, + args=(process,), + ) + timer.daemon = True + timer.start() + return timer + + +def _delete_installed_breakpoints(target): + problems = [] + try: + for breakpoint_id in sorted(_installed_breakpoint_ids): + try: + if target.BreakpointDelete(breakpoint_id) is False: + problems.append(f"breakpoint_delete_failed:{breakpoint_id}") + except Exception: + problems.append(f"breakpoint_delete_failed:{breakpoint_id}") + finally: + _installed_breakpoint_ids.clear() + _breakpoint_labels.clear() + return problems + + +def _cancel_timeout(): + global _timeout_timer + timer = _timeout_timer + _timeout_timer = None + if timer is None: + return [] + problems = [] + try: + timer.cancel() + join = getattr(timer, "join", None) + if join is not None and timer is not threading.current_thread(): + join(timeout=1.0) + except Exception: + problems.append("timeout_cleanup_failed") + return problems + + +def _sb_error_message(error, operation): + if error is None: + return None + fail = getattr(error, "Fail", None) + if fail is None or not fail(): + return None + get_message = getattr(error, "GetCString", None) + message = get_message() if get_message is not None else None + return f"{operation}_failed" + (f":{message}" if message else "") + + +def _install_breakpoints(debugger, target): + global _wechat_base + process = target.GetProcess() + module, image_base = _find_wechat_module(target) + if module is None or not image_base: + raise RuntimeError("wechat.dylib_not_loaded") + _validate_runtime_image(target, process, module, image_base) + _wechat_base = image_base + probes = _probe_rvas() + if len(probes) > 4: + raise RuntimeError("too_many_hardware_breakpoints") + callback = f"{__name__}.capture_breakpoint" + import lldb + + interpreter = debugger.GetCommandInterpreter() + for label, rva in probes.items(): + before = target.GetNumBreakpoints() + command_result = lldb.SBCommandReturnObject() + interpreter.HandleCommand( + f"breakpoint set --hardware --address {image_base + rva:#x}", + command_result, + ) + if not command_result.Succeeded() or target.GetNumBreakpoints() != before + 1: + raise RuntimeError(f"hardware_breakpoint_unavailable:{label}") + breakpoint = target.GetBreakpointAtIndex(before) + _installed_breakpoint_ids.add(breakpoint.GetID()) + callback_error = breakpoint.SetScriptCallbackFunction(callback) + callback_problem = _sb_error_message( + callback_error, + f"breakpoint_callback:{label}", + ) + if callback_problem: + raise RuntimeError(callback_problem) + _breakpoint_labels[breakpoint.GetID()] = label + + +def _finalize_capture(target, process, *, force_error): + global _capture_error, _terminal_written + cleanup_problems = _cancel_timeout() + cleanup_problems.extend(_delete_installed_breakpoints(target)) + if cleanup_problems: + _capture_error = True + missing = sorted(_REQUIRED_CAPTURE_LABELS - _seen_labels) + if force_error or _capture_error: + status = "error" + elif _marker_seen and not missing: + status = "complete" + else: + status = "incomplete" + if _terminal_written: + return status + duration_ms = max(0, int((time.monotonic() - _capture_started_at) * 1000)) + terminal = { + "label": "capture_terminal", + "status": status, + "missing_labels": missing, + "pid": process.GetProcessID(), + "duration_ms": duration_ms, + } + _append_record(terminal) + _terminal_written = True + return status + + +def _run_capture_lifecycle(debugger, target, process, *, on_ready=None): + global _capture_active, _timeout_timer + problems = [] + previous_async = None + try: + get_async = getattr(debugger, "GetAsync", None) + set_async = getattr(debugger, "SetAsync", None) + if get_async is not None and set_async is not None: + previous_async = get_async() + set_async(False) + _install_breakpoints(debugger, target) + _timeout_timer = _schedule_interrupt(process) + if on_ready is not None: + on_ready() + continue_problem = _sb_error_message(process.Continue(), "continue") + if continue_problem: + problems.append(continue_problem) + except Exception as error: + problems.append(str(error) or type(error).__name__) + finally: + try: + _finalize_capture(target, process, force_error=bool(problems)) + except Exception as error: + problems.append(f"finalize_failed:{error}") + try: + detach_problem = _sb_error_message(process.Detach(), "detach") + if detach_problem: + problems.append(detach_problem) + except Exception as error: + problems.append(f"detach_failed:{error}") + if previous_async is not None: + try: + debugger.SetAsync(previous_async) + except Exception as error: + problems.append(f"async_restore_failed:{error}") + _capture_active = False + return problems + + +def _initialize_capture_state(marker, output_path): + global _marker, _output_path, _active_until, _capture_active + global _capture_deadline, _capture_error, _capture_started_at + global _marker_seen, _terminal_written, _tracked_model_pointer + global _tracked_insert_sret_pointer, _tracked_thread_id + if _capture_active: + raise RuntimeError("capture_already_active") + _prepare_output(output_path) + now = time.monotonic() + _marker = marker + _output_path = str(output_path) + _active_until = 0.0 + _capture_deadline = now + CAPTURE_TIMEOUT_SECONDS + _capture_error = False + _capture_started_at = now + _marker_seen = False + _terminal_written = False + _tracked_model_pointer = 0 + _tracked_insert_sret_pointer = 0 + _tracked_thread_id = 0 + _seen_labels.clear() + _breakpoint_labels.clear() + _installed_breakpoint_ids.clear() + _capture_active = True + + +def _detach_after_command_error(process): + try: + return _sb_error_message(process.Detach(), "detach") + except Exception as error: + return f"detach_failed:{error}" + + +def run_capture(debugger, command, result, _internal_dict): + target = debugger.GetSelectedTarget() + process = target.GetProcess() + try: + parts = shlex.split(command) + if len(parts) != 2: + raise ValueError("usage: wechat-profile-run MARKER_BASE64 OUTPUT") + marker = base64.b64decode(parts[0], validate=True) + if not marker or len(marker) > 256: + raise ValueError("marker_must_be_1_to_256_bytes") + unresolved = _unresolved_probe_labels() + if unresolved: + raise RuntimeError("unresolved_probe_rvas:" + ",".join(unresolved)) + output_path = _command_output_path(parts[1]) + _initialize_capture_state(marker, output_path) + except Exception as error: + problem = str(error) or type(error).__name__ + detach_problem = _detach_after_command_error(process) + if detach_problem: + problem += f";{detach_problem}" + result.SetError(problem) + return + problems = _run_capture_lifecycle( + debugger, + target, + process, + on_ready=lambda: print( + f"wechat_profile_ready base={_wechat_base:#x} output={_output_path}", + flush=True, + ), + ) + if problems: + result.SetError(";".join(problems)) + return + missing = sorted(_REQUIRED_CAPTURE_LABELS - _seen_labels) + status = "complete" if _marker_seen and not missing else "incomplete" + result.AppendMessage(f"capture_{status}") + + +def __lldb_init_module(debugger, _internal_dict): + debugger.HandleCommand( + f"command script add -f {__name__}.run_capture wechat-profile-run" + ) + + +def main(argv=None): + parser = argparse.ArgumentParser() + parser.add_argument("--pid", required=True, type=int) + parser.add_argument("--marker", required=True) + parser.add_argument( + "--output", + type=Path, + default=Path("/tmp/wechat-local-store-events.jsonl"), + ) + args = parser.parse_args(argv) + lldb_args = build_lldb_arguments( + pid=args.pid, + marker=args.marker, + output=args.output.resolve(), + ) + os.execvp(lldb_args[0], lldb_args) + + +if __name__ == "__main__": + main() diff --git a/wechat_cli/bin/libwechat_official_task_bridge_v2.dylib b/wechat_cli/bin/libwechat_official_task_bridge_v2.dylib new file mode 100755 index 0000000..781e17a Binary files /dev/null and b/wechat_cli/bin/libwechat_official_task_bridge_v2.dylib differ diff --git a/wechat_cli/bin/wechat_send_injector b/wechat_cli/bin/wechat_send_injector new file mode 100755 index 0000000..f2ca8cc Binary files /dev/null and b/wechat_cli/bin/wechat_send_injector differ diff --git a/wechat_cli/commands/send.py b/wechat_cli/commands/send.py new file mode 100644 index 0000000..e5ec9c5 --- /dev/null +++ b/wechat_cli/commands/send.py @@ -0,0 +1,130 @@ +"""send 命令 — 向精确解析出的群聊发送文本。""" + +import math + +import click + +from ..core.sending import ( + SEND_INIT_ERROR_META_KEY, + SendError, + SendInputError, + SendRequest, + SendStatus, + SendUnknownError, + SendUnavailableError, + get_send_service, + resolve_send_group, + validate_send_result, +) +from ..output.formatter import output + + +def _emit_error(error, fmt): + if fmt == "json": + payload = { + "success": False, + "status": error.status, + "error": str(error), + } + if error.exit_code == 4: + payload["auto_retry"] = False + output(payload, "json") + else: + click.echo(f"发送失败:{error}", err=True) + + +def _emit_unknown_result(result, fmt): + if fmt == "json": + payload = result.as_dict() + payload["auto_retry"] = False + output(payload, "json") + else: + click.echo( + f"发送结果未知,禁止自动重试(request_id: {result.request_id})", + err=True, + ) + + +def _emit_success(result, fmt): + if fmt == "json": + output(result.as_dict(), "json") + else: + message = ( + f"服务器已接受发往“{result.group}”的消息" + f"(request_id: {result.request_id})" + ) + output( + message, + "text", + ) + + +@click.command("send") +@click.argument("group") +@click.argument("text") +@click.option( + "--timeout", + default=15.0, + type=float, + show_default=True, + help="发送超时秒数(必须大于 0)", +) +@click.option( + "--format", + "fmt", + default="json", + type=click.Choice(["json", "text"]), + help="输出格式", +) +@click.pass_context +def send(ctx, group, text, timeout, fmt): + """向唯一精确匹配的群聊 GROUP 发送纯文本 TEXT。 + + \b + 示例: + wechat-cli send "AI交流群" "大家好" + wechat-cli send "123456@chatroom" "第一行\\n第二行" --timeout 15 + wechat-cli send "AI交流群" "大家好 👋" --format text + """ + app = ctx.obj + + try: + if not text.strip(): + raise SendInputError("TEXT 不能为空或仅包含空白") + if not math.isfinite(timeout) or timeout <= 0: + raise SendInputError("timeout 必须为正数") + + init_error = ctx.meta.get(SEND_INIT_ERROR_META_KEY) + if init_error is not None: + raise SendUnavailableError( + f"发送前环境不可用: {init_error};未发送任何消息" + ) + + target = resolve_send_group(group, app.cache, app.decrypted_dir) + request = SendRequest( + group=target.group, + username=target.username, + text=text, + timeout=timeout, + ) + result = validate_send_result( + get_send_service(app).send_text(request), + request, + ) + except SendError as error: + _emit_error(error, fmt) + ctx.exit(int(error.exit_code)) + except Exception as error: + unknown = SendUnknownError(f"发送结果无法确认,禁止自动重试: {error}") + _emit_error(unknown, fmt) + ctx.exit(int(unknown.exit_code)) + + if result.status == SendStatus.UNKNOWN or not result.success: + _emit_unknown_result(result, fmt) + ctx.exit(4) + if result.status != SendStatus.SERVER_ACCEPTED: + unknown = SendUnknownError("发送服务返回未知状态,禁止自动重试") + _emit_error(unknown, fmt) + ctx.exit(int(unknown.exit_code)) + + _emit_success(result, fmt) diff --git a/wechat_cli/core/context.py b/wechat_cli/core/context.py index f8cc412..b3bff3a 100644 --- a/wechat_cli/core/context.py +++ b/wechat_cli/core/context.py @@ -33,6 +33,33 @@ def __init__(self, config_path=None): self.msg_db_keys = find_msg_db_keys(self.all_keys) + # 发送服务只在 send 真正调用时执行严格预检和按需注入。 + from .contacts import get_self_username + from .messages import _find_msg_tables_for_user + from .native_bridge_provider import prepare_native_bridge + from .native_sending import NativeSendService + from .send_confirmation import MessageConfirmationStore + from .send_preflight import verify_pre_injection + + confirmation_store = MessageConfirmationStore( + lambda username: _find_msg_tables_for_user( + username, + self.msg_db_keys, + self.cache, + ) + ) + self.send_service = NativeSendService( + app_context=self, + preflight=verify_pre_injection, + self_username_loader=lambda app: get_self_username( + app.db_dir, + app.cache, + app.decrypted_dir, + ), + confirmation_store=confirmation_store, + bridge_provider=prepare_native_bridge, + ) + # 确保状态目录存在 os.makedirs(STATE_DIR, exist_ok=True) diff --git a/wechat_cli/core/native_bridge_provider.py b/wechat_cli/core/native_bridge_provider.py new file mode 100644 index 0000000..c9a13a1 --- /dev/null +++ b/wechat_cli/core/native_bridge_provider.py @@ -0,0 +1,170 @@ +"""按需加载并复用当前 WeChat PID 的原生发送 bridge。""" + +from __future__ import annotations + +import os +from pathlib import Path +import pwd +import re +import stat +import subprocess +import time + +from .send_bridge import AuthenticatedUnixBridgeClient, read_bridge_metadata +from .sending import SendUnavailableError + + +INJECT_TIMEOUT_SECONDS = 3.0 +READY_TIMEOUT_SECONDS = 3.0 +_STARTUP_REASON = re.compile(r"[a-z0-9_]{1,128}") +BRIDGE_INSTANCE_TAG = "official-task-v1" +BRIDGE_DYLIB_NAME = "libwechat_official_task_bridge_v2.dylib" + + +def _package_binary(name: str) -> Path: + return Path(__file__).resolve().parents[1] / "bin" / name + + +def _safe_bridge_directory() -> Path: + try: + home = Path(pwd.getpwuid(os.geteuid()).pw_dir) + except (KeyError, OSError) as error: + raise SendUnavailableError( + "无法确定当前用户目录,未发送任何消息" + ) from error + directory = ( + home + / "Library" + / "Containers" + / "com.tencent.xinWeChat" + / "Data" + / "wcb" + ) + try: + directory.mkdir(mode=0o700, exist_ok=True) + metadata = directory.lstat() + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != os.geteuid() + or directory.resolve(strict=True) != directory + ): + raise OSError("unsafe bridge directory") + os.chmod(directory, 0o700) + except (OSError, RuntimeError) as error: + raise SendUnavailableError( + "bridge 安全目录不可用,未发送任何消息" + ) from error + return directory + + +def _usable_metadata(path: Path, pid: int) -> bool: + try: + read_bridge_metadata(path, pid) + return True + except SendUnavailableError: + return False + + +def _read_bridge_startup_error(path: Path, pid: int) -> str | None: + path = Path(path) + if path.name != f"bridge-{BRIDGE_INSTANCE_TAG}-{pid}.error": + return None + descriptor = None + try: + descriptor = os.open( + path, + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0), + ) + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o600 + or metadata.st_uid != os.geteuid() + or metadata.st_nlink != 1 + or not 1 <= metadata.st_size <= 256 + ): + return None + raw = os.read(descriptor, 257) + if len(raw) != metadata.st_size: + return None + reason = raw.decode("ascii", "strict").strip() + return reason if _STARTUP_REASON.fullmatch(reason) else None + except (OSError, UnicodeError, ValueError): + return None + finally: + if descriptor is not None: + os.close(descriptor) + + +def _bridge_paths(directory: Path, pid: int) -> tuple[Path, Path]: + stem = f"bridge-{BRIDGE_INSTANCE_TAG}-{pid}" + return directory / f"{stem}.json", directory / f"{stem}.error" + + +def prepare_native_bridge(profile, request_id: str): + """首次发送时注入一次;同一 PID 后续直接复用现有 socket。""" + + pid = getattr(profile, "pid", None) + if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 1: + raise SendUnavailableError( + "WeChat PID 无效,未发送任何消息" + ) + directory = _safe_bridge_directory() + metadata_path, startup_error_path = _bridge_paths(directory, pid) + + if not _usable_metadata(metadata_path, pid): + if metadata_path.exists(): + raise SendUnavailableError( + "当前 PID 的 bridge 凭据异常,未发送任何消息" + ) + injector = _package_binary("wechat_send_injector") + bridge_dylib = _package_binary(BRIDGE_DYLIB_NAME) + try: + for binary in (injector, bridge_dylib): + info = binary.lstat() + if ( + not stat.S_ISREG(info.st_mode) + or info.st_uid not in (0, os.geteuid()) + or info.st_mode & 0o022 + ): + raise OSError("unsafe packaged native binary") + result = subprocess.run( + [str(injector), str(pid), str(bridge_dylib)], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + timeout=INJECT_TIMEOUT_SECONDS, + check=False, + shell=False, + text=True, + ) + except (OSError, subprocess.SubprocessError) as error: + raise SendUnavailableError( + "无法加载发送 bridge,未发送任何消息" + ) from error + if result.returncode != 0: + detail = (result.stderr or "").strip().splitlines() + reason = detail[-1] if detail else "native_injector_failed" + raise SendUnavailableError( + f"无法加载发送 bridge({reason}),未发送任何消息" + ) + + deadline = time.monotonic() + READY_TIMEOUT_SECONDS + while time.monotonic() < deadline: + if _usable_metadata(metadata_path, pid): + break + time.sleep(0.05) + else: + reason = _read_bridge_startup_error(startup_error_path, pid) + detail = f"({reason})" if reason else "" + raise SendUnavailableError( + f"发送 bridge 启动失败{detail},未发送任何消息" + ) + + return AuthenticatedUnixBridgeClient( + pid=pid, + metadata_path=metadata_path, + request_id_factory=lambda: request_id, + ) diff --git a/wechat_cli/core/native_sending.py b/wechat_cli/core/native_sending.py new file mode 100644 index 0000000..9fa7faf --- /dev/null +++ b/wechat_cli/core/native_sending.py @@ -0,0 +1,233 @@ +"""严格单次提交的原生发送事务编排。""" + +from dataclasses import replace +import math +import time +import uuid + +from .send_bridge import BridgeAckState, BridgeReceipt +from .send_confirmation import ( + MessageBaseline, + MessageConfirmation, + MessageConfirmationUnavailable, +) +from .sending import ( + SendRequest, + SendResult, + SendUnavailableError, + SendUnknownError, +) + + +def _default_request_id(): + return str(uuid.uuid4()) + + +def _valid_request_id(value): + if type(value) is not str: + return False + try: + parsed = uuid.UUID(value) + except (ValueError, AttributeError): + return False + return str(parsed) == value.lower() + + +def _positive_id(value): + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + return None + return value + + +class NativeSendService: + """把预检、单次 bridge 提交、ACK 与数据库确认组合成一个事务。""" + + def __init__( + self, + *, + app_context, + preflight, + self_username_loader, + confirmation_store, + bridge_provider, + request_id_factory=_default_request_id, + monotonic=time.monotonic, + ): + self._app_context = app_context + self._preflight = preflight + self._self_username_loader = self_username_loader + self._confirmation_store = confirmation_store + self._bridge_provider = bridge_provider + self._request_id_factory = request_id_factory + self._monotonic = monotonic + + @staticmethod + def _unknown(request, request_id, receipt=None): + return SendResult.unknown( + request_id=request_id, + group=request.group, + username=request.username, + local_id=( + _positive_id(receipt.local_id) + if isinstance(receipt, BridgeReceipt) + else None + ), + server_id=( + _positive_id(receipt.server_id) + if isinstance(receipt, BridgeReceipt) + else None + ), + ) + + def send_text(self, request): + if not isinstance(request, SendRequest): + raise SendUnavailableError( + "发送请求类型无效,未发送任何消息" + ) + if ( + isinstance(request.timeout, bool) + or not isinstance(request.timeout, (int, float)) + or not math.isfinite(request.timeout) + or request.timeout <= 0 + ): + raise SendUnavailableError( + "发送 timeout 无效,未发送任何消息" + ) + + started_at = self._monotonic() + deadline = started_at + request.timeout + try: + request_id = self._request_id_factory() + except Exception as error: + raise SendUnavailableError( + "无法生成发送 request_id,未发送任何消息" + ) from error + if not _valid_request_id(request_id): + raise SendUnavailableError( + "发送 request_id 无效,未发送任何消息" + ) + + try: + profile = self._preflight(self._app_context) + except SendUnavailableError: + raise + except Exception as error: + raise SendUnavailableError( + "发送前环境校验失败,未发送任何消息" + ) from error + + try: + self_username = self._self_username_loader(self._app_context) + except Exception as error: + raise SendUnavailableError( + "无法确认当前微信账号,未发送任何消息" + ) from error + if type(self_username) is not str or not self_username: + raise SendUnavailableError( + "无法确认当前微信账号,未发送任何消息" + ) + + try: + baseline = self._confirmation_store.capture_baseline( + request.username + ) + except MessageConfirmationUnavailable as error: + raise SendUnavailableError( + "无法记录发送前消息基线,未发送任何消息" + ) from error + except Exception as error: + raise SendUnavailableError( + "发送前消息基线不可用,未发送任何消息" + ) from error + if ( + not isinstance(baseline, MessageBaseline) + or baseline.username != request.username + or not isinstance(baseline.max_local_ids, dict) + or not baseline.max_local_ids + ): + raise SendUnavailableError( + "发送前消息基线无效,未发送任何消息" + ) + + remaining = deadline - self._monotonic() + if remaining <= 0: + raise SendUnavailableError( + "发送在提交前超时,未发送任何消息" + ) + try: + bridge = self._bridge_provider(profile, request_id) + except SendUnavailableError: + raise + except Exception as error: + raise SendUnavailableError( + "发送 bridge 无法准备,未发送任何消息" + ) from error + if not callable(getattr(bridge, "send_text", None)): + raise SendUnavailableError( + "发送 bridge 未正确加载,未发送任何消息" + ) + + bridge_request = replace(request, timeout=remaining) + try: + receipt = bridge.send_text(bridge_request) + except SendUnavailableError: + # 只有 bridge 能证明没有提交任何帧字节时,才允许退出 3。 + raise + except SendUnknownError: + return self._unknown(request, request_id) + except Exception: + # 一旦进入传输边界,异常也可能发生在消息已经提交之后。 + return self._unknown(request, request_id) + + if not isinstance(receipt, BridgeReceipt): + return self._unknown(request, request_id) + if ( + receipt.request_id != request_id + or receipt.group != request.group + or receipt.username != request.username + ): + return self._unknown(request, request_id, receipt) + + if receipt.ack_state is BridgeAckState.ACKNOWLEDGED: + if ( + _positive_id(receipt.local_id) is None + or _positive_id(receipt.server_id) is None + ): + return self._unknown(request, request_id, receipt) + elif receipt.ack_state is BridgeAckState.SUBMITTED: + if receipt.local_id is not None or receipt.server_id is not None: + return self._unknown(request, request_id, receipt) + else: + return self._unknown(request, request_id, receipt) + + remaining = max(0.0, deadline - self._monotonic()) + try: + confirmation = self._confirmation_store.poll_confirmation( + username=request.username, + self_username=self_username, + local_id=receipt.local_id, + text=request.text, + baseline=baseline, + timeout=remaining, + ) + except Exception: + return self._unknown(request, request_id, receipt) + if ( + not isinstance(confirmation, MessageConfirmation) + or _positive_id(confirmation.local_id) is None + or _positive_id(confirmation.server_id) is None + ): + return self._unknown(request, request_id, receipt) + if receipt.ack_state is BridgeAckState.ACKNOWLEDGED and ( + confirmation.local_id != receipt.local_id + or confirmation.server_id != receipt.server_id + ): + return self._unknown(request, request_id, receipt) + + return SendResult.sent( + request_id=request_id, + group=request.group, + username=request.username, + local_id=confirmation.local_id, + server_id=confirmation.server_id, + ) diff --git a/wechat_cli/core/send_bridge.py b/wechat_cli/core/send_bridge.py new file mode 100644 index 0000000..b033289 --- /dev/null +++ b/wechat_cli/core/send_bridge.py @@ -0,0 +1,536 @@ +"""经过本地凭据认证的单次 AF_UNIX bridge 传输。""" + +from __future__ import annotations + +import ctypes +import json +import math +import os +import re +import socket +import stat +import struct +import uuid +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Callable + +from .sending import SendRequest, SendUnavailableError, SendUnknownError + + +PROTOCOL_VERSION = 1 +MAX_FRAME_BYTES = 1024 * 1024 +MAX_METADATA_BYTES = 16 * 1024 +MAX_TOKEN_BYTES = 512 +MIN_TOKEN_BYTES = 32 +MAX_UNIX_PATH_BYTES = 103 + + +class BridgeAckState(str, Enum): + """bridge 自身观察到的原始 ACK 状态,不代表服务器最终接受。""" + + ACKNOWLEDGED = "acknowledged" + SUBMITTED = "submitted" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class BridgeMetadata: + version: int + pid: int + socket_path: Path + token: str + socket_device: int | None = None + socket_inode: int | None = None + socket_uid: int | None = None + socket_mode: int | None = None + + +@dataclass(frozen=True) +class BridgeReceipt: + """未经数据库二次确认的 bridge 原始回执。""" + + request_id: str + ack_state: BridgeAckState + group: str + username: str + local_id: int | None + server_id: int | None + + +def _mentions_pid(path: Path, pid: int) -> bool: + return re.search(rf"(? bool: + if _mentions_pid(path, pid): + return True + return re.fullmatch(rf"w-[0-9a-f]{{16}}{pid}", path.name) is not None + + +def _decode_unique_json(raw: bytes): + def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON key") + result[key] = value + return result + + return json.loads( + raw.decode("utf-8", "strict"), + object_pairs_hook=unique_object, + ) + + +def _read_credential_file(path: Path, expected_uid: int) -> bytes: + flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + descriptor = None + try: + descriptor = os.open(path, flags) + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or stat.S_IMODE(metadata.st_mode) != 0o600 + or metadata.st_uid != expected_uid + or metadata.st_nlink != 1 + or metadata.st_size < 1 + or metadata.st_size > MAX_METADATA_BYTES + ): + raise OSError("unsafe credential metadata") + chunks = [] + remaining = metadata.st_size + while remaining: + chunk = os.read(descriptor, remaining) + if not chunk: + raise OSError("credential file truncated") + chunks.append(chunk) + remaining -= len(chunk) + if os.read(descriptor, 1): + raise OSError("credential file changed") + return b"".join(chunks) + except OSError as error: + raise SendUnavailableError( + "bridge 凭据文件不可安全读取,未发送任何消息" + ) from error + finally: + if descriptor is not None: + os.close(descriptor) + + +def read_bridge_metadata( + metadata_path, + pid: int, + *, + expected_uid: int | None = None, +) -> BridgeMetadata: + """安全读取 PID 专属、当前用户所有且权限恰为 0600 的凭据文件。""" + + path = Path(metadata_path) + uid = os.geteuid() if expected_uid is None else expected_uid + if ( + isinstance(pid, bool) + or not isinstance(pid, int) + or pid <= 0 + or not path.is_absolute() + or not _mentions_pid(path, pid) + ): + raise SendUnavailableError( + "bridge 凭据路径不是 PID 专属路径,未发送任何消息" + ) + raw = _read_credential_file(path, uid) + try: + payload = _decode_unique_json(raw) + except (UnicodeError, ValueError, TypeError, json.JSONDecodeError) as error: + raise SendUnavailableError( + "bridge 凭据格式无效,未发送任何消息" + ) from error + if not isinstance(payload, dict) or set(payload) != { + "version", + "pid", + "socket_path", + "token", + }: + raise SendUnavailableError( + "bridge 凭据格式无效,未发送任何消息" + ) + + version = payload.get("version") + metadata_pid = payload.get("pid") + socket_value = payload.get("socket_path") + token = payload.get("token") + if version != PROTOCOL_VERSION or type(version) is not int: + raise SendUnavailableError( + "bridge 协议版本不受支持,未发送任何消息" + ) + if type(metadata_pid) is not int or metadata_pid != pid: + raise SendUnavailableError( + "bridge 凭据 PID 不匹配,未发送任何消息" + ) + if type(socket_value) is not str or "\x00" in socket_value: + raise SendUnavailableError( + "bridge socket 路径无效,未发送任何消息" + ) + socket_path = Path(socket_value) + encoded_path = os.fsencode(socket_path) + if ( + not socket_path.is_absolute() + or socket_path != Path(os.path.normpath(socket_value)) + or not _socket_mentions_pid(socket_path, pid) + or len(encoded_path) > MAX_UNIX_PATH_BYTES + ): + raise SendUnavailableError( + "bridge socket 路径不是 PID 专属路径,未发送任何消息" + ) + if type(token) is not str: + raise SendUnavailableError( + "bridge 凭据格式无效,未发送任何消息" + ) + try: + token_bytes = token.encode("ascii", "strict") + except UnicodeError as error: + raise SendUnavailableError( + "bridge 凭据格式无效,未发送任何消息" + ) from error + if ( + not MIN_TOKEN_BYTES <= len(token_bytes) <= MAX_TOKEN_BYTES + or any(byte <= 0x20 or byte >= 0x7F for byte in token_bytes) + ): + raise SendUnavailableError( + "bridge 凭据格式无效,未发送任何消息" + ) + + try: + socket_metadata = socket_path.lstat() + except OSError as error: + raise SendUnavailableError( + "bridge socket 不可用,未发送任何消息" + ) from error + socket_mode = stat.S_IMODE(socket_metadata.st_mode) + if ( + not stat.S_ISSOCK(socket_metadata.st_mode) + or socket_metadata.st_uid != uid + or socket_mode != 0o600 + ): + raise SendUnavailableError( + "bridge socket 不可信,未发送任何消息" + ) + return BridgeMetadata( + version=version, + pid=metadata_pid, + socket_path=socket_path, + token=token, + socket_device=socket_metadata.st_dev, + socket_inode=socket_metadata.st_ino, + socket_uid=socket_metadata.st_uid, + socket_mode=socket_mode, + ) + + +def _default_socket_factory(): + return socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + + +def _peer_uid(sock) -> int: + """通过 Darwin getpeereid(3) 取得已连接 AF_UNIX 对端的有效 UID。""" + + try: + descriptor = sock.fileno() + if isinstance(descriptor, bool) or not isinstance(descriptor, int) or descriptor < 0: + raise OSError("invalid socket descriptor") + libc = ctypes.CDLL(None, use_errno=True) + getpeereid = libc.getpeereid + getpeereid.argtypes = [ + ctypes.c_int, + ctypes.POINTER(ctypes.c_uint), + ctypes.POINTER(ctypes.c_uint), + ] + getpeereid.restype = ctypes.c_int + peer_uid = ctypes.c_uint() + peer_gid = ctypes.c_uint() + if getpeereid( + descriptor, + ctypes.byref(peer_uid), + ctypes.byref(peer_gid), + ) != 0: + error_number = ctypes.get_errno() + raise OSError(error_number, os.strerror(error_number)) + return int(peer_uid.value) + except (AttributeError, TypeError, ValueError) as error: + raise OSError("getpeereid unavailable") from error + + +def verify_connected_bridge_peer( + sock, + metadata: BridgeMetadata, + *, + expected_uid: int | None = None, + peer_uid_reader: Callable = _peer_uid, +): + """在提交任何字节前,把已连接对端绑定回预先检查的 socket。""" + + uid = os.geteuid() if expected_uid is None else expected_uid + try: + peer_uid = peer_uid_reader(sock) + peer_name = sock.getpeername() + if isinstance(peer_name, bytes): + peer_name = os.fsdecode(peer_name) + current = metadata.socket_path.lstat() + except (OSError, TypeError, ValueError) as error: + raise SendUnavailableError( + "bridge socket 对端身份不可确认,未发送任何消息" + ) from error + + if ( + not isinstance(metadata, BridgeMetadata) + or peer_uid != uid + or type(peer_name) is not str + or peer_name != str(metadata.socket_path) + or not stat.S_ISSOCK(current.st_mode) + or stat.S_IMODE(current.st_mode) != 0o600 + or current.st_uid != uid + or metadata.socket_device != current.st_dev + or metadata.socket_inode != current.st_ino + or metadata.socket_uid != current.st_uid + or metadata.socket_mode != stat.S_IMODE(current.st_mode) + ): + raise SendUnavailableError( + "bridge socket 对端身份不可信,未发送任何消息" + ) + + +def _default_request_id(): + return str(uuid.uuid4()) + + +def _encode_frame(payload: dict) -> bytes: + try: + encoded = json.dumps( + payload, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ).encode("utf-8") + except (TypeError, ValueError, UnicodeError) as error: + raise SendUnavailableError( + "发送请求无法安全编码,未发送任何消息" + ) from error + if not 1 <= len(encoded) <= MAX_FRAME_BYTES: + raise SendUnavailableError( + "发送请求过大,未发送任何消息" + ) + return struct.pack(">I", len(encoded)) + encoded + + +def _recv_exact(sock, size: int) -> bytes: + chunks = [] + remaining = size + while remaining: + chunk = sock.recv(remaining) + if not chunk: + raise ConnectionError("unexpected EOF") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def _receive_payload(sock): + prefix = _recv_exact(sock, 4) + size = struct.unpack(">I", prefix)[0] + if not 1 <= size <= MAX_FRAME_BYTES: + raise ValueError("invalid response frame size") + raw = _recv_exact(sock, size) + payload = _decode_unique_json(raw) + if not isinstance(payload, dict): + raise ValueError("response is not an object") + return payload + + +def _positive_id(value, *, optional: bool): + if value is None and optional: + return None + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ValueError("invalid message id") + return value + + +def _validate_receipt(payload, request: SendRequest, request_id: str) -> BridgeReceipt: + expected_keys = { + "version", + "type", + "ack_state", + "request_id", + "group", + "username", + "local_id", + "server_id", + } + if set(payload) != expected_keys: + raise ValueError("unexpected receipt fields") + if payload["version"] != PROTOCOL_VERSION or type(payload["version"]) is not int: + raise ValueError("invalid protocol version") + if payload["type"] != "send_receipt": + raise ValueError("invalid receipt type") + if ( + payload["request_id"] != request_id + or payload["group"] != request.group + or payload["username"] != request.username + ): + raise ValueError("receipt does not match request") + try: + ack_state = BridgeAckState(payload["ack_state"]) + except (ValueError, TypeError) as error: + raise ValueError("invalid ACK state") from error + if ack_state is BridgeAckState.SUBMITTED: + if payload["local_id"] is not None or payload["server_id"] is not None: + raise ValueError("submitted receipt must not contain message ids") + local_id = None + server_id = None + else: + optional_ids = ack_state is BridgeAckState.UNKNOWN + local_id = _positive_id(payload["local_id"], optional=optional_ids) + server_id = _positive_id(payload["server_id"], optional=optional_ids) + return BridgeReceipt( + request_id=request_id, + ack_state=ack_state, + group=request.group, + username=request.username, + local_id=local_id, + server_id=server_id, + ) + + +class AuthenticatedUnixBridgeClient: + """不重试的单次 bridge 调用;返回原始 ACK,不产生最终发送结果。""" + + def __init__( + self, + *, + pid: int, + metadata_path, + metadata_reader: Callable = read_bridge_metadata, + socket_factory: Callable = _default_socket_factory, + peer_verifier: Callable = verify_connected_bridge_peer, + request_id_factory: Callable[[], str] = _default_request_id, + ): + self.pid = pid + self.metadata_path = Path(metadata_path) + self.metadata_reader = metadata_reader + self.socket_factory = socket_factory + self.peer_verifier = peer_verifier + self.request_id_factory = request_id_factory + + def send_text(self, request: SendRequest) -> BridgeReceipt: + if not isinstance(request, SendRequest): + raise SendUnavailableError( + "发送请求类型无效,未发送任何消息" + ) + if ( + isinstance(request.timeout, bool) + or not isinstance(request.timeout, (int, float)) + or not math.isfinite(request.timeout) + or request.timeout <= 0 + ): + raise SendUnavailableError( + "bridge timeout 无效,未发送任何消息" + ) + try: + request_id = self.request_id_factory() + except Exception as error: + raise SendUnavailableError( + "bridge request_id 无法生成,未发送任何消息" + ) from error + if ( + type(request_id) is not str + or not request_id + or len(request_id.encode("utf-8")) > 256 + ): + raise SendUnavailableError( + "bridge request_id 无效,未发送任何消息" + ) + try: + metadata = self.metadata_reader(self.metadata_path, self.pid) + except SendUnavailableError: + raise + except Exception as error: + raise SendUnavailableError( + "bridge 凭据不可用,未发送任何消息" + ) from error + if not isinstance(metadata, BridgeMetadata) or metadata.pid != self.pid: + raise SendUnavailableError( + "bridge 凭据与目标进程不匹配,未发送任何消息" + ) + frame = _encode_frame( + { + "version": PROTOCOL_VERSION, + "type": "send_text", + "auth_token": metadata.token, + "request_id": request_id, + "group": request.group, + "username": request.username, + "text": request.text, + } + ) + + sock = None + submitted = 0 + try: + try: + sock = self.socket_factory() + sock.settimeout(float(request.timeout)) + except Exception as error: + raise SendUnavailableError( + "bridge socket 无法初始化,未发送任何消息" + ) from error + try: + sock.connect(str(metadata.socket_path)) + except (OSError, TimeoutError) as error: + raise SendUnavailableError( + "bridge 连接不可用,未发送任何消息" + ) from error + try: + self.peer_verifier(sock, metadata) + except SendUnavailableError: + raise + except Exception as error: + raise SendUnavailableError( + "bridge socket 对端身份不可确认,未发送任何消息" + ) from error + + while submitted < len(frame): + try: + count = sock.send(memoryview(frame)[submitted:]) + except (OSError, TimeoutError) as error: + if submitted == 0: + raise SendUnavailableError( + "bridge 在提交前不可用,未发送任何消息" + ) from error + raise SendUnknownError( + "bridge 提交状态未知,禁止自动重试" + ) from error + if not isinstance(count, int) or count <= 0: + if submitted == 0: + raise SendUnavailableError( + "bridge 在提交前不可用,未发送任何消息" + ) + raise SendUnknownError( + "bridge 提交状态未知,禁止自动重试" + ) + submitted += count + + try: + payload = _receive_payload(sock) + return _validate_receipt(payload, request, request_id) + except SendUnknownError: + raise + except (OSError, TimeoutError, UnicodeError, ValueError, TypeError) as error: + raise SendUnknownError( + "bridge 回执无法确认,禁止自动重试" + ) from error + finally: + if sock is not None: + try: + sock.close() + except OSError: + pass diff --git a/wechat_cli/core/send_confirmation.py b/wechat_cli/core/send_confirmation.py new file mode 100644 index 0000000..e818aa8 --- /dev/null +++ b/wechat_cli/core/send_confirmation.py @@ -0,0 +1,343 @@ +"""发送后的消息数据库交叉确认。 + +bridge 的内部回执只能证明微信开始或完成了对应任务;最终成功还必须由 +当前账号的消息表证明同一个 local_id 已取得 server_id 并进入已发送状态。 +""" + +from dataclasses import dataclass +import hashlib +import math +import os +from pathlib import Path +import sqlite3 +import stat +import time + +from .messages import _is_safe_msg_table_name + + +class MessageConfirmationUnavailable(RuntimeError): + """无法安全读取发送基线或确认消息表。""" + + +@dataclass(frozen=True) +class MessageBaseline: + """一次发送前,目标会话每个消息分表的最大 local_id。""" + + username: str + max_local_ids: dict[tuple[str, str, int, int], int] + + +@dataclass(frozen=True) +class MessageConfirmation: + """数据库足以确认服务器接受消息时返回的最小证据。""" + + local_id: int + server_id: int + + +def _readonly_connection(path): + resolved = Path(path).resolve(strict=True) + return sqlite3.connect(f"{resolved.as_uri()}?mode=ro", uri=True) + + +def _expected_table_name(username): + if not isinstance(username, str) or not username: + return None + return f"Msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}" + + +def _table_identity(table, username): + db_path = table.get("db_path") if isinstance(table, dict) else None + table_name = table.get("table_name") if isinstance(table, dict) else None + expected_table_name = _expected_table_name(username) + if ( + not isinstance(db_path, str) + or not db_path + or not isinstance(table_name, str) + or not _is_safe_msg_table_name(table_name) + or table_name != expected_table_name + ): + return None + try: + resolved = Path(db_path).resolve(strict=True) + metadata = resolved.stat(follow_symlinks=False) + except (OSError, RuntimeError): + return None + if not stat.S_ISREG(metadata.st_mode) or metadata.st_ino <= 0: + return None + return ( + str(resolved), + table_name, + metadata.st_dev, + metadata.st_ino, + ) + + +def _validate_baseline(username, baseline): + if ( + not isinstance(baseline, MessageBaseline) + or baseline.username != username + ): + raise ValueError("发送基线目标与确认目标不一致") + if not isinstance(baseline.max_local_ids, dict) or not baseline.max_local_ids: + raise MessageConfirmationUnavailable("发送基线不包含目标消息表") + + expected_table_name = _expected_table_name(username) + for identity, maximum in baseline.max_local_ids.items(): + if ( + not isinstance(identity, tuple) + or len(identity) != 4 + or not isinstance(identity[0], str) + or not identity[0] + or identity[1] != expected_table_name + or isinstance(identity[2], bool) + or not isinstance(identity[2], int) + or identity[2] < 0 + or isinstance(identity[3], bool) + or not isinstance(identity[3], int) + or identity[3] <= 0 + ): + raise MessageConfirmationUnavailable("发送基线目标消息表无效") + if ( + isinstance(maximum, bool) + or not isinstance(maximum, int) + or maximum < 0 + ): + raise MessageConfirmationUnavailable("发送基线 local_id 无效") + + +class MessageConfirmationStore: + """从可刷新消息表定位器读取发送基线并轮询最终状态。""" + + def __init__( + self, + table_locator, + *, + monotonic=time.monotonic, + sleep=time.sleep, + poll_interval=0.1, + ): + self._table_locator = table_locator + self._monotonic = monotonic + self._sleep = sleep + self._poll_interval = poll_interval + + def _tables(self, username): + try: + located = self._table_locator(username) + if located is None: + return () + if isinstance(located, (str, bytes, bytearray, dict)): + raise TypeError("invalid target table collection") + return tuple(located) + except Exception as error: + raise MessageConfirmationUnavailable( + "无法刷新目标消息表" + ) from error + + def capture_baseline(self, username): + maxima = {} + tables = self._tables(username) + if not tables: + raise MessageConfirmationUnavailable("未找到目标消息表") + for table in tables: + identity = _table_identity(table, username) + if identity is None: + raise MessageConfirmationUnavailable( + "目标消息表描述无效" + ) + if identity in maxima: + raise MessageConfirmationUnavailable( + "目标消息表描述重复" + ) + db_path, table_name, _, _ = identity + try: + with _readonly_connection(db_path) as conn: + row = conn.execute( + f"SELECT MAX(local_id) FROM [{table_name}]" + ).fetchone() + except (OSError, sqlite3.Error) as error: + raise MessageConfirmationUnavailable( + "无法读取目标消息基线" + ) from error + if _table_identity(table, username) != identity: + raise MessageConfirmationUnavailable( + "目标消息表在记录基线期间发生变化" + ) + maximum = row[0] if row else None + if maximum is None: + maximum = 0 + elif ( + isinstance(maximum, bool) + or not isinstance(maximum, int) + or maximum < 0 + ): + raise MessageConfirmationUnavailable( + "目标消息基线 local_id 无效" + ) + maxima[identity] = maximum + return MessageBaseline(username=username, max_local_ids=maxima) + + def find_confirmation( + self, + *, + username, + self_username, + local_id, + text, + baseline, + ): + if ( + ( + local_id is not None + and ( + isinstance(local_id, bool) + or not isinstance(local_id, int) + or local_id <= 0 + ) + ) + or not isinstance(self_username, str) + or not self_username + or type(text) is not str + or not text + ): + return None + + _validate_baseline(username, baseline) + confirmations = [] + tables = self._tables(username) + if not tables: + raise MessageConfirmationUnavailable("无法刷新目标消息表") + seen = set() + for table in tables: + identity = _table_identity(table, username) + if identity is None: + raise MessageConfirmationUnavailable( + "目标消息表描述无效" + ) + if identity in seen: + raise MessageConfirmationUnavailable( + "目标消息表描述重复" + ) + seen.add(identity) + if identity not in baseline.max_local_ids: + # 发送后新出现的分表没有提交前基线,绝不能用于证明成功。 + continue + db_path, table_name, _, _ = identity + maximum = baseline.max_local_ids[identity] + if local_id is not None and local_id <= maximum: + continue + try: + with _readonly_connection(db_path) as conn: + if local_id is None: + rows = conn.execute( + f""" + SELECT local_id, server_id, local_type, + real_sender_id, status, message_content + FROM [{table_name}] + WHERE local_id > ? AND message_content = ? + """, + (maximum, text), + ).fetchall() + else: + rows = conn.execute( + f""" + SELECT local_id, server_id, local_type, + real_sender_id, status, message_content + FROM [{table_name}] + WHERE local_id = ? + LIMIT 2 + """, + (local_id,), + ).fetchall() + if len(rows) != 1: + continue + + table_confirmations = [] + for row in rows: + ( + candidate_local_id, + server_id, + local_type, + sender_id, + status, + message_content, + ) = row + sender = conn.execute( + "SELECT user_name FROM Name2Id WHERE rowid = ?", + (sender_id,), + ).fetchone() + sender_username = ( + sender[0] if sender and sender[0] else "" + ) + is_text = ( + isinstance(local_type, int) + and not isinstance(local_type, bool) + and (local_type & 0xFFFFFFFF) == 1 + ) + if ( + isinstance(candidate_local_id, int) + and not isinstance(candidate_local_id, bool) + and candidate_local_id > maximum + and sender_username == self_username + and is_text + and status == 2 + and type(message_content) is str + and message_content == text + and isinstance(server_id, int) + and not isinstance(server_id, bool) + and server_id > 0 + ): + table_confirmations.append( + MessageConfirmation( + local_id=candidate_local_id, + server_id=server_id, + ) + ) + except (OSError, sqlite3.Error): + continue + if _table_identity(table, username) != identity: + raise MessageConfirmationUnavailable( + "目标消息表在确认期间发生变化" + ) + confirmations.extend(table_confirmations) + + if len(confirmations) != 1: + return None + return confirmations[0] + + def poll_confirmation( + self, + *, + username, + self_username, + local_id, + text, + baseline, + timeout, + ): + if ( + isinstance(timeout, bool) + or not isinstance(timeout, (int, float)) + or not math.isfinite(timeout) + or timeout < 0 + ): + raise ValueError("确认 timeout 必须是非负有限数") + + deadline = self._monotonic() + timeout + while True: + confirmation = self.find_confirmation( + username=username, + self_username=self_username, + local_id=local_id, + text=text, + baseline=baseline, + ) + if confirmation is not None: + return confirmation + + now = self._monotonic() + if now >= deadline: + return None + self._sleep(min(self._poll_interval, deadline - now)) diff --git a/wechat_cli/core/send_preflight.py b/wechat_cli/core/send_preflight.py new file mode 100644 index 0000000..30ed2ad --- /dev/null +++ b/wechat_cli/core/send_preflight.py @@ -0,0 +1,679 @@ +"""发送 bridge 注入前的只读、fail-closed 环境校验。""" + +from __future__ import annotations + +import hashlib +import os +import platform +import plistlib +import stat +import struct +import subprocess +import uuid +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Sequence + +from .sending import SendUnavailableError + + +EXPECTED_BUNDLE_PATH = Path("/Applications/WeChat.app") +EXPECTED_BUNDLE_ID = "com.tencent.xinWeChat" +EXPECTED_EXECUTABLE = "WeChat" +EXPECTED_SHORT_VERSION = "4.1.8" +EXPECTED_BUILD_VERSION = "36571" +EXPECTED_WECHAT_BUNDLE_VERSION = "4.1.8.28" +EXPECTED_DYLIB_RELATIVE_PATH = Path("Contents/Frameworks/wechat.dylib") +EXPECTED_DYLIB_SHA256 = ( + "b4a740135f3f1e937bca10caf0a95cff986ddf27fa6bb15ccaf64001fc651c93" +) +EXPECTED_DYLIB_UUID = "ABD88EC8-4590-3FDB-AAB5-4E5865B86B2A" + +CPU_TYPE_ARM64 = 0x0100000C +CPU_SUBTYPE_MASK = 0x00FFFFFF +CPU_SUBTYPE_ARM64_ALL = 0 +MH_EXECUTE = 0x2 +MH_DYLIB = 0x6 +LC_UUID = 0x1B +MAX_FAT_SLICES = 32 +MAX_LOAD_COMMANDS = 65536 +COMMAND_TIMEOUT_SECONDS = 3.0 + + +class MachOError(ValueError): + """Mach-O 结构无法被严格解析。""" + + +@dataclass(frozen=True) +class MachOSlice: + cpu_type: int + cpu_subtype: int + file_type: int + is_64: bool + command_count: int + uuid: str | None + + +@dataclass(frozen=True) +class ProcessIdentity: + pid: int + uid: int + started_at: str + executable_path: Path + + +@dataclass(frozen=True) +class WeChatProfile: + """已经完成全部注入前校验的目标信息。""" + + pid: int + process_uid: int + process_started_at: str + bundle_id: str + bundle_path: Path + executable_path: Path + arch: str + short_version: str + build_version: str + wechat_bundle_version: str + dylib_path: Path + dylib_sha256: str + dylib_uuid: str + open_db_paths: tuple[Path, ...] + + +def _unpack(data: bytes, offset: int, fmt: str): + size = struct.calcsize(fmt) + end = offset + size + if offset < 0 or end > len(data): + raise MachOError("Mach-O 数据被截断") + return struct.unpack_from(fmt, data, offset) + + +def _parse_thin(data: bytes) -> MachOSlice: + if len(data) < 4: + raise MachOError("Mach-O 头被截断") + magic = data[:4] + formats = { + b"\xcf\xfa\xed\xfe": ("<", 32, True), + b"\xfe\xed\xfa\xcf": (">", 32, True), + b"\xce\xfa\xed\xfe": ("<", 28, False), + b"\xfe\xed\xfa\xce": (">", 28, False), + } + try: + endian, command_offset, is_64 = formats[magic] + except KeyError as error: + raise MachOError("不是受支持的 Mach-O slice") from error + + cpu_type = _unpack(data, 4, f"{endian}I")[0] + cpu_subtype = _unpack(data, 8, f"{endian}I")[0] + file_type = _unpack(data, 12, f"{endian}I")[0] + command_count = _unpack(data, 16, f"{endian}I")[0] + command_bytes = _unpack(data, 20, f"{endian}I")[0] + if command_count > MAX_LOAD_COMMANDS: + raise MachOError("Mach-O load command 数量异常") + command_end = command_offset + command_bytes + if command_end > len(data): + raise MachOError("Mach-O load command 区域被截断") + + found_uuid = None + offset = command_offset + command_alignment = 8 if is_64 else 4 + for _ in range(command_count): + command, size = _unpack(data, offset, f"{endian}II") + if ( + size < 8 + or size % command_alignment != 0 + or offset + size > command_end + ): + raise MachOError("Mach-O load command 长度无效") + if command == LC_UUID: + if size != 24 or found_uuid is not None: + raise MachOError("Mach-O LC_UUID 无效") + raw_uuid = data[offset + 8 : offset + 24] + if len(raw_uuid) != 16: + raise MachOError("Mach-O LC_UUID 被截断") + found_uuid = str(uuid.UUID(bytes=raw_uuid)).upper() + offset += size + if offset != command_end: + raise MachOError("Mach-O load command 大小不一致") + return MachOSlice( + cpu_type=cpu_type, + cpu_subtype=cpu_subtype, + file_type=file_type, + is_64=is_64, + command_count=command_count, + uuid=found_uuid, + ) + + +def parse_macho(data: bytes) -> tuple[MachOSlice, ...]: + """解析 thin/FAT Mach-O,并返回每个 slice 的架构与 UUID。""" + + if len(data) < 4: + raise MachOError("Mach-O 数据被截断") + magic = data[:4] + fat_formats = { + b"\xca\xfe\xba\xbe": (">", False), + b"\xbe\xba\xfe\xca": ("<", False), + b"\xca\xfe\xba\xbf": (">", True), + b"\xbf\xba\xfe\xca": ("<", True), + } + if magic not in fat_formats: + return (_parse_thin(data),) + + endian, is_64 = fat_formats[magic] + slice_count = _unpack(data, 4, f"{endian}I")[0] + if not 1 <= slice_count <= MAX_FAT_SLICES: + raise MachOError("Mach-O FAT slice 数量无效") + entry_format = f"{endian}IIQQII" if is_64 else f"{endian}IIIII" + entry_size = struct.calcsize(entry_format) + table_end = 8 + slice_count * entry_size + if table_end > len(data): + raise MachOError("Mach-O FAT header 被截断") + + slices = [] + occupied = [] + for index in range(slice_count): + values = _unpack(data, 8 + index * entry_size, entry_format) + outer_cpu_type = values[0] + outer_cpu_subtype = values[1] + slice_offset = values[2] + slice_size = values[3] + slice_alignment = values[4] + reserved = values[5] if is_64 else 0 + slice_end = slice_offset + slice_size + if ( + slice_size == 0 + or slice_offset < table_end + or slice_end > len(data) + or slice_end < slice_offset + or slice_alignment > 63 + or slice_offset % (1 << slice_alignment) != 0 + ): + raise MachOError("Mach-O FAT slice 边界无效") + if reserved != 0: + raise MachOError("Mach-O FAT64 reserved 字段无效") + if any(slice_offset < end and start < slice_end for start, end in occupied): + raise MachOError("Mach-O FAT slice 重叠") + occupied.append((slice_offset, slice_end)) + parsed = _parse_thin(data[slice_offset:slice_end]) + if ( + parsed.cpu_type != outer_cpu_type + or parsed.cpu_subtype != outer_cpu_subtype + ): + raise MachOError("Mach-O FAT 架构声明不一致") + slices.append(parsed) + return tuple(slices) + + +def arm64_uuid(data: bytes) -> str: + """取得唯一 arm64 slice 的 LC_UUID,缺失或重复时拒绝。""" + + arm64_slices = [item for item in parse_macho(data) if item.cpu_type == CPU_TYPE_ARM64] + if len(arm64_slices) != 1: + raise MachOError("Mach-O 必须恰好包含一个 arm64 slice") + value = arm64_slices[0].uuid + if value is None: + raise MachOError("arm64 slice 缺少 LC_UUID") + return value + + +def _default_command_runner(args: Sequence[str], *, timeout: float): + return subprocess.run( + list(args), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout, + check=False, + shell=False, + ) + + +class PreflightInspector: + """可注入依赖的 macOS 只读检查器。""" + + def __init__( + self, + *, + system: Callable[[], str] = platform.system, + machine: Callable[[], str] = platform.machine, + euid: Callable[[], int] = os.geteuid, + command_runner: Callable = _default_command_runner, + expected_bundle_path: Path = EXPECTED_BUNDLE_PATH, + ): + self.system = system + self.machine = machine + self.euid = euid + self.command_runner = command_runner + self.expected_bundle_path = Path(expected_bundle_path) + + def run(self, args: Sequence[str]): + try: + return self.command_runner( + list(args), + timeout=COMMAND_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.SubprocessError) as error: + raise SendUnavailableError( + "无法完成 WeChat 运行状态校验,未发送任何消息" + ) from error + + +def _file_identity(metadata): + return ( + metadata.st_dev, + metadata.st_ino, + metadata.st_mode, + metadata.st_uid, + metadata.st_gid, + metadata.st_nlink, + metadata.st_size, + metadata.st_mtime_ns, + metadata.st_ctime_ns, + ) + + +def _directory_identity(metadata): + return ( + metadata.st_dev, + metadata.st_ino, + stat.S_IFMT(metadata.st_mode), + metadata.st_uid, + metadata.st_gid, + ) + + +def _open_regular_path_without_symlinks(path: Path): + """逐组件绑定绝对路径,拒绝任意父级或末级符号链接。""" + + nofollow = getattr(os, "O_NOFOLLOW", 0) + directory_flag = getattr(os, "O_DIRECTORY", 0) + if not nofollow or not directory_flag: + raise OSError("secure component open unavailable") + + normalized = Path(os.path.normpath(os.fspath(path))) + if not normalized.is_absolute() or len(normalized.parts) < 2: + raise OSError("path must identify an absolute file") + components = normalized.parts[1:] + if any(component in ("", ".", "..") for component in components): + raise OSError("unsafe path component") + + close_on_exec = getattr(os, "O_CLOEXEC", 0) + directory_flags = os.O_RDONLY | directory_flag | nofollow | close_on_exec + file_flags = os.O_RDONLY | nofollow | close_on_exec + current_fd = os.open(os.path.sep, directory_flags) + ancestor_identities = [] + try: + metadata = os.fstat(current_fd) + if not stat.S_ISDIR(metadata.st_mode): + raise OSError("root is not a directory") + ancestor_identities.append(_directory_identity(metadata)) + + for component in components[:-1]: + next_fd = os.open( + component, + directory_flags, + dir_fd=current_fd, + ) + try: + metadata = os.fstat(next_fd) + if not stat.S_ISDIR(metadata.st_mode): + raise OSError("path component is not a directory") + ancestor_identities.append(_directory_identity(metadata)) + except Exception: + os.close(next_fd) + raise + os.close(current_fd) + current_fd = next_fd + + descriptor = os.open( + components[-1], + file_flags, + dir_fd=current_fd, + ) + return descriptor, tuple(ancestor_identities) + finally: + os.close(current_fd) + + +def _read_regular_file(path: Path, *, max_size: int) -> bytes: + descriptor = None + verification_descriptor = None + try: + descriptor, ancestor_identities = _open_regular_path_without_symlinks( + path + ) + metadata = os.fstat(descriptor) + if ( + not stat.S_ISREG(metadata.st_mode) + or metadata.st_nlink != 1 + or metadata.st_size < 1 + or metadata.st_size > max_size + ): + raise OSError("invalid regular file") + chunks = [] + remaining = metadata.st_size + while remaining: + chunk = os.read(descriptor, min(remaining, 1024 * 1024)) + if not chunk: + raise OSError("file truncated while reading") + chunks.append(chunk) + remaining -= len(chunk) + if os.read(descriptor, 1): + raise OSError("file grew while reading") + + after_read = os.fstat(descriptor) + ( + verification_descriptor, + verification_ancestors, + ) = _open_regular_path_without_symlinks(path) + path_after_read = os.fstat(verification_descriptor) + if ( + _file_identity(after_read) != _file_identity(metadata) + or _file_identity(path_after_read) != _file_identity(metadata) + or verification_ancestors != ancestor_identities + ): + raise OSError("file changed while reading") + return b"".join(chunks) + except OSError as error: + raise SendUnavailableError( + f"所需 WeChat 文件不可安全读取: {path.name};未发送任何消息" + ) from error + finally: + if descriptor is not None: + os.close(descriptor) + if verification_descriptor is not None: + os.close(verification_descriptor) + + +def _single_wechat_pid(inspector: PreflightInspector) -> int: + result = inspector.run(["/usr/bin/pgrep", "-x", "WeChat"]) + if result.returncode == 1: + pids = [] + elif result.returncode == 0: + try: + pids = [ + int(line) + for line in result.stdout.decode("ascii", "strict").splitlines() + if line + ] + except (UnicodeError, ValueError) as error: + raise SendUnavailableError( + "无法确认 WeChat 主进程,未发送任何消息" + ) from error + else: + raise SendUnavailableError("无法确认 WeChat 主进程,未发送任何消息") + if len(pids) != 1 or pids[0] <= 0: + raise SendUnavailableError( + "必须恰好一个 WeChat 主进程,未发送任何消息" + ) + return pids[0] + + +def _process_identity( + inspector: PreflightInspector, + pid: int, +) -> ProcessIdentity: + result = inspector.run( + [ + "/bin/ps", + "-p", + str(pid), + "-o", + "pid=", + "-o", + "uid=", + "-o", + "lstart=", + "-o", + "comm=", + ] + ) + if result.returncode != 0: + raise SendUnavailableError("WeChat 进程已变化,未发送任何消息") + try: + lines = result.stdout.decode("utf-8", "strict").splitlines() + except UnicodeError as error: + raise SendUnavailableError( + "无法确认 WeChat 可执行文件路径,未发送任何消息" + ) from error + if len(lines) != 1: + raise SendUnavailableError( + "无法确认 WeChat 进程身份,未发送任何消息" + ) + fields = lines[0].split(maxsplit=7) + if len(fields) != 8: + raise SendUnavailableError( + "无法确认 WeChat 进程身份,未发送任何消息" + ) + try: + reported_pid = int(fields[0]) + process_uid = int(fields[1]) + except ValueError as error: + raise SendUnavailableError( + "无法确认 WeChat 进程身份,未发送任何消息" + ) from error + started_at = " ".join(fields[2:7]) + executable_value = fields[7] + if ( + reported_pid != pid + or pid <= 0 + or process_uid < 0 + or process_uid != inspector.euid() + or not started_at + ): + raise SendUnavailableError( + "WeChat 进程身份与当前用户不一致,未发送任何消息" + ) + if not executable_value.startswith("/"): + raise SendUnavailableError( + "无法确认 WeChat 可执行文件路径,未发送任何消息" + ) + return ProcessIdentity( + pid=pid, + uid=process_uid, + started_at=started_at, + executable_path=Path(os.path.normpath(executable_value)), + ) + + +def _read_info_plist(bundle_path: Path) -> dict: + raw = _read_regular_file(bundle_path / "Contents/Info.plist", max_size=1024 * 1024) + try: + value = plistlib.loads(raw) + except (plistlib.InvalidFileException, ValueError, TypeError) as error: + raise SendUnavailableError( + "WeChat Info.plist 无法解析,未发送任何消息" + ) from error + if not isinstance(value, dict): + raise SendUnavailableError( + "WeChat Info.plist 格式无效,未发送任何消息" + ) + return value + + +def _validate_info_plist(info: dict): + expected = { + "CFBundleIdentifier": EXPECTED_BUNDLE_ID, + "CFBundleExecutable": EXPECTED_EXECUTABLE, + "CFBundleShortVersionString": EXPECTED_SHORT_VERSION, + "CFBundleVersion": EXPECTED_BUILD_VERSION, + "WeChatBundleVersion": EXPECTED_WECHAT_BUNDLE_VERSION, + } + if any( + type(info.get(key)) is not str or info.get(key) != value + for key, value in expected.items() + ): + raise SendUnavailableError( + "WeChat Info.plist 与唯一批准版本不一致,未发送任何消息" + ) + + +def _open_database_paths( + inspector: PreflightInspector, + pid: int, + db_dir, +) -> tuple[Path, ...]: + try: + db_root = Path(db_dir).expanduser().resolve(strict=True) + except (OSError, RuntimeError, TypeError) as error: + raise SendUnavailableError( + "数据库目录不可用,未发送任何消息" + ) from error + if not db_root.is_dir(): + raise SendUnavailableError("数据库目录不可用,未发送任何消息") + + result = inspector.run( + ["/usr/sbin/lsof", "-n", "-P", "-a", "-p", str(pid), "-Fn"] + ) + if result.returncode != 0: + raise SendUnavailableError( + "无法确认 WeChat 已打开数据库,未发送任何消息" + ) + try: + output = result.stdout.decode("utf-8", "strict") + except UnicodeError as error: + raise SendUnavailableError( + "无法确认 WeChat 已打开数据库,未发送任何消息" + ) from error + + found = set() + for line in output.splitlines(): + if not line.startswith("n/"): + continue + raw_path = line[1:] + if "\x00" in raw_path: + continue + try: + normalized = Path(os.path.realpath(raw_path)) + normalized.relative_to(db_root) + except (OSError, RuntimeError, ValueError): + continue + name = normalized.name.lower() + if ( + name.endswith((".db", ".db-wal", ".db-shm")) + and normalized.is_file() + ): + found.add(normalized) + if not found: + raise SendUnavailableError( + "WeChat 未打开配置目录下的数据库文件,未发送任何消息" + ) + return tuple(sorted(found)) + + +def verify_pre_injection( + app_context, + *, + inspector: PreflightInspector | None = None, +) -> WeChatProfile: + """在任何注入或发送动作前验证唯一批准的 WeChat profile。""" + + inspector = inspector or PreflightInspector() + if inspector.system() != "Darwin" or inspector.machine() != "arm64": + raise SendUnavailableError( + "发送 bridge 仅支持 macOS arm64,未发送任何消息" + ) + + pid = _single_wechat_pid(inspector) + bundle_path = inspector.expected_bundle_path + expected_executable_path = bundle_path / "Contents/MacOS/WeChat" + process_identity = _process_identity(inspector, pid) + if process_identity.executable_path != expected_executable_path: + raise SendUnavailableError( + "WeChat 可执行文件路径不是唯一批准路径,未发送任何消息" + ) + + info = _read_info_plist(bundle_path) + _validate_info_plist(info) + executable_data = _read_regular_file( + expected_executable_path, + max_size=1024 * 1024 * 1024, + ) + try: + executable_slices = parse_macho(executable_data) + except MachOError as error: + raise SendUnavailableError( + "WeChat 可执行文件 Mach-O 无效,未发送任何消息" + ) from error + executable_arm64 = [ + item for item in executable_slices + if item.cpu_type == CPU_TYPE_ARM64 + ] + if ( + len(executable_arm64) != 1 + or not executable_arm64[0].is_64 + or executable_arm64[0].file_type != MH_EXECUTE + or ( + executable_arm64[0].cpu_subtype & CPU_SUBTYPE_MASK + ) != CPU_SUBTYPE_ARM64_ALL + or executable_arm64[0].command_count < 1 + ): + raise SendUnavailableError( + "WeChat 可执行文件不是批准的 arm64 架构,未发送任何消息" + ) + + dylib_path = bundle_path / EXPECTED_DYLIB_RELATIVE_PATH + dylib_data = _read_regular_file(dylib_path, max_size=512 * 1024 * 1024) + dylib_sha256 = hashlib.sha256(dylib_data).hexdigest() + if dylib_sha256 != EXPECTED_DYLIB_SHA256: + raise SendUnavailableError( + "wechat.dylib SHA-256 与批准版本不一致,未发送任何消息" + ) + try: + dylib_slices = parse_macho(dylib_data) + except MachOError as error: + raise SendUnavailableError( + "wechat.dylib 不含唯一有效 arm64 slice,未发送任何消息" + ) from error + dylib_arm64 = [ + item for item in dylib_slices + if item.cpu_type == CPU_TYPE_ARM64 + ] + if ( + len(dylib_arm64) != 1 + or not dylib_arm64[0].is_64 + or dylib_arm64[0].file_type != MH_DYLIB + or ( + dylib_arm64[0].cpu_subtype & CPU_SUBTYPE_MASK + ) != CPU_SUBTYPE_ARM64_ALL + or dylib_arm64[0].command_count < 1 + or dylib_arm64[0].uuid is None + ): + raise SendUnavailableError( + "wechat.dylib 不含唯一有效 arm64 slice,未发送任何消息" + ) + dylib_uuid = dylib_arm64[0].uuid + if dylib_uuid != EXPECTED_DYLIB_UUID: + raise SendUnavailableError( + "wechat.dylib arm64 UUID 与批准版本不一致,未发送任何消息" + ) + + open_db_paths = _open_database_paths( + inspector, + pid, + getattr(app_context, "db_dir", None), + ) + final_process_identity = _process_identity(inspector, pid) + if final_process_identity != process_identity: + raise SendUnavailableError( + "WeChat 进程已变化,未发送任何消息" + ) + return WeChatProfile( + pid=pid, + process_uid=process_identity.uid, + process_started_at=process_identity.started_at, + bundle_id=EXPECTED_BUNDLE_ID, + bundle_path=bundle_path, + executable_path=expected_executable_path, + arch="arm64", + short_version=EXPECTED_SHORT_VERSION, + build_version=EXPECTED_BUILD_VERSION, + wechat_bundle_version=EXPECTED_WECHAT_BUNDLE_VERSION, + dylib_path=dylib_path, + dylib_sha256=dylib_sha256, + dylib_uuid=dylib_uuid, + open_db_paths=open_db_paths, + ) diff --git a/wechat_cli/core/sending.py b/wechat_cli/core/sending.py new file mode 100644 index 0000000..b80d614 --- /dev/null +++ b/wechat_cli/core/sending.py @@ -0,0 +1,315 @@ +"""群消息发送公共契约、精确目标解析和结果校验。""" + +from dataclasses import dataclass +from enum import Enum, IntEnum +from typing import Protocol + +from .contacts import get_contact_full, get_contact_names + + +SEND_INIT_ERROR_META_KEY = "wechat_cli.send_init_error" +MAX_MESSAGE_ID = (1 << 63) - 1 +MAX_RECEIPT_TEXT_BYTES = 16 * 1024 + + +class SendExitCode(IntEnum): + """send 命令稳定退出码。""" + + SUCCESS = 0 + TARGET_ERROR = 1 + INPUT_ERROR = 2 + UNAVAILABLE = 3 + UNKNOWN = 4 + + +class SendStatus(str, Enum): + """发送服务可以明确返回的最终状态。""" + + SERVER_ACCEPTED = "server_accepted" + UNKNOWN = "unknown" + + +class SendError(RuntimeError): + """发送命令可安全映射为退出码的错误。""" + + exit_code = SendExitCode.UNKNOWN + status = SendStatus.UNKNOWN.value + auto_retry = False + + +class SendTargetError(SendError): + """目标不存在、不唯一或不是群聊。""" + + exit_code = SendExitCode.TARGET_ERROR + status = "target_error" + + +class SendInputError(SendError): + """调用参数无效。""" + + exit_code = SendExitCode.INPUT_ERROR + status = "input_error" + + +class SendUnavailableError(SendError): + """发送实现或其运行环境不可用,且尚未尝试发送。""" + + exit_code = SendExitCode.UNAVAILABLE + status = "unavailable" + + +class SendUnknownError(SendError): + """无法确认消息是否已发送;调用方不得自动重试。""" + + exit_code = SendExitCode.UNKNOWN + status = SendStatus.UNKNOWN.value + + +@dataclass(frozen=True) +class SendTarget: + """已从联系人库精确解析出的群目标。""" + + group: str + username: str + + +@dataclass(frozen=True) +class SendRequest: + """发送服务的稳定输入。""" + + group: str + username: str + text: str + timeout: float + + +@dataclass(frozen=True) +class SendResult: + """发送服务的稳定输出。""" + + success: bool + status: SendStatus + request_id: str + group: str + username: str + local_id: int | None + server_id: int | None + + @classmethod + def sent( + cls, + *, + request_id, + group, + username, + local_id, + server_id, + ): + return cls( + success=True, + status=SendStatus.SERVER_ACCEPTED, + request_id=request_id, + group=group, + username=username, + local_id=local_id, + server_id=server_id, + ) + + @classmethod + def unknown( + cls, + *, + request_id, + group, + username, + local_id=None, + server_id=None, + ): + return cls( + success=False, + status=SendStatus.UNKNOWN, + request_id=request_id, + group=group, + username=username, + local_id=local_id, + server_id=server_id, + ) + + def as_dict(self): + status = self.status.value if isinstance(self.status, SendStatus) else str(self.status) + return { + "success": self.success, + "status": status, + "request_id": self.request_id, + "group": self.group, + "username": self.username, + "local_id": self.local_id, + "server_id": self.server_id, + } + + +class SendService(Protocol): + """后续发送实现必须满足的最小接口。""" + + def send_text(self, request: SendRequest) -> SendResult: + """发送一条群文本消息,或抛出分类后的 SendError。""" + + +class UnavailableSendService: + """发送服务未配置时使用的 fail-closed 默认实现。""" + + def send_text(self, request): + raise SendUnavailableError( + "当前构建未安装或不支持发送 bridge,未发送任何消息" + ) + + +def _display_from_contact(contact): + username = contact.get("username") + if not username: + return None, None + display = ( + contact.get("remark") + or contact.get("nick_name") + or contact.get("display_name") + or username + ) + return username, display + + +def _load_unique_contacts(cache, decrypted_dir): + """按 username 合并现有 names/full 两种联系人结构。""" + + try: + names = get_contact_names(cache, decrypted_dir) or {} + full = get_contact_full(cache, decrypted_dir) or [] + + unique = {} + for contact in full: + if not isinstance(contact, dict): + continue + username, display = _display_from_contact(contact) + if username and username not in unique: + unique[username] = display + + # get_contact_names 使用项目统一的 remark > nick_name > username 规则, + # 因此在两种结构同时存在时以它为准。 + for username, display in names.items(): + if username: + unique[username] = display or username + except Exception as error: + raise SendUnavailableError( + f"联系人库不可用: {error};未发送任何消息" + ) from error + + if not unique: + raise SendUnavailableError("联系人库不可用或为空,未发送任何消息") + return unique + + +def resolve_send_group(group, cache, decrypted_dir): + """精确解析发送目标,不复用或改变查询命令的模糊解析器。""" + + contacts = _load_unique_contacts(cache, decrypted_dir) + + if group in contacts: + if not group.endswith("@chatroom"): + raise SendTargetError(f"{group} 不是群聊") + return SendTarget(group=contacts[group], username=group) + + # 带 @chatroom 的输入被视为 username,禁止回退为显示名, + # 从而拒绝伪造 ID。 + if "@chatroom" in group: + raise SendTargetError(f"联系人库中不存在群聊 username: {group}") + + matches = [ + SendTarget(group=display, username=username) + for username, display in contacts.items() + if username.endswith("@chatroom") and display == group + ] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise SendTargetError( + f'群名“{group}”匹配到多个群聊,请使用 @chatroom username' + ) + + if any( + not username.endswith("@chatroom") and display == group + for username, display in contacts.items() + ): + raise SendTargetError(f"{group} 不是群聊") + raise SendTargetError(f"找不到群聊: {group}") + + +def get_send_service(app): + """取得注入的发送服务;未注入时始终 fail-closed。""" + + service = getattr(app, "send_service", None) + if service is None: + return UnavailableSendService() + if not callable(getattr(service, "send_text", None)): + raise SendUnavailableError("发送服务未正确配置,未发送任何消息") + return service + + +def validate_send_result(result, request): + """验证 bridge 回执足以证明服务器已接受当前目标的消息。""" + + if not isinstance(result, SendResult): + raise SendUnknownError("发送服务未返回可确认结果,禁止自动重试") + + def valid_text(value, *, max_bytes): + if type(value) is not str or not value: + return False + try: + encoded = value.encode("utf-8", "strict") + except UnicodeError: + return False + return len(encoded) <= max_bytes + + if not valid_text(result.request_id, max_bytes=256): + raise SendUnknownError("发送回执缺少有效 request_id,禁止自动重试") + if ( + not valid_text(result.group, max_bytes=MAX_RECEIPT_TEXT_BYTES) + or not valid_text(result.username, max_bytes=MAX_RECEIPT_TEXT_BYTES) + or result.group != request.group + or result.username != request.username + ): + raise SendUnknownError("发送回执目标与请求不一致,禁止自动重试") + + for field_name, value in ( + ("local_id", result.local_id), + ("server_id", result.server_id), + ): + if value is not None and ( + isinstance(value, bool) + or not isinstance(value, int) + or value <= 0 + or value > MAX_MESSAGE_ID + ): + raise SendUnknownError( + f"发送回执包含无效 {field_name},禁止自动重试" + ) + + if result.status is SendStatus.UNKNOWN: + if result.success is not False: + raise SendUnknownError("发送服务返回矛盾状态,禁止自动重试") + return result + + if result.status is not SendStatus.SERVER_ACCEPTED or result.success is not True: + raise SendUnknownError("发送服务返回未知状态,禁止自动重试") + for field_name, value in ( + ("local_id", result.local_id), + ("server_id", result.server_id), + ): + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value <= 0 + or value > MAX_MESSAGE_ID + ): + raise SendUnknownError( + f"发送回执缺少有效 {field_name},禁止自动重试" + ) + return result diff --git a/wechat_cli/main.py b/wechat_cli/main.py index 3400c06..de97830 100644 --- a/wechat_cli/main.py +++ b/wechat_cli/main.py @@ -5,8 +5,9 @@ import click from .core.context import AppContext +from .core.sending import SEND_INIT_ERROR_META_KEY -_VERSION = "0.2.4" +_VERSION = "0.3.0" @click.group() @@ -15,7 +16,7 @@ help="config.json 路径(默认自动查找)") @click.pass_context def cli(ctx, config_path): - """WeChat CLI — 查询微信消息、联系人等数据 + """WeChat CLI — 查询本地微信数据,并按需后台发送群聊文本 \b 使用示例: @@ -27,6 +28,7 @@ def cli(ctx, config_path): wechat-cli search "Claude" --chat "AI交流群" # 在指定群里搜索关键词 wechat-cli search "你好" --limit 50 # 全局搜索 wechat-cli contacts --query "李" # 搜索联系人 + wechat-cli send "AI交流群" "大家好" # 向精确匹配的群发送文本 wechat-cli new-messages # 获取增量新消息 """ # init/version 命令不需要 AppContext @@ -36,9 +38,15 @@ def cli(ctx, config_path): try: ctx.obj = AppContext(config_path) except FileNotFoundError as e: + if ctx.invoked_subcommand == "send": + ctx.meta[SEND_INIT_ERROR_META_KEY] = e + return click.echo(str(e), err=True) sys.exit(1) except Exception as e: + if ctx.invoked_subcommand == "send": + ctx.meta[SEND_INIT_ERROR_META_KEY] = e + return click.echo(f"初始化失败: {e}", err=True) sys.exit(1) @@ -55,6 +63,7 @@ def cli(ctx, config_path): from .commands.stats import stats from .commands.unread import unread from .commands.favorites import favorites +from .commands.send import send cli.add_command(init) cli.add_command(sessions) @@ -67,6 +76,7 @@ def cli(ctx, config_path): cli.add_command(stats) cli.add_command(unread) cli.add_command(favorites) +cli.add_command(send) if __name__ == "__main__":