commit 077a54cbfe679bda963cd038d8440422907fc797 Author: jackwener Date: Mon Sep 14 15:43:35 2026 +0800 Initial commit: wx-cli again Fresh start from botiverse/wx-cli (working tree at main, no prior history). diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c6a693a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,19 @@ +{ + "permissions": { + "allow": [ + "Bash(grep -E \"\\\\.py$|\\\\.md$\")", + "Bash(git checkout:*)", + "Bash(python3 -c \"import ast; ast.parse\\(open\\(''wx_daemon.py''\\).read\\(\\)\\); print\\(''wx_daemon.py OK''\\)\")", + "Bash(python3 -c \"import ast; ast.parse\\(open\\(''wx.py''\\).read\\(\\)\\); print\\(''wx.py OK''\\)\")", + "Bash(pip install:*)", + "Bash(pip show:*)", + "Bash(pip3 install:*)", + "Bash(python3 -c \"import click; print\\(''click'', click.__version__\\)\")", + "Bash(python3 wx.py --help)", + "Bash(python3 wx.py sessions --help)", + "Bash(python3 -c \"import sys; print\\(sys.executable\\)\")", + "Bash(uv pip:*)", + "Bash(uv venv:*)" + ] + } +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..68add27 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,160 @@ +name: Release + +on: + push: + tags: ['v*'] + workflow_dispatch: + +permissions: + contents: write + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + - name: cargo check linux target + run: cargo check --target x86_64-unknown-linux-gnu + + build: + needs: check + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + target: aarch64-apple-darwin + asset: wx-macos-arm64 + npm_dir: darwin-arm64 + bin: wx + - os: macos-latest + target: x86_64-apple-darwin + asset: wx-macos-x86_64 + npm_dir: darwin-x64 + bin: wx + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + asset: wx-linux-x86_64 + npm_dir: linux-x64 + bin: wx + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + asset: wx-linux-arm64 + npm_dir: linux-arm64 + bin: wx + - os: windows-latest + target: x86_64-pc-windows-msvc + asset: wx-windows-x86_64.exe + npm_dir: win32-x64 + bin: wx.exe + + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Install cross-compile tools (Linux arm64) + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: | + sudo apt-get update -q + sudo apt-get install -y gcc-aarch64-linux-gnu + echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }} + restore-keys: ${{ runner.os }}-${{ matrix.target }}-cargo- + + - name: Build release + run: cargo build --release --locked --target ${{ matrix.target }} + + - name: Copy binary (Unix) + if: matrix.os != 'windows-latest' + run: | + cp target/${{ matrix.target }}/release/wx ${{ matrix.asset }} + mkdir -p npm/platforms/${{ matrix.npm_dir }}/bin + cp target/${{ matrix.target }}/release/wx npm/platforms/${{ matrix.npm_dir }}/bin/wx + + - name: Copy binary (Windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + Copy-Item "target\${{ matrix.target }}\release\wx.exe" "${{ matrix.asset }}" + New-Item -ItemType Directory -Force -Path "npm\platforms\${{ matrix.npm_dir }}\bin" | Out-Null + Copy-Item "target\${{ matrix.target }}\release\wx.exe" "npm\platforms\${{ matrix.npm_dir }}\bin\wx.exe" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.asset }} + path: ${{ matrix.asset }} + + - uses: actions/upload-artifact@v4 + with: + name: npm-${{ matrix.npm_dir }} + path: npm/platforms/${{ matrix.npm_dir }}/bin/ + + - name: Upload to GitHub Release + uses: softprops/action-gh-release@v2 + if: startsWith(github.ref, 'refs/tags/') + with: + files: ${{ matrix.asset }} + + publish-npm: + needs: build + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - name: Download all platform binaries + uses: actions/download-artifact@v4 + with: + pattern: npm-* + path: npm-bins/ + + - name: Place binaries into platform packages + run: | + for dir in darwin-arm64 darwin-x64 linux-x64 linux-arm64; do + mkdir -p npm/platforms/$dir/bin + cp npm-bins/npm-$dir/wx npm/platforms/$dir/bin/wx + chmod +x npm/platforms/$dir/bin/wx + done + mkdir -p npm/platforms/win32-x64/bin + cp npm-bins/npm-win32-x64/wx.exe npm/platforms/win32-x64/bin/wx.exe + + - name: Publish platform packages + run: | + for dir in darwin-arm64 darwin-x64 linux-x64 linux-arm64 win32-x64; do + cd npm/platforms/$dir + npm publish 2>&1 | tee /tmp/npm-out.txt || grep -q "previously published" /tmp/npm-out.txt || exit 1 + cd ../../.. + done + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish main package + run: | + cd npm/wx-cli + npm publish 2>&1 | tee /tmp/npm-out.txt || grep -q "previously published" /tmp/npm-out.txt || exit 1 + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cf83214 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Decrypted databases and keys - NEVER upload +all_keys.json +wechat_key.txt +config.json +decrypted/ +decoded_images/ +*.db +*.db-shm +*.db-wal +*.db.tmp_monitor + +# Hook outputs +hook_output.txt +hook_start_output.txt +hook_stderr.txt +run_hook.bat + +# Rust +target/ + +# Python +__pycache__/ +*.py[cod] +*.egg-info/ + +# OS +.DS_Store +Thumbs.db +find_all_keys_macos +.claude/worktrees/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a9cdc28 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,34 @@ +# wx-cli Agent Rules + +## 每次改完代码后必须做的事 + +1. **`cargo check`** — 改任何 `.rs` 文件后立刻运行,不通过不提交 +2. **改了跨平台代码时加运行跨平台 check:** + ```bash + cargo check --target x86_64-unknown-linux-gnu + cargo check --target x86_64-pc-windows-msvc + ``` +3. **改了 `Cargo.toml` 版本号时:** `cargo update --workspace` + +## 禁止行为 + +- 不能在 `cargo check` 失败的情况下 commit +- 不能只在 macOS 本地 check 就认为跨平台没问题 +- 不能改完 `Cargo.toml` 不更新 `Cargo.lock` 就打 tag + +## 常见陷阱 + +| 陷阱 | 正确做法 | +|------|----------| +| `libc::__error()` 在 `#[cfg(unix)]` 里 | 用 `std::io::Error::last_os_error()` | +| 把通用 dep 放到 `[target.cfg(windows).dependencies]` 后面 | TOML section 是贪婪的,通用 dep 必须在 target section 之前 | +| 改版本号忘更新 Cargo.lock | `cargo update --workspace` | +| Windows 代码用 trait method 忘 import trait | `use std::os::windows::process::CommandExt` 等 | +| `#[cfg(windows)]` 里引用了未定义的函数 | 跨平台 check 会发现 | + +## Push 规则 + +- remote 名称:`origin` → `git@github.com:botiverse/wx-cli.git`(SSH) +- 不要使用已 DMCA 的 `jackwener/wx-cli` +- 每次 commit 后立刻 push(默认分支 `main`) +- 打 tag 用 `git tag vX.Y.Z && git push origin vX.Y.Z` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..876859b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# wx-cli Project Rules + +## After Every Code Change + +**Rust 代码改动后,必须立刻运行:** + +```bash +cargo check +``` + +不允许在 `cargo check` 通过之前提交或推送。 + +**改动涉及跨平台代码(`#[cfg(...)]` / `Cargo.toml` dependencies)时,额外运行:** + +```bash +cargo check --target x86_64-unknown-linux-gnu +cargo check --target x86_64-pc-windows-gnu # 在 macOS 上用这个,msvc 需要 MSVC 工具链 +``` + +macOS 上需要一次性安装 target 和交叉编译器: + +```bash +rustup target add x86_64-pc-windows-gnu +brew install mingw-w64 # 提供 x86_64-w64-mingw32-gcc,zstd-sys 等 C 依赖需要 +``` + +这两条 check 命令用于提前暴露 Linux/Windows 特有的编译错误,**只做类型检查**(不 link)。 + +## IPC / 跨平台同库约定 + +动任何 IPC / 网络代码时:**两端必须用同一个库、同一套 API**。例如 server 用 `interprocess::local_socket::tokio::Listener`,client 就必须用 `interprocess::local_socket::Stream::connect`,不能用 `std::fs::OpenOptions` 打开同名路径——即使 kernel 名字对上了,底层的 framing / overlapped 模式也不兼容。 + +## Cargo.toml 修改规则 + +- 修改版本号后,必须运行 `cargo update --workspace` 更新 Cargo.lock +- 添加/移动 `[target.'cfg(...)'.dependencies]` section 时,确认后续依赖没有被意外归入该 section(TOML section 持续到下一个 header) +- 改完后运行 `cargo check` 验证 + +## Git 规则 + +- 每次 commit 后必须 push(`git push origin main`) +- 打 tag 前确认 `cargo check` 和 `cargo update --workspace` 都已完成 +- remote 使用 `origin` → `git@github.com:botiverse/wx-cli.git`(SSH) +- 不要使用已 DMCA 的 `jackwener/wx-cli` + +## 平台兼容性检查清单 + +改动以下内容时必须做跨平台 check: + +- [ ] `libc::` 调用 → 确认函数在 Linux 和 macOS 都存在(`__error` 是 macOS 专属,用 `std::io::Error::last_os_error()` 代替) +- [ ] `#[cfg(unix)]` 块 → unix 包括 macOS 和 Linux,不能用 macOS 专属 API +- [ ] `Cargo.toml` dependency section 顺序 → 检查是否有 dep 意外落入 target section +- [ ] Windows named pipe 代码 → 确认函数都已定义,trait import 齐全 + +## CI 结构 + +``` +check job(ubuntu) + └── cargo check --target linux-x86, linux-arm64, windows-x86 + ↓ 通过后 +build jobs(5平台并行) + ↓ 全部通过后 +publish-npm job +``` diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..32b22f2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1411 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "doctest-file" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interprocess" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be5e5c847dbdb44564bd85294740d031f4f8aeb3464e5375ef7141f7538db69" +dependencies = [ + "doctest-file", + "futures-core", + "libc", + "recvmsg", + "tokio", + "widestring", + "windows-sys 0.52.0", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-src" +version = "300.5.0+3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8ce546f549326b0e6052b649198487d91320875da901e7bd11a06d1ee3f9c2f" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "recvmsg" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3edd4d5d42c92f0a659926464d4cce56b562761267ecf0f469d85b7de384175" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.140" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91135f59b1cbf38c91e73cf3386fca9bb77915c45ce2771460c9d92f0f3d776" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "wx-cli" +version = "0.6.3" +dependencies = [ + "aes", + "anyhow", + "base64", + "cbc", + "chrono", + "clap", + "dirs", + "hmac", + "interprocess", + "libc", + "md5", + "pbkdf2", + "regex", + "roxmltree", + "rusqlite", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tokio", + "windows", + "zstd", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7dad8b0 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,82 @@ +[package] +name = "wx-cli" +version = "0.6.3" +edition = "2021" +description = "WeChat 4.x (macOS/Linux) local data CLI — decrypt SQLCipher DBs, query chat history, watch new messages" +license = "Apache-2.0" +repository = "https://github.com/botiverse/wx-cli" +keywords = ["wechat", "sqlcipher", "decrypt", "cli"] +categories = ["command-line-utilities"] +readme = "README.md" + +[[bin]] +name = "wx" +path = "src/main.rs" + +[dependencies] +# CLI +clap = { version = "4", features = ["derive"] } + +# 异步 +tokio = { version = "1", features = ["full"] } + +# 序列化 +serde = { version = "1", features = ["derive"] } +serde_json = "=1.0.140" +serde_yaml = "0.9" + +# SQLite + SQLCipher(在线打开加密 WeChat DB,避免全量解密) +rusqlite = { version = "0.31", features = ["bundled-sqlcipher-vendored-openssl"] } + +# 加密 +aes = "0.8" +cbc = { version = "0.1", features = ["alloc"] } +hmac = "0.12" +sha2 = "0.10" +pbkdf2 = "0.12" + +# 解压 +zstd = "0.13" + +# 错误处理 +anyhow = "1" + +# 时间 +chrono = { version = "0.4", features = ["serde"] } + +# 跨平台路径 +dirs = "5" + +# MD5 (联系人表名 Msg_) +md5 = "0.7" + +# 附件 ID 编码(base64url) +base64 = "0.22" + +# 正则表达式 +regex = "1" +roxmltree = "0.20" + +# IPC Windows named pipe(Unix 直接用 tokio::net::UnixListener) +[target.'cfg(windows)'.dependencies] +interprocess = { version = "2", features = ["tokio"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.58", features = [ + "Win32_System_Diagnostics_Debug", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_Threading", + "Win32_Foundation", + "Win32_System_Memory", + "Win32_System_Com", + "Win32_UI_Shell", +] } + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +strip = true diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..33f0899 --- /dev/null +++ b/README.md @@ -0,0 +1,430 @@ +
+ +# wx-cli + +**从命令行查询本地微信数据** + +[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) +[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey.svg)](#安装) +[![Rust](https://img.shields.io/badge/built%20with-Rust-orange.svg)](https://www.rust-lang.org) + +会话 · 聊天记录 · 搜索 · 联系人 · 群成员 · 群昵称 · 收藏 · 统计 · 导出 + +
+ +--- + +## AI Agent Skill + +通过 [skills CLI](https://github.com/vercel-labs/skills) 一键安装到 Claude Code、Cursor、Codex 等 agent: + +```bash +npx skills add botiverse/wx-cli +``` + +或全局安装: + +```bash +npx skills add botiverse/wx-cli -g +``` + +安装后 agent 会自动读取 `SKILL.md`,了解如何安装和调用 wx-cli。 + +源码与发布仓库:[botiverse/wx-cli](https://github.com/botiverse/wx-cli)。 + +--- + +## 特性 + +- **零依赖安装** — 单一 Rust 二进制,一行命令装完 +- **毫秒级响应** — 后台 daemon 持久缓存解密数据库,mtime 不变则复用 +- **AI 友好** — `history` / `search` / `sessions` / `new-messages` / `stats` / `attachments` 默认返回 `{..., meta}` wrapper,agent 能直接消费 freshness / source 信息 +- **完全本地** — 数据不出本机,实时解密,无需全量预解密 + +--- + +## 安装 + +> **当前仓库 [botiverse/wx-cli](https://github.com/botiverse/wx-cli) 为 private。** +> 匿名 `curl` / 公开 npm 旧包(`@jackwener/wx-cli@0.3.0`)**拿不到**本仓库最新二进制。 +> 有仓库读权限时,请用下面的 **源码构建**(推荐)。 + +### 从源码构建(推荐) + +```bash +git clone git@github.com:botiverse/wx-cli.git && cd wx-cli +cargo build --release +# 安装到用户 PATH(覆盖旧版) +mkdir -p ~/.local/bin +cp target/release/wx ~/.local/bin/wx +wx --version # 应显示当前 Cargo.toml 版本,如 0.6.3 +``` + +Windows: + +```powershell +git clone git@github.com:botiverse/wx-cli.git +cd wx-cli +cargo build --release +# 将 target\release\wx.exe 放到 PATH 目录 +``` + +### 已有 clone 时升级 + +```bash +git pull +cargo build --release +cp target/release/wx ~/.local/bin/wx +``` + +
+其他方式(需仓库权限 / 发布配置) + +**GitHub Release 预编译包**(仓库 private 时仅协作者可见) + +从 [Releases](https://github.com/botiverse/wx-cli/releases) 下载: + +| 平台 | 文件 | +|------|------| +| macOS Apple Silicon | `wx-macos-arm64` | +| macOS Intel | `wx-macos-x86_64` | +| Linux x86_64 | `wx-linux-x86_64` | +| Linux arm64 | `wx-linux-arm64` | +| Windows x86_64 | `wx-windows-x86_64.exe` | + +```bash +chmod +x wx-macos-arm64 && mv wx-macos-arm64 ~/.local/bin/wx +``` + +**一键脚本**(raw 链接在 private 仓库下对匿名用户 404;有权限时可用 `gh` 下载 release asset) + +```bash +# 需已登录 gh 且对 botiverse/wx-cli 有读权限 +gh release download -R botiverse/wx-cli -p 'wx-macos-arm64' -O ~/.local/bin/wx +chmod +x ~/.local/bin/wx +``` + +`install.sh` / `install.ps1` 仍维护在仓库内,仓库公开或 raw 可访问后可再启用: + +```bash +curl -fsSL https://raw.githubusercontent.com/botiverse/wx-cli/main/install.sh | bash +``` + +**npm** + +历史包名 `@jackwener/wx-cli` 仍存在于 npm,但公开 registry 上的版本可能严重滞后,**不要**当作当前主安装路径。 + +
+ +--- + +## 快速开始 + +### 新用户要做什么 / 不要做什么 + +| | macOS 新用户 | +|--|--| +| **需要** | 微信 4.x 已安装并**登录**;从**本机 GUI Terminal**(Terminal.app / iTerm 等,不要用 SSH)执行 `sudo wx init` | +| **不需要** | **关闭 SIP** | +| **不需要** | 预先 `codesign` / ad-hoc **重签微信**(默认路径不会改 WeChat.app) | +| **init 之后** | 日常 `wx sessions` / `history` 等**无需 sudo**,也**不要求微信一直开着** | + +### 初始化(只需一次) + +保持微信运行并已登录,然后: + +**macOS** + +```bash +# 必须:本机 GUI Terminal + sudo(系统可能提示授予「开发者工具」权限,请允许) +sudo wx init + +# 若提示缺分片密钥 / meta.unknown_shards 非空:加长 hook,等待期间在微信里点开相关聊天 +sudo wx key extract --hook-seconds 90 +``` + +`wx init` 两阶段取密钥(**都不依赖关 SIP**): + +1. 进程内存扫描(`x'key+salt'` + salt 邻接) +2. LLDB hook `CCCryptorCreate`,补齐尚未加载进内存的 per-DB AES key + +说明: + +- 官方 **Hardened Runtime** 包:本机 Terminal + `sudo` 即可;若失败,到「系统设置 → 隐私与安全性 → 开发者工具」里勾选你的终端。 +- 部分官网 4.x 本身已是 **ad-hoc**:用户态 LLDB 往往也能工作。 +- **不要**默认对 WeChat 做 ad-hoc 重签:会打乱 TCC 权限、公众号/截图可能异常。仅当 SSH 等无 GUI 场景下 `task_for_pid` 仍失败时,才考虑有副作用的重签;详见 [macOS 权限指南](docs/macos-permission-guide.md)。 + +**Linux** + +```bash +sudo wx init +``` + +**Windows**(以管理员身份运行 PowerShell) + +```powershell +wx init +``` + +### 验证 + +```bash +wx --version +wx doctor # 密钥 / 分片 / SQLCipher 健康检查 +wx sessions +``` + +能看到最近会话且 `wx doctor` 关键项通过即表示正常。daemon 在首次查询时自动启动。 + +若 `doctor` 提示关键分片缺密钥,或 `meta.unknown_shards` 非空: + +```bash +sudo wx key extract --hook-seconds 90 +# 等待期间在微信里打开相关聊天(冷分片可能需触发加载) +wx doctor +``` + +--- + +## 命令 + +### 诊断与密钥 + +```bash +wx doctor # 环境 / 密钥 / 分片检查 +wx doctor --fix --json # JSON + 修复建议 +wx key list # 已有密钥与缺失覆盖 +wx key extract --hook-seconds 90 # 建议:sudo wx key extract --hook-seconds 90 +wx key set message/message_1.db <64hex> # 手动写入并校验 +``` + +补密钥统一用 **`sudo wx key extract --hook-seconds 90`**(不要用裸的 `wx init --force` 当主路径)。 +取钥**不需要关闭 SIP**;需本机 GUI Terminal +(Hardened Runtime 包)sudo。 + +### 消息 + +```bash +wx sessions # 最近 20 个会话 +wx unread # 有未读消息的会话 +wx unread --filter private,group # 只看真人未读(过滤公众号/折叠入口) +wx new-messages # 上次检查后的新消息(增量) +wx history "张三" # 最近 50 条记录 +wx history "张三" -n 2000 # 拉更多历史消息 +wx history "AI群" --since 2026-04-01 --until 2026-04-15 +wx search "关键词" # 全库搜索 +wx search "关键词" -n 500 # 放宽搜索结果条数 +wx search "会议" --in "工作群" --since 2026-01-01 +``` + +`history` / `search` / `export` 都支持 `-n` / `--limit` 指定条数。默认值只是为了避免一次性输出过多消息,不是硬上限。 + +会话/消息输出里都带 `chat_type` 字段,取值为 `private` / `group` / `official_account` / `folded`。`official_account` 涵盖公众号、订阅号、服务号及 `mphelper` / `qqsafe` 等系统通知;`folded` 对应微信里的"订阅号折叠"和"折叠群聊"两个聚合入口。 + +群聊里的 `last_sender`、`sender` 和 `stats` 的 `top_senders` 会优先使用群昵称(群名片)。如果本地数据库里没有对应群昵称,则回退到联系人备注、微信昵称或 username。 + +`history` / `search` / `new-messages` / `attachments` 以及 `stats.top_senders`,在群聊上下文里还会附带稳定身份三件套: + +- `sender_username`:稳定 wxid,用来区分两个昵称同名的成员 +- `sender_contact_display`:通讯录里的显示名(备注 > 昵称 > wxid 兜底) +- `sender_group_nickname`:群名片本身(同 `sender` 的来源,方便机器读取时不必再解析) + +解析不到 wxid 时(id2u 没命中且老格式 `wxid_xxx:\n...` 前缀也不存在)这三字段不会输出,避免伪造空字段污染下游过滤。 + +`history` / `search` / `sessions` / `unread` / `new-messages` / `stats` / `attachments` 现在都会附带 `meta`: + +- `status`: `ok` / `possibly_stale` / `possibly_stale_unknown_shards` / `windowed` +- `unknown_shards`: 磁盘上存在、但 daemon 当前没有 key 的 `message_N.db` 分片;非空时应先跑 `sudo wx key extract --hook-seconds 90` +- `chat_latest_timestamp` / `chat_latest_db`: 当前命中数据里最新一条消息的时间和分片来源 +- `session_last_timestamp`: `session.db` 里 WeChat 自己记录的最新时间;如果明显领先于 `chat_latest_timestamp`,说明结果可能漏了消息 + +默认情况下,人类用户会在 stderr 看到可执行的 warning;agent / 脚本可直接读 stdout 里的 `meta`。传 `--with-meta` 会额外返回 `per_shard_latest` / `cache_mode_per_shard`,传隐藏 flag `--debug-source` 还会带真实 `shard_paths`。 + +引用消息会在 `history` / `search` / `new-messages` 输出中显示当前回复和被引用原文: + +```text +[引用] 当前回复 + ↳ 发送者: 被引用内容 +``` + +`--type link` / `--type file` 会包含微信 appmsg 里的链接、文件、合并聊天记录和引用消息等变体;搜索时也会匹配解压后可见的引用原文。 + +### 朋友圈(SNS) + +三个独立命令,区分"通知"和"帖子": + +```bash +wx sns-notifications # 点赞/评论通知(默认仅未读) +wx sns-notifications --include-read -n 100 # 含已读 + +wx sns-feed # 近 20 条朋友圈(时间线) +wx sns-feed --user "张三" # 限定作者 +wx sns-feed --since 2026-04-01 -n 100 # 按时间 + +wx sns-search "关键词" # 全文搜索朋友圈正文 +wx sns-search "婚礼" --user "李四" --since 2023-01-01 +``` + +- **sns-notifications** 返回互动通知:`type`(`like`/`comment`)、`from_nickname`、`content`(评论正文)、`feed_preview` + `feed_author`(对应原帖) +- **sns-feed** / **sns-search** 返回朋友圈帖子:`author`、`content`(正文)、`media`、`media_count`、`location`、`timestamp`;`media` 字段含每张图的 url/thumb/key/token/md5/enc_idx/size,供下游做图片代理或离线渲染。`media_count = media.len()`,按 DOM 解析的合法 `` 子节点计数(malformed XML 返回 0) + +朋友圈数据只覆盖你本地刷到过的帖子(微信 app 按需下载)。 + +### 公众号文章 + +公众号文章推送存在独立的 `biz_message_*.db` 分片,用 `biz-articles` 单独查: + +```bash +wx biz-articles # 最近 50 篇 +wx biz-articles -n 200 # 更多 +wx biz-articles --account "返朴" # 限定公众号(名称模糊匹配) +wx biz-articles --since 2026-05-01 --until 2026-05-10 +wx biz-articles --unread # 仅有未读的公众号,每号取最新 1 篇 +wx biz-articles --json | jq '.[].url' # 下游消费 URL +``` + +每条返回:`account` / `account_username` / `title` / `url` / `digest` / `cover_url` / `time` / `timestamp` / `recv_time_str`。多图文推送会展开成多行。 + +### 附件提取(图片) + +聊天里的附件本体存在 `xwechat_files//msg/attach/...` 下的 `.dat` 文件,需要按消息所在 `message_resource.db` 的 md5 + 平台相关 image key 解码才能拿到原图。 + +```bash +# 1) 列出会话里的图片附件,先拿到不透明的 attachment_id +wx attachments "张三" +wx attachments "AI群" --kind image -n 100 +wx attachments "AI群" --since 2026-04-01 --until 2026-04-15 + +# 2) 把单个 attachment_id 解密写出去(扩展名建议保留 .jpg / .mp4 等) +wx extract -o ~/Desktop/photo.jpg +wx extract -o /tmp/x.jpg --overwrite +``` + +`attachments` 输出每条带:`attachment_id` / `kind` / `type` / `local_id` / `timestamp` / `time`,群聊里还有 `sender` 以及稳定身份三件套 `sender_username` / `sender_contact_display` / `sender_group_nickname`(语义同 `history` / `search` / `new-messages`:`sender_username` 是 wxid,用于两个同名成员之间的稳定区分;解析不到 wxid 时这三字段不输出)。当前 `kind` 固定为 `image`;命令名保留成 `attachments` 是为了后续扩到其他附件类型时不 break CLI。 + +`extract` 输出报告里带:`md5` / `dat_path` / `dat_size` / `output` / `output_size` / `format`(实际识别出的图片格式:jpg / png / gif / webp / hevc 等)/ `decoder`(实际选用的解码器:`legacy_xor` / `v1_aes` / `v2`)。 + +支持的解码档位: +- **legacy XOR**:早期单字节 XOR,无 magic(按文件首字节探测格式自动反推) +- **V1 fixed-AES**(`07 08 V1 08 07`):AES-128-ECB + 固定 key `cfcd208495d565ef` +- **V2 AES + XOR**(`07 08 V2 08 07`):AES-128-ECB + raw + XOR;AES key 平台派生 + +V2 image key 提取: +- **macOS**:`kvcomm` cache(`key__*.statistic` 文件名取 uin → `md5(str(uin) + wxid)[:16]`)+ brute-force fallback(`md5(str(uin))[:4] == wxid_suffix` 枚举 2^24);xor_key = `uin & 0xff`,**不是硬编码 0x88** +- **Windows**:扫 `Weixin.exe` 内存匹配 `[A-Za-z0-9]{32|16}` 候选,按 V2 template ciphertext-block 反验 +- **Linux**:上游空白,遇到 V2 .dat 会报 unsupported + +### 联系人 & 群组 + +```bash +wx contacts # 联系人列表 +wx contacts --query "李" # 按名字搜索 +wx members "AI交流群" # 群成员列表 +``` + +`wx members --json` 返回的成员字段包括: + +- `username`:微信内部 username +- `display`:用于展示的名称,优先使用群昵称 +- `contact_display`:联系人备注或微信昵称 +- `group_nickname`:群昵称;本地没有记录时为空字符串 +- `is_owner`:是否群主 + +### 收藏 & 统计 + +```bash +wx favorites # 全部收藏 +wx favorites --type image # 按类型筛选(text/image/article/card/video) +wx favorites --query "关键词" # 搜索收藏内容 +wx stats "AI群" # 聊天统计 +wx stats "AI群" --since 2026-01-01 # 指定时间范围 +``` + +### 导出 + +```bash +wx export "张三" --format markdown -o chat.md +wx export "张三" -n 2000 --format markdown -o chat.md +wx export "AI群" --since 2026-01-01 --format json +``` + +### 输出格式 + +默认输出 YAML;`--json` 可切换为 JSON。对 agent 而言,`history` / `search` / `sessions` / `new-messages` / `stats` / `attachments` 的 stdout 现在是 wrapper,而不是裸数组: + +```bash +wx sessions --json +wx search "关键词" --json | jq '.results[0].content' +wx new-messages --json +wx history "张三" --json | jq '.meta' +wx history "张三" --json --with-meta | jq '.meta.cache_mode_per_shard' +``` + +### Daemon 管理 + +```bash +wx daemon status +wx daemon stop +wx daemon logs --follow +``` + +--- + +## 架构 + +``` +wx (CLI) ──Unix socket──▶ wx-daemon (后台进程) + │ + ┌─────────┴──────────┐ + DBCache 联系人缓存 + (mtime 感知复用) +``` + +daemon 首次解密后将数据库和 mtime 持久化到 `~/.wx-cli/cache/`。重启后 mtime 未变则直接复用,无需重解密。 + +``` +~/.wx-cli/ +├── config.json # 配置 +├── all_keys.json # 数据库密钥 +├── daemon.sock # Unix socket +├── daemon.pid / .log +└── cache/ + ├── _mtimes.json # mtime 索引 + └── *.db # 解密后的数据库 +``` + +--- + +## 原理 + +微信 4.x 使用 SQLCipher 4 加密本地数据库(AES-256-CBC + HMAC-SHA512,页级 raw key)。每个 DB 有独立的 32-byte AES key;密钥在微信进程打开 DB 时出现在内存中。 + +- **首次**:`sudo wx init` — 内存扫描(`x'key+salt'` + salt 邻接)+ 可选 LLDB hook,写入 `~/.wx-cli/all_keys.json` +- **补齐 / 新分片**:`sudo wx key extract --hook-seconds 90`(与 `init --force` 等价,是产品推荐命令) + +之后 daemon **优先 SQLCipher 在线打开**加密库(无需全量预解密);必要时才解密到 `~/.wx-cli/cache/` 并按 mtime / WAL 增量更新。 + +macOS 取钥依赖本机 Terminal 的 `task_for_pid` / 调试能力,**与是否关闭 SIP 无关**(SIP 保护的是系统组件,不是「能不能读微信内存」的开关)。 + +--- + +## 常见问题 + +| 问题 | 处理 | +|------|------| +| `meta.unknown_shards` / doctor 缺关键分片 | `sudo wx key extract --hook-seconds 90`,等待时打开相关聊天 | +| `wx --version` 偏旧 | `git pull && cargo build --release && cp target/release/wx ~/.local/bin/wx` | +| 是否必须关 SIP? | **否** | +| 是否必须 ad-hoc 重签微信? | **默认否**;仅 SSH/无 GUI 且 attach 失败时考虑 | +| daemon 无响应 | `wx daemon stop` 后任意查询会自动重启 | + +--- + +## 致谢 + +本项目受 [ylytdeng/wechat-decrypt](https://github.com/ylytdeng/wechat-decrypt) 启发,在其基础上进行了重新设计与实现。感谢原作者的研究与探索。 + +--- + +## 免责声明 + +本工具仅用于学习和研究目的,用于解密**自己的**微信数据。请遵守相关法律法规,不得用于未经授权的数据访问。 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..3904031 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,372 @@ +--- +name: wx-cli +description: "wx-cli — 从本地微信数据库查询聊天记录、联系人、会话、收藏等。用户提到微信聊天记录、联系人、消息历史、群成员、收藏内容时,使用此 skill 安装并调用 wx-cli。" +--- + +# wx-cli + +## Triggers + +- 查微信聊天记录 +- 微信消息历史 +- 微信联系人 +- 微信群成员 +- 微信群昵称 / 群名片 +- 微信收藏 +- wechat history / messages / contacts +- wx-cli +- 帮我看看微信里 +- 搜索微信消息 + +## Prerequisites + +- macOS(Apple Silicon / Intel)、Linux 或 Windows +- 微信桌面版 4.x 已安装并**登录** +- Node.js >= 14(npm 安装)或 curl / 源码构建 +- 首次 `wx init` 需要提升权限(macOS/Linux: `sudo`;Windows: 管理员) + +### macOS 新用户注意(常见误区) + +- **不需要关闭 SIP** +- **不需要**预先 `codesign` / ad-hoc 重签 WeChat(默认路径不改 WeChat.app) +- **需要**本机 **GUI Terminal**(Terminal.app / iTerm 等),不要用 SSH 做首次 init +- 系统若弹出「开发者工具」权限,请允许;也可到「系统设置 → 隐私与安全性 → 开发者工具」勾选终端 + +--- + +## 安装 + +源码仓库:[botiverse/wx-cli](https://github.com/botiverse/wx-cli)(当前 **private**)。 + +### 推荐:源码构建(需仓库读权限 + SSH) + +```bash +git clone git@github.com:botiverse/wx-cli.git && cd wx-cli +cargo build --release +mkdir -p ~/.local/bin && cp target/release/wx ~/.local/bin/wx +wx --version +``` + +升级已有安装: + +```bash +cd /path/to/wx-cli && git pull && cargo build --release && cp target/release/wx ~/.local/bin/wx +``` + +### 其他 + +- 有 `gh` 权限时:`gh release download -R botiverse/wx-cli -p 'wx-macos-arm64' -O ~/.local/bin/wx` +- **不要**依赖公开 npm `@jackwener/wx-cli`(registry 上可能是旧版本) +- 匿名 curl `install.sh` 在 private 仓库下会 404 + +--- + +## 初始化(首次使用,只需一次) + +### macOS + +1. 打开并登录微信 +2. 在**本机 GUI Terminal** 执行: + +```bash +sudo wx init +``` + +3. 若缺分片密钥(`unknown_shards` / 匹配数过低),加长 LLDB hook,并在等待期间点开相关聊天: + +```bash +sudo wx key extract --hook-seconds 90 +``` + +`wx init` 会:检测数据目录 → 内存扫描 + LLDB hook 取 per-DB 密钥 → 写入 `~/.wx-cli/config.json` 与 `all_keys.json`。 + +**不要**默认执行 `codesign --force --deep --sign - /Applications/WeChat.app`:会打乱 TCC,导致截图/公众号等权限异常。仅 SSH 等无 GUI 场景下 attach 失败时才考虑有副作用的重签。 + +### Linux + +```bash +sudo wx init +``` + +### Windows(管理员 PowerShell) + +```powershell +wx init +``` + +初始化完成后,后续查询**无需 sudo**,daemon 在首次调用时自动启动;日常查询**不要求微信进程一直运行**。 + +预检: + +```bash +wx doctor --json +wx key list --json +``` + +缺关键分片时**只**推荐:`sudo wx key extract --hook-seconds 90`(等待期间点开相关聊天)。不要循环 `init --force`。 + +--- + +## 命令速查 + +所有命令默认输出 YAML,更省 token & 易读;`--json` 可切换为 JSON(方便 `jq` 处理等)。 + +### 会话与消息 + +```bash +# 最近 20 个会话 +wx sessions + +# 有未读消息的会话 +wx unread + +# 只看真人(私聊 + 群聊)的未读,过滤公众号与折叠入口 +wx unread --filter private,group + +# 上次检查后的新消息(增量) +wx new-messages +wx new-messages --json # JSON 输出,适合 agent 解析 + +# 聊天记录(支持昵称/备注名) +wx history "张三" +wx history "张三" -n 2000 +wx history "AI群" --since 2026-04-01 --until 2026-04-15 -n 100 + +# 全库搜索 +wx search "关键词" +wx search "关键词" -n 500 +wx search "会议" --in "工作群" --since 2026-01-01 +``` + +`history` / `search` / `export` 都支持 `-n` / `--limit` 指定返回条数。默认值只是为了避免一次输出过多,不是硬上限。 + +`sessions` / `unread` / `history` / `new-messages` / `stats` 的输出都带 `chat_type` 字段,agent 可据此分流: + +| 取值 | 含义 | username 特征 | +|------|------|--------------| +| `private` | 真人私聊 | `wxid_*` 或自定义短号 | +| `group` | 群聊 | `*@chatroom` | +| `official_account` | 公众号 / 订阅号 / 服务号 / 系统通知 | `gh_*`、`biz_*`、`mphelper`、`qqsafe`、`@opencustomerservicemsg` | +| `folded` | 折叠入口(订阅号折叠、折叠群聊的聚合条目) | `brandsessionholder`、`@placeholder_foldgroup` | + +`wx unread --filter` 支持 `private` / `group` / `official` / `folded` / `all`,逗号分隔多选。默认 `all`。 + +群聊消息里的 `last_sender`、`sender` 和 `stats.top_senders` 会优先显示群昵称(群名片)。如果本地数据库没有群昵称,再回退到联系人备注、微信昵称或 username。 + +`history` / `search` / `new-messages` / `attachments` 和 `stats.top_senders` 在群上下文里同时输出稳定身份三件套:`sender_username`(稳定 wxid,用来区分同名成员)/ `sender_contact_display`(备注 > 昵称 > wxid 兜底)/ `sender_group_nickname`(群名片,等价于 `sender` 的来源,免去再做字符串解析)。当 wxid 解析不到时,这三字段不会输出,避免空字符串污染下游过滤。 + +`sessions` / `unread` / `history` / `search` / `new-messages` / `stats` / `attachments` 的 stdout 现在统一是 wrapper: + +```json +{ + "messages": [...], + "meta": { + "status": "ok", + "unknown_shards": [], + "chat_latest_timestamp": 1715750400, + "chat_latest_db": "message/message_2.db", + "session_last_timestamp": 1715760000 + } +} +``` + +其中: + +- `status = possibly_stale_unknown_shards`:磁盘上出现 daemon 不认识的新 `message_N.db`,先跑 `sudo wx key extract --hook-seconds 90` +- `status = possibly_stale`:`session.db` 记录的最新时间明显领先于本次查到的最新消息,结果可能漏消息 +- `status = windowed`:这次查询本来就是窗口化/过滤后的局部视图,不应把它当作"全量最新状态" +- `--with-meta`:额外返回 `per_shard_latest` / `cache_mode_per_shard` +- `--debug-source`:在 `--with-meta` 基础上再暴露真实 `shard_paths` + +引用消息(appmsg `type=57`)在 `history` / `search` / `new-messages` 输出里会展开为两行:第一行是当前回复,第二行以 `↳` 开头显示被引用原文,例如: + +```text +[引用] 当前回复 + ↳ 发送者: 被引用内容 +``` + +`--type link` / `--type file` 会覆盖微信 appmsg 的链接、文件、合并聊天记录和引用消息等变体;`search --type link` 也会匹配解压并格式化后的引用原文。 + +### 联系人与群组 + +```bash +# 联系人列表 / 搜索 +wx contacts +wx contacts --query "李" + +# 群成员列表 +wx members "AI交流群" +``` + +`wx members --json` 每个成员包含: + +- `username`:微信内部 username +- `display`:推荐展示名,优先使用群昵称 +- `contact_display`:联系人备注或微信昵称 +- `group_nickname`:群昵称;没有记录时为空字符串 +- `is_owner`:是否群主 + +Agent 展示群成员时优先用 `display`。需要区分群昵称和联系人名时,再读取 `group_nickname` 与 `contact_display`。 + +### 朋友圈(SNS) + +三个命令,作用各不同: + +```bash +# 1) 互动通知(点赞 / 评论,默认仅未读) +wx sns-notifications +wx sns-notifications --include-read --since 2026-04-01 -n 100 + +# 2) 时间线:浏览本地缓存的朋友圈帖子 +wx sns-feed # 近 20 条 +wx sns-feed --user "张三" # 只看某人 +wx sns-feed --since 2026-04-01 --until 2026-04-18 -n 100 + +# 3) 全文搜索:在正文里找关键词 +wx sns-search "关键词" +wx sns-search "婚礼" --user "李四" --since 2023-01-01 -n 50 +``` + +**字段区分**: + +- `sns-notifications` 返回"通知"条目:`type`(`like`/`comment`)、`from_nickname`、`content`(评论正文,点赞为空)、`feed_preview` + `feed_author`(对应的原帖) +- `sns-feed` / `sns-search` 返回"帖子"条目:`author`、`content`(朋友圈正文)、`media`、`media_count`(图片/视频数)、`location`、`timestamp`;`media` 字段含每张图的 url/thumb/key/token/md5/enc_idx/size,供下游做图片代理或离线渲染。`media_count = media.len()`,按 DOM 解析的合法 `` 子节点计数(malformed XML 返回 0) + +> 只保存你本地刷到过的朋友圈(微信 app 按需下载)。没刷到过的帖子不在本地,任何命令都拿不到。 + +### 公众号文章 + +公众号的文章推送存在独立的 `biz_message_*.db` 分片,与普通 `message_0.db` 分开: + +```bash +# 最近 50 篇(默认) +wx biz-articles + +# 更多 +wx biz-articles -n 200 + +# 限定公众号(名称模糊匹配 display name / username) +wx biz-articles --account "返朴" + +# 时间范围(YYYY-MM-DD,发布时间,非接收时间) +wx biz-articles --since 2026-05-01 --until 2026-05-10 + +# 仅有未读消息的公众号,每号取最新 1 篇(适合"今天有什么新推送"扫描) +wx biz-articles --unread +wx biz-articles --unread --account "Datawhale" # 与 --account 取交集 + +# 下游消费:拿 URL 做内容抓取 +wx biz-articles --since 2026-05-10 --json | jq '.[].url' +``` + +每条返回的字段:`account` / `account_username`(`gh_*`)/ `title` / `url`(`mp.weixin.qq.com` 链接)/ `digest` / `cover_url` / `time` + `timestamp`(文章发布时间)/ `recv_time_str` + `recv_time`(微信接收推送的时间)。多图文推送会展开为多行。 + +### 附件提取(图片) + +聊天里的图片本体在 `xwechat_files//msg/attach/...` 下加密存储(`.dat`),需要按消息所在 `message_resource.db` 的 md5 + 平台相关 image key 才能解码。两步走: + +```bash +# 1) 先列出图片附件,拿到不透明的 attachment_id +wx attachments "张三" +wx attachments "AI群" --kind image -n 100 +wx attachments "AI群" --since 2026-04-01 --until 2026-04-15 + +# 2) 用 attachment_id 把单个资源解密写到指定路径 +wx extract -o ~/Desktop/photo.jpg +wx extract -o /tmp/x.jpg --overwrite +``` + +`attachments` 输出每条带:`attachment_id` / `kind`(当前固定 `image`)/ `type` / `local_id` / `timestamp` / `time`,群聊里另带 `sender` 和稳定身份三件套(同上文)。命令名保留成 `attachments` 是为了后续扩到其他附件类型时不 break CLI。 + +`extract` 报告里带:`md5` / `dat_path` / `dat_size` / `output` / `output_size` / `format`(实际识别出的图片格式:jpg / png / gif / webp / hevc 等)/ `decoder`(实际选用的解码器:`legacy_xor` / `v1_aes` / `v2`)。 + +支持的解码档位: +- **legacy XOR**:早期单字节 XOR,无 magic(按文件首字节探测格式自动反推) +- **V1 fixed-AES**(`07 08 V1 08 07`):AES-128-ECB + 固定 key `cfcd208495d565ef` +- **V2 AES + XOR**(`07 08 V2 08 07`):AES-128-ECB + raw + XOR;AES key 平台派生 + +V2 image key 提取(macOS / Windows 自动;Linux 暂不支持): +- macOS:`kvcomm` cache(`key__*.statistic` 文件名取 uin → `md5(str(uin) + wxid)[:16]`)+ brute-force fallback;`xor_key = uin & 0xff` +- Windows:扫 `Weixin.exe` 内存匹配 `[A-Za-z0-9]{32|16}` 候选,按 V2 template ciphertext-block 反验 + +### 收藏与统计 + +```bash +# 全部收藏 +wx favorites + +# 按类型筛选:text / image / article / card / video +wx favorites --type image + +# 搜索收藏内容 +wx favorites --query "关键词" + +# 聊天统计(发言人、消息类型、活跃时段) +wx stats "AI群" +wx stats "AI群" --since 2026-01-01 +``` + +### 导出 + +```bash +# 导出为 Markdown(默认) +wx export "张三" --format markdown -o chat.md +wx export "张三" -n 2000 --format markdown -o chat.md + +# 导出为 JSON +wx export "AI群" --since 2026-01-01 --format json -o chat.json +``` + +### Daemon 管理 + +```bash +wx daemon status +wx daemon stop +wx daemon logs --follow +``` + +--- + +## Agent 使用建议 + +查询结果需要程序处理时,统一加 `--json`: + +```bash +wx sessions --json +wx new-messages --json +wx search "关键词" --json | jq '.results[0]' +wx history "张三" --json -n 50 | jq '.messages[0]' +wx history "张三" --json | jq '.meta' +wx history "张三" --json --with-meta | jq '.meta.cache_mode_per_shard' +``` + +CHAT 参数支持昵称、备注名、微信 ID,模糊匹配。不确定准确名称时,先用 `wx contacts --query` 搜索。 + +--- + +## 数据文件位置 + +``` +~/.wx-cli/ +├── config.json # 配置 +├── all_keys.json # 数据库密钥(敏感,勿分享) +├── daemon.sock # Unix socket +├── daemon.pid / .log +└── cache/ # 解密后的数据库缓存 +``` + +--- + +## 常见问题 + +**是不是必须关 SIP?**:不是。SIP 开着即可;init 用本机 Terminal + sudo。 + +**是不是必须 ad-hoc 重签微信?**:默认不需要。只有 SSH / 无 GUI 且 attach 失败时才考虑,且有副作用。 + +**微信重启后密钥失效 / 新分片没 key**:重新运行 `sudo wx key extract --hook-seconds 90`(微信必须正在运行;等待期间点开相关聊天)。 + +**daemon 无响应**:`wx daemon stop` 后重新调用任意命令自动重启。 + +**找不到聊天**:用 `wx contacts --query` 确认昵称/备注名,或用微信 ID 直接查询。 + +**为什么只能获取 500 条消息?**:这是默认输出条数,不是硬限制。显式传 `-n` 即可,例如 `wx history "张三" -n 2000` 或 `wx export "张三" -n 2000 -o chat.md`。 \ No newline at end of file diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..abbd8fa --- /dev/null +++ b/config.example.json @@ -0,0 +1,6 @@ +{ + "db_dir": "D:\\xwechat_files\\your_wxid\\db_storage", + "keys_file": "all_keys.json", + "decrypted_dir": "decrypted", + "wechat_process": "Weixin.exe" +} diff --git a/docs/macos-3x-vs-4x-decryption-guide.md b/docs/macos-3x-vs-4x-decryption-guide.md new file mode 100644 index 0000000..a728478 --- /dev/null +++ b/docs/macos-3x-vs-4x-decryption-guide.md @@ -0,0 +1,388 @@ +# WeChat macOS 数据库解密指南:3.x vs 4.x 完整对比 + +## 一、背景 + +微信 macOS 版使用 SQLCipher 加密本地数据库。不同大版本的加密参数完全不同,解密方法不能混用。 + +| 项目 | WeChat 3.x (≤3.8.x) | WeChat 4.x (≥4.0.x) | +|------|---------------------|---------------------| +| SQLCipher 版本 | **3** | **4** | +| 默认 page_size | **1024** | **4096** | +| HMAC 算法 | HMAC-**SHA1** (20 bytes) | HMAC-**SHA512** (64 bytes) | +| Reserve 区大小 | **48** bytes (IV16 + HMAC20 + pad12) | **80** bytes (IV16 + HMAC64) | +| KDF 迭代次数 | **64,000** | **256,000** | +| KDF 算法 | PBKDF2-SHA1 | PBKDF2-SHA512 | +| 密钥使用方式 | 32字节 raw key **直接使用** | 32字节 raw key **直接使用** | + +--- + +## 二、数据存放位置 + +### WeChat 3.x + +``` +~/Library/Containers/com.tencent.xinWeChat/Data/ + Library/Application Support/com.tencent.xinWeChat/ + 2.0b4.0.9// + Message/msg_0.db ~ msg_9.db ← 聊天消息 (按hash分片) + Contact/wccontact_new2.db ← 联系人 + Session/session_new.db ← 会话列表 + Group/group_new.db ← 群信息 + Favorites/favorites.db ← 收藏 + ...共约 34 个 DB +``` + +### WeChat 4.x + +``` +~/Library/Containers/com.tencent.xinWeChat/Data/ + Documents/xwechat_files// + db_storage/ + message/message_0.db ~ message_5.db ← 聊天消息 + contact/contact.db ← 联系人 + session/session.db ← 会话列表 + ... +``` + +**关键区别**: 3.x 用 MD5 hash 做账号目录名(看不出是谁),4.x 用微信ID做目录名。 + +--- + +## 三、密钥提取(核心步骤) + +两个版本的密钥提取方式完全一样:**从微信进程内存中读取 32 字节 raw key**。 + +### 前提条件 + +1. 微信已登录且正在运行 +2. 安装 Frida:`pip3 install frida-tools` 或 `brew install frida` +3. 管理员密码(sudo 权限) + +### macOS 权限要求 + +密钥提取需要调用 `task_for_pid()`,能否成功取决于**微信 App 的代码签名状态**: + +- **Ad-hoc 签名**(如安装了防撤回补丁):`sudo` 即可,SSH 也行 +- **Apple 官方签名**(有 Hardened Runtime):需要本机 Terminal + sudo,SSH 不可行 + +```bash +# 检查微信签名状态 +codesign -dv /Applications/WeChat.app 2>&1 | grep -E "Signature|flags" +# Ad-hoc: flags=0x2(adhoc) → sudo 直接可用 +# Apple: flags=0x10000(runtime) → 需本机 Terminal 或先重签名 +``` + +如果需要 SSH 远程操作,可以重签名微信去掉 Hardened Runtime: +```bash +sudo codesign --force --deep --sign - /Applications/WeChat.app +# 重启微信后 SSH sudo 即可提取密钥 +``` + +> 📖 完整的权限模型、SSH 配置、常见误区详见 [macOS 权限完全指南](macos-permission-guide.md) + +### 新手操作步骤 + +根据你的微信签名状态,选择对应方案: + +```bash +# 首先检查你的微信签名状态 +codesign -dv /Applications/WeChat.app 2>&1 | grep -E "Signature|flags" + +# 如果显示 Signature=adhoc, flags=0x2(adhoc) +# → 恭喜!直接 sudo 即可,SSH 也行 +sudo ./find_all_keys_macos + +# 如果显示 Authority=..Apple.., flags 包含 runtime +# → 需要本机 Terminal 操作,或者先重签名: +sudo codesign --force --deep --sign - /Applications/WeChat.app +# 然后重启微信,再用 sudo 提取密钥 +``` + +### SSH 远程提取方案(需 ad-hoc 签名) + +以下方法全部在 Apple 官方签名的微信上失败(经多台机器穷举验证): +- `sudo frida -p ` → "unable to access process" +- `lldb -p ` → "non-interactive debug session" +- `sudo gcore ` → "insufficient privilege" +- 自编译带 `com.apple.security.cs.debugger` entitlement 的 C 程序 → KERN_FAILURE=5 +- `vmmap`/`heap` → 只能看元数据,无法读内存内容 +- LaunchDaemon (root) / LaunchAgent (Aqua) / `launchctl asuser` → 全部失败 +- 修改 TCC.db → SIP 保护,`restricted` 标志,只读 + +### 实际操作步骤 + +#### 方法 A: 使用 C 版扫描器(推荐,4.x) + +```bash +# 编译 +cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation + +# 运行(自动查找微信进程、扫描内存、匹配 DB salt) +sudo ./find_all_keys_macos +``` + +扫描器会在内存中搜索 `x'<64hex_key><32hex_salt>'` 格式的密钥,自动匹配 DB 文件的 salt,输出 `all_keys.json`。 + +#### 方法 B: 使用 Frida(3.x / 通用) + +```bash +# 附加到微信进程,手动 dump 内存搜索 32 字节密钥 +sudo frida -p $(pgrep -x WeChat) -l scan_keys.js +``` + +输出示例(3.x 实际结果): + +``` +600000d8d930 72 8e 8e dd 26 68 48 37 92 89 2c 7b 24 10 58 9d r...&hH7..,{$.X. +600000d8d940 3e 64 1e e7 ef b3 47 c9 9f 17 3d 58 bf 9d 38 05 >d....G...=X..8. +``` + +这 32 字节就是密钥:`728e8edd2668483792892c7b2410589d3e641ee7efb347c99f173d58bf9d3805` + +--- + +## 四、解密实现 + +### 核心原理 + +SQLCipher 加密的每一页(page)结构: + +``` +┌─────────────────────────────────────────────────────┐ +│ 第 1 页 (特殊) │ +├──────────┬──────────────────────┬───────────────────┤ +│ Salt │ 加密的数据 │ Reserve区 │ +│ 16 bytes │ (page_size-16-rsv) │ IV+HMAC+padding │ +├──────────┴──────────────────────┴───────────────────┤ +│ │ +│ 第 2~N 页 (普通页) │ +├────────────────────────────────┬────────────────────┤ +│ 加密的数据 │ Reserve区 │ +│ (page_size - reserve) │ IV + HMAC + padding │ +└────────────────────────────────┴────────────────────┘ +``` + +**第 1 页特殊处理**:前 16 字节是明文 salt(不加密),解密后需要拼回 `SQLite format 3\0` 头。 + +### WeChat 3.x 解密参数 + +```python +# SQLCipher 3 参数 +PAGE_SIZE = 1024 +RESERVE = 48 # 16(IV) + 20(HMAC-SHA1) + 12(padding) +KDF_ITER = 64000 +HMAC_ALGO = 'sha1' +HMAC_LEN = 20 +``` + +### WeChat 4.x 解密参数 + +```python +# SQLCipher 4 参数 +PAGE_SIZE = 4096 +RESERVE = 80 # 16(IV) + 64(HMAC-SHA512) +KDF_ITER = 256000 +HMAC_ALGO = 'sha512' +HMAC_LEN = 64 +``` + +### 3.x 的特殊陷阱:同一账号的 DB 使用不同参数! + +这是 3.x 最坑的地方。我们实测发现同一个账号的 34 个 DB 居然用了 **4 种不同的 SQLCipher 配置**: + +| DB 类别 | page_size | key 模式 | +|---------|-----------|---------| +| 大部分 DB (msg, contact, session...) | 1024 | raw key **直接使用** | +| WebTemplate/webtemplate.db | 4096 | raw key **直接使用** | +| FTS 索引 (ftsmessage, ftsfilemessage) | 1024 | PBKDF2(raw_key, salt, 64000) | +| mediaData.db | 4096 | PBKDF2(raw_key, salt, 64000) | + +还有 3 个 DB 根本没加密(kv_config, solitaire_chat, multiTalk),直接复制即可。 + +所以解密脚本必须自动判断并尝试多种组合。 + +### 完整解密代码(Python, 3.x) + +```python +#!/usr/bin/env python3 +"""WeChat 3.x macOS 数据库解密器""" + +import hashlib, hmac, struct, shutil +from Crypto.Cipher import AES + +def decrypt_page(page_data, enc_key, page_no, page_size, reserve): + """解密单个 page""" + if page_no == 1: + # 第1页: 前16字节是salt(明文), 后面才是加密数据 + salt = page_data[:16] + encrypted = page_data[16:page_size - reserve] + iv = page_data[page_size - reserve:page_size - reserve + 16] + else: + encrypted = page_data[:page_size - reserve] + iv = page_data[page_size - reserve:page_size - reserve + 16] + + cipher = AES.new(enc_key, AES.MODE_CBC, iv) + decrypted = cipher.decrypt(encrypted) + + if page_no == 1: + # 拼回 SQLite 头: "SQLite format 3\0" + 解密内容 + reserve填零 + page = bytearray(b'SQLite format 3\x00' + decrypted + b'\x00' * reserve) + # 清除 header offset 20 的 reserved-space 字段 + # 加密时该字段 = reserve size,解密后需要归零,否则 SQLite 误判 usable page size + page[20] = 0 + return bytes(page) + else: + # Reserve 区填零(SQLite 不读取该区域,清零保持输出干净) + return decrypted + b'\x00' * reserve + + +def verify_hmac_page1(page_data, enc_key, page_size, reserve): + """验证第1页的 HMAC-SHA1 (SQLCipher 3)""" + salt = page_data[:16] + mac_salt = bytes([b ^ 0x3a for b in salt]) + mac_key = hashlib.pbkdf2_hmac('sha1', enc_key, mac_salt, 2, dklen=32) + + content = page_data[16:page_size - reserve] + iv = page_data[page_size - reserve:page_size - reserve + 16] + stored_hmac = page_data[page_size - reserve + 16:page_size - reserve + 36] + + msg = content + iv + struct.pack('/Message/msg_0.db +# 应该显示 "data" 而不是 "SQLite 3.x database" + +# 4. 提取密钥 (必须在本机 Terminal!) +# 方法 A: 使用 C 工具(推荐,见本 repo 的 find_all_keys_macos.c) +cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation +sudo ./find_all_keys_macos +# 输出 all_keys.json,可直接用于解密 + +# 方法 B: 使用 Frida(需自行编写扫描脚本) +# sudo frida -p $(pgrep -x WeChat) -l your_scan_script.js + +# 5. 运行解密(需配置 config.json 指向 db_storage 目录) +python3 decrypt_db.py + +# 6. 验证 +file decrypted/Message/msg_0.db +# 应该显示 "SQLite 3.x database" +sqlite3 decrypted/Message/msg_0.db "SELECT COUNT(*) FROM (SELECT name FROM sqlite_master WHERE type='table')" +``` + +### 常见问题 + +| 问题 | 原因 | 解决 | +|------|------|------| +| Frida 报 "unable to access process" | SSH 下运行 / TCC 未授权 | 必须在本机 Terminal 运行 | +| 解密后文件打不开 | 参数不匹配 | 脚本会自动尝试4种配置 | +| 部分 DB 用不同密钥 | ChatSync.db 等特殊 DB | 非关键数据,可跳过 | +| "No module named Crypto" | 未安装 pycryptodome | `pip3 install pycryptodome` | +| 3.x 和 4.x 混用参数 | 版本判断错误 | 先确认微信版本号 | + +--- + +## 六、总结对比 + +``` +WeChat 3.x WeChat 4.x +────────── ────────── +SQLCipher 3 SQLCipher 4 +page 1024 (混用4096) page 4096 (统一) +HMAC-SHA1, reserve 48 HMAC-SHA512, reserve 80 +KDF 64000 迭代 KDF 256000 迭代 +4种参数组合混用 (坑!) 统一参数 (简单) +msg_0~msg_9.db message_0~message_5.db +Chat_ 表名 不同表结构 +密钥提取方式相同: Frida dump 32字节 密钥提取方式相同 +``` + +**核心经验**: 密钥提取是最难的一步(受 macOS TCC 限制),解密算法本身是确定的。3.x 比 4.x 更复杂,因为同一账号内的数据库使用了不同的加密参数组合。 diff --git a/docs/macos-permission-guide.md b/docs/macos-permission-guide.md new file mode 100644 index 0000000..56ae1ed --- /dev/null +++ b/docs/macos-permission-guide.md @@ -0,0 +1,321 @@ +# macOS WeChat 密钥提取:权限与签名完全指南 + +> 基于多台机器 (macOS 10.15 ~ 15.x, Intel + Apple Silicon) 的实测经验总结。 + +## 核心结论 + +能否从微信进程提取加密密钥,取决于 **两个独立问题**: + +| 问题 | 控制什么 | 关键因素 | +|------|---------|---------| +| `task_for_pid()` 能否成功 | 读取进程内存 | **目标 App 的代码签名** | +| `codesign` 能否重签名 | 修改 App 文件 | **调用者的完全磁盘访问** | + +--- + +## 一、task_for_pid 权限(读取微信内存) + +### 决定因素:微信 App 的 Hardened Runtime + +```bash +# 检查微信签名状态 +codesign -dv /Applications/WeChat.app 2>&1 | grep -E "Signature|flags" +``` + +#### 情况 A:Ad-hoc 签名(无 Hardened Runtime) + +``` +flags=0x2(adhoc) +Signature=adhoc +TeamIdentifier=not set +``` + +**原因**: 安装过防撤回补丁等第三方修改工具,App 被重新签名。 + +**权限要求**: 只需 `sudo`,任何上下文(Terminal、SSH、cron)都能成功。 + +```bash +# SSH 远程直接可用 +sudo ./find_all_keys_macos +``` + +#### 情况 B:Apple 官方签名(有 Hardened Runtime) + +``` +flags=0x10000(runtime) +Signature size=9092 +Authority=...Apple... +``` + +**原因**: App Store 下载或官方 DMG 安装,未经修改。 + +**权限要求**: `sudo` + 本机 GUI 终端 + TCC "开发者工具"授权。SSH **不可行**。 + +``` +taskgated 检查流程: + 目标有 hardened runtime? + YES → 检查调用者的"负责应用"是否有 TCC DeveloperTool 授权 + SSH 的负责应用是 sshd → 无法获得 TCC 授权 → 拒绝 + Terminal.app 可以弹窗获得授权 → 允许 + NO → root (sudo) 即可 → 允许 +``` + +### 实测数据 + +| 机器 | macOS | WeChat 签名 | 本机 Terminal sudo | SSH sudo | +|------|-------|------------|-------------------|---------| +| MacBook (macOS 15.x) | 15.x | **ad-hoc** (防撤回补丁) | ✅ | ✅ | +| Mac mini (Catalina) | 10.15.8 | Apple 官方 runtime | ✅ | ❌ | +| MacBook Pro (Big Sur) | 11.1 | Apple 官方 runtime | ✅ | ❌ | + +### SSH 下穷举过的所有方法(Apple 签名时全部失败) + +| 方法 | 结果 | 错误信息 | +|------|------|---------| +| `sudo frida -p ` | ❌ | unable to access process | +| `lldb -p ` | ❌ | non-interactive debug session | +| `sudo gcore ` | ❌ | insufficient privilege | +| 带 debugger entitlement 的 C 程序 | ❌ | KERN_FAILURE=5 | +| `launchctl asuser` (用户会话) | ❌ | task_for_pid=5 | +| LaunchAgent (Aqua GUI 会话) | ❌ | 非 root,需要 sudo | +| LaunchDaemon (root) | ❌ | 系统域无 GUI 上下文 | +| `launchctl submit` (root) | ❌ | 同上 | +| `osascript` 操控 Terminal.app | ❌ | 需要辅助功能权限/挂起 | +| 修改 TCC.db 给 sshd 授权 | ❌ | SIP 保护,restricted 只读 | +| `vmmap` / `heap` | ⚠️ | 只能看元数据,无法读内存 | + +--- + +## 二、codesign 权限(重签名微信 App) + +如果微信是 Apple 官方签名,需要重签名为 ad-hoc 来解锁 SSH 提取。 + +### 问题:SSH 下 codesign 可能失败 + +``` +$ sudo codesign --force --deep --sign - /Applications/WeChat.app +/Applications/WeChat.app: Operation not permitted +In subcomponent: /Applications/WeChat.app/Contents/MacOS/WeChatAppEx.app +``` + +**原因**: SSH 进程没有「完全磁盘访问」(Full Disk Access, FDA) 权限,无法修改 `/Applications` 下的 App bundle 文件。 + +### 给 SSH 授予完全磁盘访问 + +在目标机器的 **GUI** 上操作: + +``` +系统偏好设置 → 安全性与隐私 → 隐私 → 完全磁盘访问 +点击 🔒 解锁 → 点 + 号 → Cmd+Shift+G 输入路径 +``` + +**必须添加这两个**(缺一不可): + +| 路径 | 说明 | +|------|------| +| `/usr/sbin/sshd` | SSH 守护进程 | +| `/usr/libexec/sshd-keygen-wrapper` | SSH 的实际执行进程(负责应用) | + +> ⚠️ 添加后必须**断开 SSH 重新连接**!TCC 权限在进程启动时检查,不会热更新。 + +### 验证 FDA 是否生效 + +```bash +# 重连 SSH 后执行 +cat ~/Library/Application\ Support/com.apple.TCC/TCC.db > /dev/null 2>&1 && echo "FDA: YES" || echo "FDA: NO" +``` + +TCC.db 是受保护文件,只有 FDA 进程能读取。 + +### 完整流程:SSH 远程重签名微信 + +```bash +# 0. 前提:SSH 已有 FDA(上面的步骤) + +# 1. 确认微信已退出 +kill $(pgrep -x WeChat) 2>/dev/null +sleep 2 +pgrep -x WeChat && echo "还在运行!" || echo "已退出" + +# 2. 清除扩展属性(可选,防止干扰) +sudo xattr -cr /Applications/WeChat.app + +# 3. Ad-hoc 重签名 +sudo codesign --force --deep --sign - /Applications/WeChat.app + +# 4. 验证签名 +codesign -dv /Applications/WeChat.app 2>&1 | grep -E "Signature|flags" +# 期望: flags=0x2(adhoc), Signature=adhoc + +# 5. 用户需在 GUI 上重新打开微信并登录 +# (或者 SSH 执行 open,但用户仍需在 GUI 上完成登录) +open /Applications/WeChat.app +``` + +### 注意事项 + +| 事项 | 说明 | +|------|------| +| 微信必须先退出 | 运行中的 App,其 dylib/binary 被占用,codesign 会报 `internal error` | +| **重签名后必须重启微信** | 已运行的进程仍使用旧签名的内存映像,task_for_pid 仍会失败。必须 kill 后重新启动 | +| 重签名后需重新登录微信 | 签名变更会使登录态失效 | +| 自动更新可能覆盖签名 | 微信更新后变回 Apple 签名,需要再次重签 | +| 小程序可能受影响 | 部分小程序校验签名,ad-hoc 可能报安全错误 | + +--- + +## 三、权限矩阵总结 + +| 操作 | 需要的权限 | SSH 需要额外配置 | +|------|-----------|-----------------| +| 读取微信数据库文件 | 文件系统权限(通常有) | 无 | +| `task_for_pid` (ad-hoc App) | sudo | 无 | +| `task_for_pid` (Apple 签名 App) | sudo + TCC DeveloperTool | **不可行**,必须本机 Terminal | +| `codesign` 重签名 App | sudo + FDA | SSH 需添加 sshd + sshd-keygen-wrapper 到 FDA | +| 修改 TCC.db | sudo + 关闭 SIP | **不推荐** | + +### 完全远程操作清单(一次性 GUI 配置) + +只需在目标机器 GUI 上做一次,之后 SSH 永久可用: + +1. **完全磁盘访问** → 添加 `/usr/sbin/sshd` 和 `/usr/libexec/sshd-keygen-wrapper` +2. SSH 连入 → `sudo codesign --force --deep --sign - /Applications/WeChat.app` +3. 用户在 GUI 重开微信并登录 +4. 之后 SSH 永久可以 `sudo` 提取密钥,微信重启也不影响(除非更新覆盖签名) + +--- + +## 四、常见误区 + +| 误区 | 真相 | +|------|------| +| "需要给终端完全磁盘访问才能调试" | ❌ FDA 控制文件访问,不控制进程调试 | +| "需要给终端开发者工具权限" | ⚠️ 仅当目标 App 有 hardened runtime 时才需要 | +| "SSH 下永远无法提取密钥" | ❌ 目标 App 是 ad-hoc 签名时,SSH sudo 可以 | +| "macOS 版本决定了能否 SSH 调试" | ❌ 主要取决于目标 App 的签名状态 | +| "SIP 阻止了调试微信" | ❌ SIP 只保护系统进程,微信不受 SIP 保护 | +| "加了 sshd 到 FDA 就行" | ❌ 还需要加 `sshd-keygen-wrapper`,且要重连 SSH | +| "微信开着也能重签名" | ❌ 运行中的 binary/dylib 被占用,codesign 会失败 | + +--- + +## 五、重签名后微信权限 silent 失效 + +### 现象 + +完成 ad-hoc 重签名后,微信任意以下功能都可能"看起来已授权但实际被拒绝": + +- 截图 / 屏幕共享(`ScreenCapture`) +- 视频通话 / 扫码(`Camera`) +- 语音消息 / 通话(`Microphone`) +- 自动化、第三方输入法(`AppleEvents`) +- 同步通讯录(`AddressBook`) +- 文件发送 / 接收(`SystemPolicyDocumentsFolder` / `Downloads` / `Desktop`) + +System Settings 里通常仍看到"微信.app"开关是 ON,但运行时权限校验失败。微信会反复弹"需要开启 X 权限"。 + +### 根因(第一性原理) + +macOS TCC(Transparency, Consent, and Control)按 **bundle id + csreq** 联合校验权限。`csreq`(code requirement)是从 app 的 code signature 推导出的二进制 blob,存在 `/Library/Application Support/com.apple.TCC/TCC.db` 的 `access` 表里,每条 ~160 字节。 + +`codesign --force --deep --sign -` 把 WeChat 从官方签名换成 ad-hoc 签名(甚至 ad-hoc → ad-hoc 重签也会变),新进程的 csreq 跟旧记录里那条对不上 —— tccd 拒绝。 + +System Settings UI 只按 client 显示开关、不重算 csreq,所以视觉上是"已授权",运行时实际拒绝。这是 silent drift。 + +### 修复步骤 + +把 WeChat 在 TCC 里的旧记录全部抹掉,让 macOS 在下次微信请求权限时按新签名重新生成 csreq: + +```bash +for s in ScreenCapture Camera Microphone AppleEvents AddressBook \ + SystemPolicyDocumentsFolder SystemPolicyDownloadsFolder SystemPolicyDesktopFolder; do + tccutil reset "$s" com.tencent.xinWeChat +done +``` + +`tccutil` 对没有授权过的 service 会报 "No such bundle identifier",这是 no-op,不影响其他 service 的 reset。 + +之后退出并重新打开微信,按 GUI 提示重新允许: + +```bash +killall WeChat +open /Applications/WeChat.app +``` + +> 这一步**应当由用户/agent 手动执行**,不在 `wx init` 里自动跑——TCC 重置会让用户的现有授权失效,需要由人决定时机。 + +#### macOS 26 的 UI 拆分 + +在 macOS 26 上,**隐私与安全 → 录屏与系统录音** 显示为两块,容易踩坑: + +| 区域 | 作用 | +|------|------| +| **录屏与系统录音**(上半区) | 录制屏幕内容 + 系统音频;微信截图、屏幕共享需要这一项 | +| **仅系统录音**(下半区) | 只录系统音频;只打开这一项**不能**修复微信截图 | + +把 WeChat 加进上半区;只勾下半区的"仅系统录音"无效。 + +### 验证 + +确认 WeChat 当前是 ad-hoc 签名(这是修复前提): + +```bash +codesign -dv --verbose=4 /Applications/WeChat.app 2>&1 | grep -E "Signature|flags|TeamIdentifier" +``` + +期望看到: + +```text +flags=0x2(adhoc) +Signature=adhoc +TeamIdentifier=not set +``` + +最直接的功能验证:在微信里使用截图、视频通话、麦克风等功能,按 GUI 弹窗的"允许"重新授权一次,之后正常工作。 + +--- + +## 六、`"微信" 想访问其他 App 的数据` 弹窗 + +### 现象 + +执行过 `wx init`、对 `/Applications/WeChat.app` 做过 ad-hoc 重签名之后,再使用微信时会比较频繁地看到 macOS 弹出: + +``` +"微信" 想访问其他 App 的数据。 +单独存放 App 数据可让你更容易管理隐私和安全。 +[ 不允许 ] [ 允许 ] +``` + +最常见的触发面是**在微信里打开公众号文章**,但这只是高频触发面,不是根因。 + +### 根因(第一性原理) + +这弹窗是 macOS Ventura+ / 14 / 15 对 **app data container 跨身份访问** 的保护:当前进程("微信")正在读取另一个 code identity 的 app 留下的数据。 + +我们当前 macOS 方案为了让 `task_for_pid` 能拿到 WeChat 的 task port、读取进程内存里的 raw key,要求用户执行: + +```bash +codesign --force --deep --sign - /Applications/WeChat.app +``` + +这一步把 WeChat 从 Apple 官方签名换成 ad-hoc 身份。对用户来说它仍然是"微信";对 macOS 安全模型来说,**重签前的 WeChat** 和 **重签后的 WeChat** 已经不是同一个 app identity。 + +之后当(重签后的)微信访问它原本的 `~/Library/Containers/com.tencent.xinWeChat/...`、缓存、app group 等数据时,系统看到的是"一个新身份在读旧身份留下的 container 数据",于是按隐私保护策略弹这个对话框。公众号文章里的 webview / cookie / 缓存路径刚好踩到了这条访问路径,所以"打开公众号就弹"会非常容易复现,但**本质不是公众号页面的问题**,而是 code identity + container access。 + +> 注意:这**不是** "wx-cli 在偷偷读别的 App 的数据",wx-cli 进程本身对 WeChat container 是只读访问;但**要求用户重签 WeChat** 这一步本身就是这类弹窗的直接诱因。所以这是当前 macOS invasive init 路径的已知副作用,不是与 wx-cli 无关的系统行为。 + +### 应对 + +短期缓解: +- 点"允许"通常只是放行**当前这次** WeChat 进程;下一次 WeChat 启动权限会 reset,可能还会再弹 +- 该授权一般不会在 System Settings 里留下显式开关,因为它绑定的是动态的 code identity + +彻底不弹: +- 把 `/Applications/WeChat.app` 恢复成官方签名(重装官方 WeChat 包),不再执行 `codesign --force --deep --sign -` +- 这一步只是放弃**当前依赖 ad-hoc 重签的默认路径**,并不等于放弃 macOS memory-scan:在本机 GUI Terminal 下、对 Terminal.app 授予「开发者工具」TCC 权限后,`task_for_pid` 对 Apple 官方签名(hardened runtime)的 WeChat 应当仍能走通——参考 §一 实测表里的"Apple 签名 + 本机 Terminal sudo = ✅" +- ⚠️ 实测覆盖范围说明:§一 实测表里 "Apple 签名 + 本机 Terminal sudo ✅" 的两条实证只覆盖 macOS 10.15 (Catalina) 与 11.1 (Big Sur);macOS 14 (Sonoma) / 15 (Sequoia) 上是否仍走通**未在本项目内实测**。如果你按这条路恢复官方签名后发现 init 走不通,请回到重签路径并接受本节描述的弹窗副作用 +- 真正受限的场景是 SSH 远程 + Apple 签名 WeChat:`sshd` 拿不到 TCC 开发者工具授权,这时才必须走重签路径 + +长期方向: +- 这条副作用的真正修复是把 `wx init` 重新设计成 `safe → assisted → invasive fallback` 三层:默认不动 WeChat,只有在前两条都不可行时才走 ad-hoc 重签,并先打出完整副作用清单让用户显式确认。在那之前,这是已知 trade-off。 diff --git a/docs/old-main-port-audit.md b/docs/old-main-port-audit.md new file mode 100644 index 0000000..aa67b75 --- /dev/null +++ b/docs/old-main-port-audit.md @@ -0,0 +1,41 @@ +# old-main → new main 对比与捞回审计 + +基准: +- **新 main 源**:`feat/appmsg-url`(0.5.0 线) +- **old-main**:原 `botiverse/main` tip `6424a21`(0.1.11 线) +- 分叉点:约 `f0dcd4e` + +## 结论摘要 + +| 类别 | 判定 | +|------|------| +| 产品功能(attachment/SNS/biz/appmsg/favorites url) | **两边都有**;feat 侧经 `d5492ea` 恢复/重做,无缺文件 | +| 0.5 独有(doctor/key/timeline/watch/media/online SQLCipher/shard-meta/FTS) | **仅新 main**,不必从 old-main 回捞 | +| 工程硬化(SUDO_USER home、PidFile JSON、stop_daemon、short-page read_exact、new_messages state) | **新 main 已具备** | +| Windows 页保护扫描加宽(#54) | **old-main 更强** → **已捞回** | + +## old-main 独有 commit 逐条 + +| Commit | 主题 | 是否捞回 | +|--------|------|----------| +| e8939f3 / 2b5d872 / c7e2775 | SNS 命令与 media/DOM | 已在新 main,跳过 | +| d750ef6 | sudo home + stop_daemon + ReloadConfig 预留 | 新 main 已有完整实现(含真实 ReloadConfig),跳过 | +| 35a8f0e / b043135 / 1b00d04 / c284b4a | 群昵称 / 引用 / appmsg url / type49 | 已在新 main,跳过 | +| 9d5a78a | macOS TCC 文档 | 文档向;新 README 已改默认「不重签」,跳过 | +| dab3217 / f0f3d3c | biz-articles / favorites url | 已在新 main,跳过 | +| d4587b1 | contacts private 过滤 / search JoinSet / new_messages state | 新 main query 已含等价逻辑,跳过 | +| 70aa3a4 | daemon lifecycle / PidFile / short read / **Windows page protect** | 前三项已有;**page protect 已捞回** | +| 5c001b1 | 0.1.11 bump | 版本已是 0.5.0,跳过 | +| 14fdfde…ff96f95 / 7feacc6 | attachment 全套 + extract 去掉 payload `ok` | 模块在新 main;extract 无重复 `ok`,跳过 | +| b032b8b / e9f65ba / 6424a21 | WAL 增量 cache | 新 main 有 WAL + online + per-key 锁,跳过 | + +## 已实现捞回 + +1. `scanner::is_writable_readable_page`(WinNT 常量 + 单测) +2. `scanner/windows.rs` 扫描条件改回宽匹配(WRITECOPY / EXECUTE_*WRITE* + modifier strip) + +## 明确不捞 + +- 默认 ad-hoc 重签 / 依赖关 SIP 的文档路径(与 0.5 产品方向冲突) +- 把 0.1.11 版本号或 npm 包元数据倒退 +- 机械 cherry-pick 整段 query/cache(会覆盖 online-open 重构) diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..8d62dd7 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,50 @@ +# wx-cli Windows installer +# Run with: irm https://raw.githubusercontent.com/botiverse/wx-cli/main/install.ps1 | iex + +$ErrorActionPreference = "Stop" + +$Repo = "botiverse/wx-cli" +$BinName = "wx.exe" +$Asset = "wx-windows-x86_64.exe" +$InstallDir = "$env:LOCALAPPDATA\wx-cli" + +# ── 获取最新版本 ──────────────────────────────────────────── +Write-Host "正在获取最新版本..." +$Release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" +$Tag = $Release.tag_name + +if (-not $Tag) { + Write-Error "获取版本失败,请检查网络或访问 https://github.com/$Repo/releases" + exit 1 +} + +Write-Host "版本: $Tag" + +# ── 下载 ──────────────────────────────────────────────────── +$Url = "https://github.com/$Repo/releases/download/$Tag/$Asset" +$TmpFile = Join-Path $env:TEMP "wx-cli-download.exe" + +Write-Host "下载中: $Url" +Invoke-WebRequest -Uri $Url -OutFile $TmpFile -UseBasicParsing + +# ── 安装 ──────────────────────────────────────────────────── +if (-not (Test-Path $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir | Out-Null +} + +Move-Item -Force $TmpFile (Join-Path $InstallDir $BinName) + +# ── 加入 PATH(当前用户) ──────────────────────────────────── +$UserPath = [Environment]::GetEnvironmentVariable("PATH", "User") +if ($UserPath -notlike "*$InstallDir*") { + [Environment]::SetEnvironmentVariable("PATH", "$UserPath;$InstallDir", "User") + Write-Host "已将 $InstallDir 加入用户 PATH(重新打开终端生效)" +} + +Write-Host "" +Write-Host "✓ wx 已安装到 $InstallDir\$BinName" +Write-Host "" +Write-Host "快速开始(以管理员身份运行):" +Write-Host " wx init # 首次初始化(需要微信正在运行)" +Write-Host " wx sessions # 查看最近会话" +Write-Host " wx --help # 查看所有命令" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..6abf31f --- /dev/null +++ b/install.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO="botiverse/wx-cli" +BIN_NAME="wx" +INSTALL_DIR="/usr/local/bin" + +# ── 检测平台 ──────────────────────────────────────────────── +OS=$(uname -s) +ARCH=$(uname -m) + +case "${OS}-${ARCH}" in + Darwin-arm64) ASSET="wx-macos-arm64" ;; + Darwin-x86_64) ASSET="wx-macos-x86_64" ;; + Linux-x86_64) ASSET="wx-linux-x86_64" ;; + Linux-aarch64) ASSET="wx-linux-arm64" ;; + *) + echo "不支持的平台: ${OS}-${ARCH}" + echo "请从 https://github.com/${REPO}/releases 手动下载" + exit 1 + ;; +esac + +# ── 获取最新版本号 ────────────────────────────────────────── +echo "正在获取最新版本..." +TAG=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ + | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/') + +if [ -z "$TAG" ]; then + echo "获取版本失败,请检查网络或访问 https://github.com/${REPO}/releases" + exit 1 +fi + +echo "版本: ${TAG} 平台: ${ASSET}" + +# ── 下载 ──────────────────────────────────────────────────── +URL="https://github.com/${REPO}/releases/download/${TAG}/${ASSET}" +TMP=$(mktemp) +trap 'rm -f "$TMP"' EXIT + +echo "下载中: ${URL}" +curl -fsSL --progress-bar -o "$TMP" "$URL" +chmod +x "$TMP" + +# ── 安装 ──────────────────────────────────────────────────── +if [ -w "$INSTALL_DIR" ]; then + mv "$TMP" "${INSTALL_DIR}/${BIN_NAME}" +else + echo "需要 sudo 权限安装到 ${INSTALL_DIR}" + sudo mv "$TMP" "${INSTALL_DIR}/${BIN_NAME}" +fi + +echo "" +echo "✓ wx 已安装到 ${INSTALL_DIR}/${BIN_NAME}" +echo "" +echo "快速开始:" +echo " sudo wx init # 首次初始化(微信须登录运行)" +echo " sudo wx key extract --hook-seconds 90 # 缺分片密钥时补齐" +echo " wx doctor # 环境 / 密钥健康检查" +echo " wx sessions # 查看最近会话" +echo " wx --help" diff --git a/npm/platforms/darwin-arm64/package.json b/npm/platforms/darwin-arm64/package.json new file mode 100644 index 0000000..0c10724 --- /dev/null +++ b/npm/platforms/darwin-arm64/package.json @@ -0,0 +1,18 @@ +{ + "name": "@jackwener/wx-cli-darwin-arm64", + "version": "0.6.3", + "description": "wx-cli binary for macOS arm64", + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "files": [ + "bin/" + ], + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + } +} diff --git a/npm/platforms/darwin-x64/package.json b/npm/platforms/darwin-x64/package.json new file mode 100644 index 0000000..5066b9c --- /dev/null +++ b/npm/platforms/darwin-x64/package.json @@ -0,0 +1,18 @@ +{ + "name": "@jackwener/wx-cli-darwin-x64", + "version": "0.6.3", + "description": "wx-cli binary for macOS x64", + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin/" + ], + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + } +} diff --git a/npm/platforms/linux-arm64/package.json b/npm/platforms/linux-arm64/package.json new file mode 100644 index 0000000..06d3ea3 --- /dev/null +++ b/npm/platforms/linux-arm64/package.json @@ -0,0 +1,18 @@ +{ + "name": "@jackwener/wx-cli-linux-arm64", + "version": "0.6.3", + "description": "wx-cli binary for Linux arm64", + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "files": [ + "bin/" + ], + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + } +} diff --git a/npm/platforms/linux-x64/package.json b/npm/platforms/linux-x64/package.json new file mode 100644 index 0000000..f3063d6 --- /dev/null +++ b/npm/platforms/linux-x64/package.json @@ -0,0 +1,18 @@ +{ + "name": "@jackwener/wx-cli-linux-x64", + "version": "0.6.3", + "description": "wx-cli binary for Linux x64", + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin/" + ], + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + } +} diff --git a/npm/platforms/win32-x64/package.json b/npm/platforms/win32-x64/package.json new file mode 100644 index 0000000..68fead8 --- /dev/null +++ b/npm/platforms/win32-x64/package.json @@ -0,0 +1,18 @@ +{ + "name": "@jackwener/wx-cli-win32-x64", + "version": "0.6.3", + "description": "wx-cli binary for Windows x64", + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "files": [ + "bin/" + ], + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + } +} diff --git a/npm/wx-cli/bin/wx.js b/npm/wx-cli/bin/wx.js new file mode 100644 index 0000000..40a8427 --- /dev/null +++ b/npm/wx-cli/bin/wx.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +'use strict'; + +const { execFileSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +const PLATFORM_PACKAGES = { + 'darwin-arm64': '@jackwener/wx-cli-darwin-arm64', + 'darwin-x64': '@jackwener/wx-cli-darwin-x64', + 'linux-x64': '@jackwener/wx-cli-linux-x64', + 'linux-arm64': '@jackwener/wx-cli-linux-arm64', + 'win32-x64': '@jackwener/wx-cli-win32-x64', +}; + +const platformKey = `${process.platform}-${process.arch}`; +const ext = process.platform === 'win32' ? '.exe' : ''; + +function getBinaryPath() { + if (process.env.WX_CLI_BINARY) { + return process.env.WX_CLI_BINARY; + } + + const pkg = PLATFORM_PACKAGES[platformKey]; + if (!pkg) { + console.error(`wx-cli: unsupported platform ${platformKey}`); + process.exit(1); + } + + try { + return require.resolve(`${pkg}/bin/wx${ext}`); + } catch { + const modPath = path.join( + path.dirname(require.resolve(`${pkg}/package.json`)), + `bin/wx${ext}` + ); + if (fs.existsSync(modPath)) return modPath; + } + + console.error(`wx-cli: binary not found for ${platformKey}`); + console.error('Try: npm install -g @jackwener/wx-cli'); + process.exit(1); +} + +try { + execFileSync(getBinaryPath(), process.argv.slice(2), { + stdio: 'inherit', + env: { ...process.env }, + }); +} catch (e) { + if (e && e.status != null) process.exit(e.status); + throw e; +} diff --git a/npm/wx-cli/install.js b/npm/wx-cli/install.js new file mode 100644 index 0000000..9ed9832 --- /dev/null +++ b/npm/wx-cli/install.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); + +const PLATFORM_PACKAGES = { + 'darwin-arm64': '@jackwener/wx-cli-darwin-arm64', + 'darwin-x64': '@jackwener/wx-cli-darwin-x64', + 'linux-x64': '@jackwener/wx-cli-linux-x64', + 'linux-arm64': '@jackwener/wx-cli-linux-arm64', + 'win32-x64': '@jackwener/wx-cli-win32-x64', +}; + +const platformKey = `${process.platform}-${process.arch}`; +const pkg = PLATFORM_PACKAGES[platformKey]; + +if (!pkg) { + console.log(`wx-cli: no binary for ${platformKey}, skipping`); + process.exit(0); +} + +const ext = process.platform === 'win32' ? '.exe' : ''; + +try { + const binaryPath = require.resolve(`${pkg}/bin/wx${ext}`); + if (process.platform !== 'win32') { + fs.chmodSync(binaryPath, 0o755); + } +} catch { + console.log(`wx-cli: platform package ${pkg} not installed`); +} diff --git a/npm/wx-cli/package.json b/npm/wx-cli/package.json new file mode 100644 index 0000000..436628c --- /dev/null +++ b/npm/wx-cli/package.json @@ -0,0 +1,42 @@ +{ + "name": "@jackwener/wx-cli", + "version": "0.6.3", + "description": "Query your local WeChat data from the command line. Designed for LLM agent tool calls.", + "bin": { + "wx": "bin/wx.js" + }, + "scripts": { + "postinstall": "node install.js" + }, + "files": [ + "bin/", + "install.js" + ], + "optionalDependencies": { + "@jackwener/wx-cli-darwin-arm64": "0.6.3", + "@jackwener/wx-cli-darwin-x64": "0.6.3", + "@jackwener/wx-cli-linux-x64": "0.6.3", + "@jackwener/wx-cli-linux-arm64": "0.6.3", + "@jackwener/wx-cli-win32-x64": "0.6.3" + }, + "engines": { + "node": ">=14" + }, + "keywords": [ + "wechat", + "cli", + "wx", + "llm", + "ai", + "sqlite", + "sqlcipher" + ], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/botiverse/wx-cli" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/src/attachment/attachment_id.rs b/src/attachment/attachment_id.rs new file mode 100644 index 0000000..8af569e --- /dev/null +++ b/src/attachment/attachment_id.rs @@ -0,0 +1,153 @@ +//! 不透明附件 ID — 跨 CLI / IPC 的圆 trip 句柄。 +//! +//! 编码:`base64url_no_pad(serde_json(payload))`。 +//! 选择 base64url(json) 而不是紧凑 bit-pack: +//! - phase 1 求稳,不发明二进制协议 +//! - 后面加字段(`resource_md5` / `decoder_hint` 之类)老 CLI 不 break +//! - debug 直接 base64 -d | jq 看字段 +//! +//! ⚠️ `local_id` 在同一 chat 内会被 WeChat 复用(实测同 chat 最多 7 条同 local_id), +//! 所以 `(chat, local_id, create_time)` 三元组才是定位资源行的最小集。 + +use anyhow::{anyhow, Context, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AttachmentKind { + Image, + Video, + File, + Voice, +} + +impl AttachmentKind { + /// 从 message.local_type 推 attachment kind(只覆盖 phase 1 关心的几种)。 + /// 高 32 bit 是版本/会话 flag,要先 mask 到低 32 bit。 + pub fn from_local_type(local_type: i64) -> Option { + let lo = (local_type as u64) & 0xFFFF_FFFF; + match lo { + 3 => Some(AttachmentKind::Image), + 34 => Some(AttachmentKind::Voice), + 43 => Some(AttachmentKind::Video), + // type=49 是 appmsg,里面 subtype=6 才是文件;这里偏宽松返回 File, + // 由 resolver 进一步根据 appmsg subtype 决定是否真的能 extract + 49 => Some(AttachmentKind::File), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + AttachmentKind::Image => "image", + AttachmentKind::Video => "video", + AttachmentKind::File => "file", + AttachmentKind::Voice => "voice", + } + } +} + +/// 附件 ID payload(序列化后 base64url 编码)。 +/// +/// `v` 是版本字段,将来 schema 变了可以走分支兼容。当前 v=1。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AttachmentId { + /// payload schema version + pub v: u32, + /// 会话 username(同时用于 ChatName2Id 查 chat_id 和拼 attach 路径) + pub chat: String, + /// 消息行的 local_id + pub local_id: i64, + /// 消息行的 create_time(unix 秒)— 用于 disambiguate 同 chat 内 local_id 复用 + pub create_time: i64, + /// 附件类别 + pub kind: AttachmentKind, + /// 可选 hint:消息所在 message_N.db 的 N。给定时 resolver 可跳过 shard 扫描; + /// 缺省时 resolver 会按 `find_msg_tables` 逻辑全量扫 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub db: Option, +} + +impl AttachmentId { + pub fn encode(&self) -> Result { + let json = serde_json::to_vec(self).context("序列化 AttachmentId")?; + Ok(URL_SAFE_NO_PAD.encode(json)) + } + + pub fn decode(s: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(s.trim()) + .map_err(|e| anyhow!("attachment_id 不是合法 base64url: {}", e))?; + let id: AttachmentId = + serde_json::from_slice(&bytes).context("attachment_id payload 非合法 JSON")?; + if id.v != 1 { + return Err(anyhow!("不支持的 attachment_id 版本 v={}", id.v)); + } + Ok(id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_minimal() { + let id = AttachmentId { + v: 1, + chat: "wxid_abc".to_string(), + local_id: 12345, + create_time: 1_715_678_901, + kind: AttachmentKind::Image, + db: None, + }; + let s = id.encode().unwrap(); + let back = AttachmentId::decode(&s).unwrap(); + assert_eq!(back.chat, id.chat); + assert_eq!(back.local_id, id.local_id); + assert_eq!(back.create_time, id.create_time); + assert_eq!(back.kind, id.kind); + assert_eq!(back.db, id.db); + } + + #[test] + fn round_trip_with_db_hint() { + let id = AttachmentId { + v: 1, + chat: "1234@chatroom".to_string(), + local_id: 42, + create_time: 1, + kind: AttachmentKind::Image, + db: Some(2), + }; + let s = id.encode().unwrap(); + assert!(!s.contains('=')); // base64url no-pad + let back = AttachmentId::decode(&s).unwrap(); + assert_eq!(back.db, Some(2)); + } + + #[test] + fn local_type_mask_high_bits() { + // monitor_web.py 里 image push 路径:高位带 flag,低 32 bit 是 3 + let high_flag = (0xDEAD_BEEFu64 << 32) as i64 | 3; + assert_eq!( + AttachmentKind::from_local_type(high_flag), + Some(AttachmentKind::Image) + ); + } + + #[test] + fn rejects_unknown_version() { + let id = AttachmentId { + v: 99, + chat: "x".to_string(), + local_id: 0, + create_time: 0, + kind: AttachmentKind::Image, + db: None, + }; + let s = id.encode().unwrap(); + assert!(AttachmentId::decode(&s).is_err()); + } +} diff --git a/src/attachment/decoder/mod.rs b/src/attachment/decoder/mod.rs new file mode 100644 index 0000000..a5723c5 --- /dev/null +++ b/src/attachment/decoder/mod.rs @@ -0,0 +1,122 @@ +//! `.dat` 文件解码:根据 6B header magic 分发到具体 decoder。 +//! +//! 三档: +//! | header[0..6] | decoder | 备注 | +//! |-------------------------|-------------------|-----------------------------------------| +//! | `07 08 V2 08 07` | `v2` | AES-128-ECB + XOR 混合,需要 image AES key | +//! | `07 08 V1 08 07` | `v1_aes` | 固定 AES key `cfcd208495d565ef` | +//! | (其他, 通常无 magic) | `v1_xor` | legacy single-byte XOR,magic 自动探测 | +//! +//! 决策点放在 `dispatch`,让上层(`resolver` / CLI extract 命令)只跟一个入口打交道。 + +use anyhow::{anyhow, Result}; + +pub mod v1_xor; +pub mod v2; + +/// 完整 V2 magic:`\x07\x08V2\x08\x07` +pub const V2_MAGIC: [u8; 6] = [0x07, 0x08, b'V', b'2', 0x08, 0x07]; +/// 完整 V1 magic:`\x07\x08V1\x08\x07` +pub const V1_MAGIC: [u8; 6] = [0x07, 0x08, b'V', b'1', 0x08, 0x07]; + +/// 解码后的产物 + 探测出的图片格式 +#[derive(Debug)] +pub struct DecodedImage { + pub data: Vec, + /// 推断出的图片扩展名(不带点),由 magic 决定。例如 "jpg" / "png" / "gif" / "webp" / + /// "tif" / "bmp" / "hevc"(wxgf 容器)/ "bin"(未识别) + pub format: &'static str, + /// 解码器名称("legacy_xor" / "v1_aes" / "v2"),用于 CLI 调试输出 + pub decoder: &'static str, +} + +/// 由 caller 提供的 V2 image AES key(codex 的 `image_key` 模块负责拿到)。 +/// 缺省时遇到 V2 文件会返回 `Err`,caller 可以拿到具体错误信息再处理。 +#[derive(Debug, Clone, Copy, Default)] +pub struct V2KeyMaterial<'a> { + pub aes_key: Option<&'a [u8; 16]>, + /// XOR key — WeChat 4.x 默认 0x88,可 override + pub xor_key: u8, +} + +impl<'a> V2KeyMaterial<'a> { + pub fn with_aes(key: &'a [u8; 16]) -> Self { + Self { aes_key: Some(key), xor_key: 0x88 } + } +} + +/// 根据 `dat_bytes` 头部 magic 自动分发到对应 decoder。 +/// +/// `v2_key` 仅在文件是 V2 magic 时被消费。 +pub fn dispatch(dat_bytes: &[u8], v2_key: V2KeyMaterial<'_>) -> Result { + if dat_bytes.len() >= 6 { + let head: &[u8; 6] = dat_bytes[..6].try_into().unwrap(); + if head == &V2_MAGIC { + return v2::decode(dat_bytes, v2_key); + } + if head == &V1_MAGIC { + // V1 fixed-AES: 固定 key = md5("0")[:16] = "cfcd208495d565ef" + let fixed_key: [u8; 16] = *b"cfcd208495d565ef"; + return v2::decode( + dat_bytes, + V2KeyMaterial { aes_key: Some(&fixed_key), xor_key: v2_key.xor_key }, + ) + .map(|mut d| { + d.decoder = "v1_aes"; + d + }); + } + } + if dat_bytes.is_empty() { + return Err(anyhow!("空 .dat 文件")); + } + v1_xor::decode(dat_bytes) +} + +/// 从解密后的字节流头部探测图片格式扩展名。 +/// +/// 与上游 `decode_image.py::detect_image_format` 一致;新增 wxgf (HEVC 裸流) 的探测, +/// 因为 V2 解码后产物可能直接是 wxgf 容器。 +pub fn detect_image_format(bytes: &[u8]) -> &'static str { + if bytes.len() >= 4 && &bytes[..4] == b"wxgf" { + return "hevc"; + } + if bytes.len() >= 3 && bytes[..3] == [0xFF, 0xD8, 0xFF] { + return "jpg"; + } + if bytes.len() >= 4 && bytes[..4] == [0x89, 0x50, 0x4E, 0x47] { + return "png"; + } + if bytes.len() >= 3 && &bytes[..3] == b"GIF" { + return "gif"; + } + if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + return "webp"; + } + if bytes.len() >= 4 && bytes[..4] == [0x49, 0x49, 0x2A, 0x00] { + return "tif"; + } + if bytes.len() >= 2 && &bytes[..2] == b"BM" { + return "bmp"; + } + "bin" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detect_basic_formats() { + assert_eq!(detect_image_format(&[0xFF, 0xD8, 0xFF, 0xE0]), "jpg"); + assert_eq!(detect_image_format(&[0x89, 0x50, 0x4E, 0x47]), "png"); + assert_eq!(detect_image_format(b"GIF89a"), "gif"); + assert_eq!(detect_image_format(b"BM\0\0\0\0\0\0\0\0\0\0\0\0"), "bmp"); + let mut webp = b"RIFF\0\0\0\0WEBP".to_vec(); + webp.extend_from_slice(&[0; 4]); + assert_eq!(detect_image_format(&webp), "webp"); + assert_eq!(detect_image_format(&[0x49, 0x49, 0x2A, 0x00]), "tif"); + assert_eq!(detect_image_format(b"wxgfXXXX"), "hevc"); + assert_eq!(detect_image_format(&[0, 0, 0, 0]), "bin"); + } +} diff --git a/src/attachment/decoder/v1_xor.rs b/src/attachment/decoder/v1_xor.rs new file mode 100644 index 0000000..788383e --- /dev/null +++ b/src/attachment/decoder/v1_xor.rs @@ -0,0 +1,166 @@ +//! Legacy single-byte XOR decoder(无 magic 头的旧 .dat) +//! +//! 算法:用已知图片 magic 反推 XOR key —— `key = file[0] ^ magic[0]`。 +//! 然后用同一个 key 校验 `file[i] ^ key == magic[i]`,全部命中才接受这个 key。 +//! +//! 优先级(按 magic 长度降序,避免短 magic 假阳性): +//! PNG (4) > GIF (4) > TIF (4) > WEBP (4, RIFF) > JPG (3) > BMP (2, 需额外校验) +//! +//! BMP 只有 2 字节 magic,假阳性高;额外用 BMP file header 里的 +//! `bf_size`(offset 2, u32 LE)和 `bf_offset`(offset 10, u32 LE)做合理性校验: +//! - `|bf_size - file_size| < 1024`(允许微小 padding 差) +//! - `14 <= bf_offset <= 1078`(最大调色板 256*4 + header 14 = 1038,留点余量) + +use anyhow::{anyhow, Result}; + +use super::{detect_image_format, DecodedImage}; + +const PNG: &[u8] = &[0x89, 0x50, 0x4E, 0x47]; +const GIF: &[u8] = &[0x47, 0x49, 0x46, 0x38]; +const TIF: &[u8] = &[0x49, 0x49, 0x2A, 0x00]; +const WEBP_RIFF: &[u8] = &[0x52, 0x49, 0x46, 0x46]; +const JPG: &[u8] = &[0xFF, 0xD8, 0xFF]; +const BMP: &[u8] = &[0x42, 0x4D]; + +/// 在 `header` 上尝试一个固定 magic:返回 `Some(key)` 当且仅当所有字节都对得上。 +fn try_magic(header: &[u8], magic: &[u8]) -> Option { + if header.len() < magic.len() { + return None; + } + let key = header[0] ^ magic[0]; + for i in 1..magic.len() { + if header[i] ^ key != magic[i] { + return None; + } + } + Some(key) +} + +/// 探测 XOR key。失败返回 `None`(caller 决定是不是错)。 +pub fn detect_key(file_bytes: &[u8]) -> Option { + if file_bytes.len() < 4 { + return None; + } + let header = &file_bytes[..file_bytes.len().min(16)]; + + // 先试 3+ 字节 magic + for magic in [PNG, GIF, TIF, WEBP_RIFF, JPG] { + if let Some(k) = try_magic(header, magic) { + return Some(k); + } + } + + // 最后试 BMP(只有 2B magic,需额外校验) + if let Some(k) = try_magic(header, BMP) { + if header.len() >= 14 { + // 解 BMP file header 14 字节 + let mut dec = [0u8; 14]; + for i in 0..14 { + dec[i] = header[i] ^ k; + } + let bmp_size = u32::from_le_bytes([dec[2], dec[3], dec[4], dec[5]]); + let bmp_offset = u32::from_le_bytes([dec[10], dec[11], dec[12], dec[13]]); + let file_size = file_bytes.len() as u32; + // 允许 1024 字节 padding 差;offset 在合理范围 + if file_size.abs_diff(bmp_size) < 1024 && (14..=1078).contains(&bmp_offset) { + return Some(k); + } + } + } + + None +} + +/// XOR 解码整个 `.dat` 内容。 +pub fn decode(file_bytes: &[u8]) -> Result { + let key = + detect_key(file_bytes).ok_or_else(|| anyhow!("legacy XOR: 无法识别图片 magic(key 探测失败)"))?; + let data: Vec = file_bytes.iter().map(|b| b ^ key).collect(); + let format = detect_image_format(&data); + if format == "bin" { + return Err(anyhow!("legacy XOR: 解出 key=0x{:02x} 但产物 magic 不识别", key)); + } + Ok(DecodedImage { data, format, decoder: "legacy_xor" }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 把一段 plaintext 用单字节 key XOR 加密,模拟 .dat 文件 + fn xor_encrypt(plain: &[u8], key: u8) -> Vec { + plain.iter().map(|b| b ^ key).collect() + } + + #[test] + fn detect_jpg_key() { + let plain = vec![0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46]; + let enc = xor_encrypt(&plain, 0x3C); + assert_eq!(detect_key(&enc), Some(0x3C)); + } + + #[test] + fn detect_png_key() { + let mut plain = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + plain.extend_from_slice(&[0; 16]); + let enc = xor_encrypt(&plain, 0xA5); + assert_eq!(detect_key(&enc), Some(0xA5)); + } + + #[test] + fn detect_gif_key() { + let mut plain = b"GIF89a".to_vec(); + plain.extend_from_slice(&[0; 16]); + let enc = xor_encrypt(&plain, 0x77); + assert_eq!(detect_key(&enc), Some(0x77)); + } + + #[test] + fn detect_webp_riff_key() { + let mut plain = b"RIFF\x00\x00\x00\x00WEBP".to_vec(); + plain.extend_from_slice(&[0; 8]); + let enc = xor_encrypt(&plain, 0x12); + assert_eq!(detect_key(&enc), Some(0x12)); + } + + #[test] + fn detect_tif_key() { + let mut plain = vec![0x49, 0x49, 0x2A, 0x00, 0x08, 0x00, 0x00, 0x00]; + plain.extend_from_slice(&[0; 16]); + let enc = xor_encrypt(&plain, 0xC3); + assert_eq!(detect_key(&enc), Some(0xC3)); + } + + #[test] + fn detect_bmp_with_valid_header() { + // BMP 14B header: 'BM' + size(u32 LE) + reserved(2*u16) + offset(u32 LE) + let mut plain = Vec::new(); + plain.extend_from_slice(b"BM"); + plain.extend_from_slice(&100u32.to_le_bytes()); // file_size = 100 + plain.extend_from_slice(&[0; 4]); // reserved + plain.extend_from_slice(&54u32.to_le_bytes()); // pixel data offset = 54 + plain.resize(100, 0); // 整个文件 100 字节,匹配 file_size + let enc = xor_encrypt(&plain, 0x55); + assert_eq!(detect_key(&enc), Some(0x55)); + } + + #[test] + fn reject_random_bytes() { + // 全 0 文件:BMP 检测会算出 key = 0x42 ^ 0 = 0x42, + // 但解密出的 BMP file_size = 0 vs file_size = 100,差距 > 1024 → + // 应该 reject + let bytes = vec![0u8; 100]; + assert_eq!(detect_key(&bytes), None); + } + + #[test] + fn decode_round_trip_jpg() { + let mut plain = vec![0xFF, 0xD8, 0xFF, 0xE0]; + plain.extend_from_slice(b"JFIF padding here"); + let enc = xor_encrypt(&plain, 0xAB); + let out = decode(&enc).unwrap(); + assert_eq!(out.format, "jpg"); + assert_eq!(out.decoder, "legacy_xor"); + assert_eq!(out.data, plain); + } +} diff --git a/src/attachment/decoder/v2.rs b/src/attachment/decoder/v2.rs new file mode 100644 index 0000000..1c90f29 --- /dev/null +++ b/src/attachment/decoder/v2.rs @@ -0,0 +1,130 @@ +//! V2 .dat 解码:`AES-128-ECB(PKCS7) + raw + XOR` 三段拼接。 +//! +//! 文件结构(来自上游 `decode_image.py::v2_decrypt_file`): +//! `[6B magic V2/V1] [4B aes_size LE] [4B xor_size LE] [1B padding]` +//! `[aligned_aes_size bytes AES-ECB ciphertext]` +//! `[len - aligned_aes_size - xor_size bytes raw_data (不加密)]` +//! `[xor_size bytes XOR (单字节 key)]` +//! +//! `aligned_aes_size`:把 `aes_size` 向上对齐到 16 的倍数;当 `aes_size` 本身是 +//! 16 的倍数时,PKCS7 还会再加一整块 padding,所以再 +16。等价于 +//! `aes_size + (16 - aes_size % 16)`。 +//! +//! ⚠️ 此模块由 codex 落地完整 V2 实现 + image key 模块。当前只提供一个 +//! `decode` 入口骨架,方便 v1_aes 路径(固定 key)和 dispatch 一起编译过。 +//! `aes_key=None` 时返回带具体诊断信息的错误。 + +use anyhow::{anyhow, bail, Result}; + +use super::{detect_image_format, DecodedImage, V2KeyMaterial, V1_MAGIC, V2_MAGIC}; + +const HEADER_SIZE: usize = 15; + +pub fn decode(file_bytes: &[u8], key: V2KeyMaterial<'_>) -> Result { + if file_bytes.len() < HEADER_SIZE { + bail!("V2 .dat: 文件过短({} < {} 字节)", file_bytes.len(), HEADER_SIZE); + } + let magic: &[u8; 6] = file_bytes[..6].try_into().unwrap(); + if magic != &V2_MAGIC && magic != &V1_MAGIC { + bail!("V2 .dat: header magic 不匹配 V1/V2"); + } + + let aes_key = key.aes_key.ok_or_else(|| { + anyhow!("V2 .dat: 需要 image AES key(codex 的 image_key 模块尚未填充)") + })?; + + let aes_size = u32::from_le_bytes(file_bytes[6..10].try_into().unwrap()) as usize; + let xor_size = u32::from_le_bytes(file_bytes[10..14].try_into().unwrap()) as usize; + + // PKCS7 对齐:aes_size 不是 16 的倍数 → 向上对齐;是 16 的倍数 → 再加一整块 + let aligned_aes_size = aes_size + (16 - (aes_size % 16)); + + let aes_end = HEADER_SIZE.checked_add(aligned_aes_size).ok_or_else(|| anyhow!("aes 段长度溢出"))?; + if aes_end > file_bytes.len() { + bail!( + "V2 .dat: 头部宣称 aes_size={} (aligned={}) 超过文件长度 {}", + aes_size, + aligned_aes_size, + file_bytes.len() + ); + } + let raw_end = file_bytes.len().checked_sub(xor_size).ok_or_else(|| { + anyhow!("V2 .dat: 头部宣称 xor_size={} 超过文件长度 {}", xor_size, file_bytes.len()) + })?; + if aes_end > raw_end { + bail!( + "V2 .dat: aes_end={} > raw_end={}(aes/xor 段重叠)", + aes_end, + raw_end + ); + } + + // === AES-128-ECB 解密 + PKCS7 unpad === + let aes_data = &file_bytes[HEADER_SIZE..aes_end]; + let dec_aes = aes_ecb_decrypt_pkcs7(aes_key, aes_data)?; + + // === Raw 段(未加密) === + let raw_data = &file_bytes[aes_end..raw_end]; + + // === XOR 段 === + let xor_data: Vec = file_bytes[raw_end..].iter().map(|b| b ^ key.xor_key).collect(); + + let mut out = Vec::with_capacity(dec_aes.len() + raw_data.len() + xor_data.len()); + out.extend_from_slice(&dec_aes); + out.extend_from_slice(raw_data); + out.extend_from_slice(&xor_data); + + let format = detect_image_format(&out); + if format == "bin" { + bail!("V2 .dat: AES 解密成功但产物 magic 不识别(key 可能错)"); + } + Ok(DecodedImage { data: out, format, decoder: "v2" }) +} + +/// AES-128-ECB 解密 + PKCS7 unpad。失败时返回 `Err`,不返回半结果。 +/// +/// 不引第三方 ECB 包;ECB 本身就是 block-by-block,手工跑就行。 +/// PKCS7 padding 由本函数最后一段做 strict 校验:长度 1..=16,且尾部全是同值字节。 +fn aes_ecb_decrypt_pkcs7(key: &[u8; 16], cipher: &[u8]) -> Result> { + use aes::cipher::{generic_array::GenericArray, BlockDecrypt, KeyInit}; + if cipher.is_empty() || cipher.len() % 16 != 0 { + bail!("AES 输入长度 {} 不是 16 的倍数", cipher.len()); + } + let aes = aes::Aes128::new(key.into()); + let mut out = Vec::with_capacity(cipher.len()); + for chunk in cipher.chunks_exact(16) { + let mut block = GenericArray::clone_from_slice(chunk); + aes.decrypt_block(&mut block); + out.extend_from_slice(&block); + } + let pad = *out.last().ok_or_else(|| anyhow!("AES PKCS7: 空输出"))? as usize; + if pad == 0 || pad > 16 || pad > out.len() { + bail!("AES PKCS7: 非法 padding 长度 {}", pad); + } + let tail = &out[out.len() - pad..]; + if !tail.iter().all(|&b| b as usize == pad) { + bail!("AES PKCS7: padding 字节不一致"); + } + out.truncate(out.len() - pad); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_short_file() { + let r = decode(&[0u8; 4], V2KeyMaterial::default()); + assert!(r.is_err()); + } + + #[test] + fn rejects_v2_without_key() { + let mut buf = V2_MAGIC.to_vec(); + buf.extend_from_slice(&[0u8; HEADER_SIZE - 6]); + let r = decode(&buf, V2KeyMaterial::default()); + let err = r.unwrap_err().to_string(); + assert!(err.contains("AES key"), "{}", err); + } +} diff --git a/src/attachment/image_key/linux.rs b/src/attachment/image_key/linux.rs new file mode 100644 index 0000000..4100ab2 --- /dev/null +++ b/src/attachment/image_key/linux.rs @@ -0,0 +1,11 @@ +use anyhow::{bail, Result}; + +use super::{ImageKeyMaterial, ImageKeyProvider}; + +pub struct LinuxImageKeyProvider; + +impl ImageKeyProvider for LinuxImageKeyProvider { + fn get_key(&self, _wxid: &str) -> Result { + bail!("Linux V2 图片 key 当前未实现;请先用 legacy/V1 图片或在 README 中标注 unsupported") + } +} diff --git a/src/attachment/image_key/macos.rs b/src/attachment/image_key/macos.rs new file mode 100644 index 0000000..127d81c --- /dev/null +++ b/src/attachment/image_key/macos.rs @@ -0,0 +1,423 @@ +//! macOS V2 image AES key 提取。 +//! +//! 主路径:从 `key__*.statistic` 文件名拿 uin,然后 +//! `md5(str(uin) + normalize(wxid)).hex()[:16]` 派生 AES key。 +//! +//! fallback:通过 `md5(str(uin))[:4] == wxid_suffix` + `uin & 0xff == xor_key` +//! 把搜索空间压到 2^24,再用 V2 模板反验 AES key。 + +use anyhow::{bail, Context, Result}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; + +use crate::config; + +use super::{ + attach_root_for_db_dir, configured_db_dir_for_wxid, derive_xor_key_from_v2_dat, + find_v2_template_ciphertexts, join_components, normalize_wxid, verify_aes_key, wxid_from_db_dir, + ImageKeyMaterial, ImageKeyProvider, +}; + +pub struct MacosImageKeyProvider { + configured_db_dir: Result, + cache: Mutex>, +} + +impl MacosImageKeyProvider { + pub fn from_current_config() -> Self { + let configured_db_dir = config::load_config() + .map(|cfg| cfg.db_dir) + .map_err(|err| err.to_string()); + Self { + configured_db_dir, + cache: Mutex::new(HashMap::new()), + } + } +} + +impl ImageKeyProvider for MacosImageKeyProvider { + fn get_key(&self, wxid: &str) -> Result { + let cache_key = normalize_wxid(wxid); + if let Some(found) = self.cache.lock().unwrap().get(&cache_key).copied() { + return Ok(found); + } + + let configured_db_dir = self + .configured_db_dir + .as_ref() + .map_err(|err| anyhow::anyhow!("读取 config.db_dir 失败: {}", err))?; + let db_dir = configured_db_dir_for_wxid(configured_db_dir, wxid); + let attach_dir = attach_root_for_db_dir(&db_dir); + let key = derive_key_for_paths(&db_dir, &attach_dir)?; + self.cache.lock().unwrap().insert(cache_key, key); + Ok(key) + } +} + +fn derive_key_for_paths(db_dir: &Path, attach_dir: &Path) -> Result { + let templates = find_v2_template_ciphertexts(attach_dir, 3, 64)?; + if templates.is_empty() { + bail!("在 {} 下找不到 V2 模板文件", attach_dir.display()); + } + + if let Some(found) = find_via_kvcomm(db_dir, &templates)? { + return Ok(found); + } + + let (wxid_full, wxid_norm, suffix) = + extract_wxid_parts(db_dir).context("db_dir 不含可用于 fallback 的 wxid 4 位后缀")?; + let (xor_key, _votes, _total) = derive_xor_key_from_v2_dat(attach_dir, 10, 3)? + .context("V2 .dat 样本不足,无法投票反推 xor_key")?; + + for wxid in preferred_wxid_candidates(&wxid_full, &wxid_norm) { + if let Some(aes_key) = bruteforce_aes_key(xor_key, &suffix, wxid, &templates)? { + return Ok(ImageKeyMaterial { aes_key, xor_key }); + } + } + + bail!("macOS V2 图片 key 派生失败") +} + +fn find_via_kvcomm(db_dir: &Path, templates: &[[u8; 16]]) -> Result> { + let Some(kvcomm_dir) = find_existing_kvcomm_dir(db_dir) else { + return Ok(None); + }; + + let codes = collect_kvcomm_codes(&kvcomm_dir)?; + if codes.is_empty() { + return Ok(None); + } + let wxids = collect_wxid_candidates(db_dir); + if wxids.is_empty() { + return Ok(None); + } + + for wxid in wxids { + for code in &codes { + let candidate = derive_image_key_material(*code, &wxid); + if verify_aes_key(&candidate.aes_key, templates) { + return Ok(Some(candidate)); + } + } + } + Ok(None) +} + +fn derive_image_key_material(code: u32, wxid: &str) -> ImageKeyMaterial { + let xor_key = (code & 0xFF) as u8; + let digest = format!("{:x}", md5::compute(format!("{}{}", code, wxid))); + let mut aes_key = [0u8; 16]; + aes_key.copy_from_slice(&digest.as_bytes()[..16]); + ImageKeyMaterial { aes_key, xor_key } +} + +fn collect_wxid_candidates(db_dir: &Path) -> Vec { + let Some(raw) = wxid_from_db_dir(db_dir) else { + return Vec::new(); + }; + let mut out = vec![raw.clone()]; + let normalized = normalize_wxid(&raw); + if normalized != raw { + out.push(normalized); + } + out +} + +fn extract_wxid_parts(db_dir: &Path) -> Option<(String, String, String)> { + let raw = wxid_from_db_dir(db_dir)?; + let idx = raw.rfind('_')?; + let suffix = &raw[idx + 1..]; + if suffix.len() != 4 || !suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + Some((raw.clone(), normalize_wxid(&raw), suffix.to_ascii_lowercase())) +} + +fn preferred_wxid_candidates<'a>(raw: &'a str, normalized: &'a str) -> Vec<&'a str> { + if raw == normalized { + vec![raw] + } else { + vec![normalized, raw] + } +} + +fn derive_kvcomm_dir_candidates(db_dir: &Path) -> Vec { + let parts: Vec = db_dir + .components() + .map(|component| component.as_os_str().to_string_lossy().into_owned()) + .collect(); + + let mut candidates = Vec::new(); + if let Some(idx) = parts.iter().position(|part| part == "xwechat_files") { + let documents_root = join_components(&parts[..idx]); + candidates.push(documents_root.join("app_data/net/kvcomm")); + candidates.push(documents_root.join("xwechat/net/kvcomm")); + if idx >= 1 { + let container_root = join_components(&parts[..idx - 1]); + candidates.push( + container_root + .join("Library/Application Support/com.tencent.xinWeChat/xwechat/net/kvcomm"), + ); + candidates.push( + container_root.join("Library/Application Support/com.tencent.xinWeChat/net/kvcomm"), + ); + } + } + if let Some(home) = dirs::home_dir() { + candidates.push( + home.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm"), + ); + } + + let mut dedup = Vec::new(); + for candidate in candidates { + if !dedup.contains(&candidate) { + dedup.push(candidate); + } + } + dedup +} + +fn find_existing_kvcomm_dir(db_dir: &Path) -> Option { + derive_kvcomm_dir_candidates(db_dir) + .into_iter() + .find(|path| path.is_dir()) +} + +fn collect_kvcomm_codes(kvcomm_dir: &Path) -> Result> { + let mut codes = std::collections::BTreeSet::new(); + for entry in std::fs::read_dir(kvcomm_dir)? { + let entry = entry?; + let Some(name) = entry.file_name().to_str().map(|value| value.to_string()) else { + continue; + }; + let Some(rest) = name.strip_prefix("key_") else { + continue; + }; + let Some((code, _)) = rest.split_once('_') else { + continue; + }; + if let Ok(code) = code.parse::() { + codes.insert(code); + } + } + Ok(codes.into_iter().collect()) +} + +fn bruteforce_aes_key( + xor_key: u8, + suffix_hex: &str, + wxid: &str, + templates: &[[u8; 16]], +) -> Result> { + let suffix = hex_prefix_to_bytes(suffix_hex)?; + let workers = std::thread::available_parallelism() + .map(|count| count.get()) + .unwrap_or(1) + .max(1); + let total = 1u32 << 24; + let chunk = total / workers as u32; + let stop = Arc::new(AtomicBool::new(false)); + let (tx, rx) = mpsc::channel(); + let wxid = Arc::new(wxid.as_bytes().to_vec()); + let templates = Arc::new(templates.to_vec()); + + std::thread::scope(|scope| { + for idx in 0..workers { + let start = idx as u32 * chunk; + let end = if idx + 1 == workers { + total + } else { + (idx as u32 + 1) * chunk + }; + let stop = Arc::clone(&stop); + let tx = tx.clone(); + let wxid = Arc::clone(&wxid); + let templates = Arc::clone(&templates); + scope.spawn(move || { + for upper in start..end { + if stop.load(Ordering::Relaxed) { + break; + } + let uin = (upper << 8) | xor_key as u32; + let uin_ascii = uin.to_string(); + let digest = md5::compute(uin_ascii.as_bytes()); + if digest.0[0] != suffix[0] || digest.0[1] != suffix[1] { + continue; + } + + let mut input = Vec::with_capacity(uin_ascii.len() + wxid.len()); + input.extend_from_slice(uin_ascii.as_bytes()); + input.extend_from_slice(&wxid); + let aes_hex = format!("{:x}", md5::compute(input)); + let mut aes_key = [0u8; 16]; + aes_key.copy_from_slice(&aes_hex.as_bytes()[..16]); + if verify_aes_key(&aes_key, &templates) { + stop.store(true, Ordering::Relaxed); + let _ = tx.send(aes_key); + break; + } + } + }); + } + }); + drop(tx); + Ok(rx.try_iter().next()) +} + +fn hex_prefix_to_bytes(hex: &str) -> Result<[u8; 2]> { + if hex.len() != 4 { + bail!("wxid suffix 不是 4 位 hex: {}", hex); + } + let hi = u8::from_str_radix(&hex[..2], 16)?; + let lo = u8::from_str_radix(&hex[2..], 16)?; + Ok([hi, lo]) +} + +#[cfg(test)] +mod tests { + use super::{derive_key_for_paths, find_existing_kvcomm_dir}; + use super::collect_wxid_candidates; + use crate::attachment::image_key::normalize_wxid; + use aes::cipher::{generic_array::GenericArray, BlockEncrypt, KeyInit}; + use aes::Aes128; + use std::fs; + use std::path::Path; + + fn temp_dir(label: &str) -> std::path::PathBuf { + let mut dir = std::env::temp_dir(); + dir.push(format!( + "wx-cli-image-key-macos-{}-{:?}", + label, + std::thread::current().id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_v2_template(path: &Path, aes_key: &[u8; 16], xor_key: u8, plaintext: &[u8; 16]) { + let cipher = Aes128::new(aes_key.into()); + let mut block = GenericArray::clone_from_slice(plaintext); + cipher.encrypt_block(&mut block); + + let mut data = Vec::new(); + data.extend_from_slice(&crate::attachment::decoder::V2_MAGIC); + data.extend_from_slice(&0u32.to_le_bytes()); + data.extend_from_slice(&0u32.to_le_bytes()); + data.push(0); + data.extend_from_slice(&block); + data.push(0); + data.push(0xD9 ^ xor_key); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, data).unwrap(); + } + + #[test] + fn normalize_wxid_matches_expected_shapes() { + assert_eq!(normalize_wxid("wxid_abc_def"), "wxid_abc"); + assert_eq!(normalize_wxid("your_wxid_a1b2"), "your_wxid"); + assert_eq!(normalize_wxid("plain"), "plain"); + } + + #[test] + fn kvcomm_path_detection_works() { + let dir = temp_dir("kvcomm"); + let db_dir = dir.join( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/your_wxid_a1b2/db_storage", + ); + let kvcomm = dir.join( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm", + ); + fs::create_dir_all(&db_dir).unwrap(); + fs::create_dir_all(&kvcomm).unwrap(); + assert_eq!(find_existing_kvcomm_dir(&db_dir), Some(kvcomm)); + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn derives_key_via_kvcomm() { + let dir = temp_dir("via-kvcomm"); + let db_dir = dir.join( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/your_wxid_a1b2/db_storage", + ); + let attach = dir.join( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/your_wxid_a1b2/msg/attach/chat/2026-05/Img", + ); + let kvcomm = dir.join( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/app_data/net/kvcomm", + ); + fs::create_dir_all(&db_dir).unwrap(); + fs::create_dir_all(&kvcomm).unwrap(); + fs::write(kvcomm.join("key_42_x.statistic"), b"").unwrap(); + + let digest = format!("{:x}", md5::compute("42your_wxid")); + let mut aes_key = [0u8; 16]; + aes_key.copy_from_slice(&digest.as_bytes()[..16]); + write_v2_template( + &attach.join("sample_t.dat"), + &aes_key, + 42, + b"\xFF\xD8\xFFtemplate-001!", + ); + + let derived = derive_key_for_paths(&db_dir, db_dir.parent().unwrap().join("msg/attach").as_path()) + .unwrap(); + assert_eq!(derived.aes_key, aes_key); + assert_eq!(derived.xor_key, 42); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn derives_key_via_bruteforce_fallback() { + let dir = temp_dir("via-fallback"); + let suffix = format!("{:x}", md5::compute("42")) + .chars() + .take(4) + .collect::(); + let raw_wxid = format!("mywxid_{}", suffix); + let db_dir = dir.join(format!( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/{}/db_storage", + raw_wxid + )); + let attach = dir.join(format!( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/{}/msg/attach/chat/2026-05/Img", + raw_wxid + )); + fs::create_dir_all(&db_dir).unwrap(); + + let digest = format!("{:x}", md5::compute("42mywxid")); + let mut aes_key = [0u8; 16]; + aes_key.copy_from_slice(&digest.as_bytes()[..16]); + for idx in 0..3 { + write_v2_template( + &attach.join(format!("sample{}_t.dat", idx)), + &aes_key, + 42, + b"\xFF\xD8\xFFtemplate-001!", + ); + } + + let derived = derive_key_for_paths(&db_dir, db_dir.parent().unwrap().join("msg/attach").as_path()) + .unwrap(); + assert_eq!(derived.aes_key, aes_key); + assert_eq!(derived.xor_key, 42); + + let _ = fs::remove_dir_all(dir); + } + + #[test] + fn collects_raw_and_normalized_wxid() { + let dir = temp_dir("wxid"); + let db_dir = dir.join( + "Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/your_wxid_a1b2/db_storage", + ); + fs::create_dir_all(&db_dir).unwrap(); + let wxids = collect_wxid_candidates(&db_dir); + assert_eq!(wxids, vec!["your_wxid_a1b2".to_string(), "your_wxid".to_string()]); + let _ = fs::remove_dir_all(dir); + } +} diff --git a/src/attachment/image_key/mod.rs b/src/attachment/image_key/mod.rs new file mode 100644 index 0000000..74eee30 --- /dev/null +++ b/src/attachment/image_key/mod.rs @@ -0,0 +1,342 @@ +//! V2 image AES key 提取 — 平台相关。 +//! +//! 路径: +//! - macOS:磁盘派生(`key__*.statistic` 文件名拿 uin → `md5(str(uin) + wxid)[:16]`) +//! + brute-force fallback(`md5(str(uin))[:4] == wxid_suffix` 枚举 2^24) +//! - Windows:扫 `Weixin.exe` 内存,匹配 `[a-zA-Z0-9]{32}` 候选,按已知 AES ciphertext-block +//! 反验(`find_image_key.py` / `find_image_key.c` 已写实) +//! - Linux:上游空白;当前不实现,遇到 V2 .dat 返回 unsupported 错误 + +#[cfg(target_os = "linux")] +pub mod linux; +#[cfg(target_os = "macos")] +pub mod macos; +#[cfg(target_os = "windows")] +pub mod windows; + +use anyhow::Result; +use regex::bytes::Regex; +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::attachment::decoder::{detect_image_format, V2_MAGIC}; + +/// V2 图片真正需要的是两份材料: +/// - 16 字节 ASCII AES key +/// - XOR key(macOS 上来自 uin & 0xff,不是总能硬编码成 0x88) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ImageKeyMaterial { + pub aes_key: [u8; 16], + pub xor_key: u8, +} + +/// 单个 wxid 的 V2 image key 提取接口。 +/// +/// 实现者负责跨调用缓存(一台机器上同一 wxid 的 image key 在微信不重启时通常稳定)。 +pub trait ImageKeyProvider { + fn get_key(&self, wxid: &str) -> Result; + + fn get_aes_key(&self, wxid: &str) -> Result<[u8; 16]> { + Ok(self.get_key(wxid)?.aes_key) + } + + fn get_xor_key(&self, wxid: &str) -> Result { + Ok(self.get_key(wxid)?.xor_key) + } +} + +/// 平台默认实现。 +pub fn default_provider() -> Option> { + #[cfg(target_os = "macos")] + { + return Some(Box::new(macos::MacosImageKeyProvider::from_current_config())); + } + #[cfg(target_os = "windows")] + { + return Some(Box::new(windows::WindowsImageKeyProvider::from_current_config())); + } + #[cfg(target_os = "linux")] + { + return Some(Box::new(linux::LinuxImageKeyProvider)); + } + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + { + None + } +} + +pub(crate) fn configured_db_dir_for_wxid(configured_db_dir: &Path, requested_wxid: &str) -> PathBuf { + if requested_wxid.trim().is_empty() { + return configured_db_dir.to_path_buf(); + } + + let configured_leaf = wxid_from_db_dir(configured_db_dir); + if let Some(leaf) = configured_leaf.as_deref() { + if same_wxid(leaf, requested_wxid) { + return configured_db_dir.to_path_buf(); + } + } + + xwechat_files_root(configured_db_dir) + .map(|root| root.join(requested_wxid).join("db_storage")) + .unwrap_or_else(|| configured_db_dir.to_path_buf()) +} + +pub(crate) fn wxid_from_db_dir(db_dir: &Path) -> Option { + let mut components = db_dir + .components() + .map(|component| component.as_os_str().to_string_lossy().into_owned()); + while let Some(component) = components.next() { + if component == "xwechat_files" { + return components.next(); + } + } + None +} + +pub(crate) fn xwechat_files_root(db_dir: &Path) -> Option { + let parts: Vec<_> = db_dir + .components() + .map(|component| component.as_os_str().to_string_lossy().into_owned()) + .collect(); + let idx = parts.iter().position(|part| part == "xwechat_files")?; + Some(join_components(&parts[..=idx])) +} + +pub(crate) fn normalize_wxid(raw: &str) -> String { + let raw = raw.trim(); + if raw.is_empty() { + return String::new(); + } + if let Some(stripped) = raw.strip_prefix("wxid_") { + let head = stripped.split('_').next().unwrap_or(stripped); + return format!("wxid_{}", head); + } + if let Some((base, suffix)) = raw.rsplit_once('_') { + if suffix.len() == 4 && suffix.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return base.to_string(); + } + } + raw.to_string() +} + +pub(crate) fn same_wxid(a: &str, b: &str) -> bool { + a == b || normalize_wxid(a) == normalize_wxid(b) +} + +pub(crate) fn join_components(parts: &[String]) -> PathBuf { + let mut out = if parts.first().map(|part| part.is_empty()).unwrap_or(false) { + PathBuf::from("/") + } else { + PathBuf::new() + }; + for part in parts { + if part.is_empty() { + continue; + } + out.push(part); + } + out +} + +pub(crate) fn attach_root_for_db_dir(db_dir: &Path) -> PathBuf { + db_dir + .parent() + .map(|base| base.join("msg").join("attach")) + .unwrap_or_else(|| PathBuf::from("msg/attach")) +} + +pub(crate) fn find_v2_template_ciphertexts( + attach_dir: &Path, + max_templates: usize, + max_files: usize, +) -> Result> { + if !attach_dir.is_dir() { + return Ok(Vec::new()); + } + + let mut out = collect_templates_with_suffix(attach_dir, "_t.dat", max_templates, max_files)?; + if out.is_empty() { + out = collect_templates_with_suffix(attach_dir, ".dat", max_templates, max_files)?; + } + Ok(out) +} + +pub(crate) fn derive_xor_key_from_v2_dat( + attach_dir: &Path, + sample: usize, + min_samples: usize, +) -> Result> { + if !attach_dir.is_dir() { + return Ok(None); + } + let mut votes = Vec::new(); + visit_files(attach_dir, &mut |path| -> Result { + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return Ok(false); + }; + if !name.ends_with(".dat") { + return Ok(false); + } + + let meta = fs::metadata(path)?; + if meta.len() < 0x20 { + return Ok(false); + } + + let bytes = fs::read(path)?; + if bytes.starts_with(&V2_MAGIC) { + let last = *bytes.last().unwrap(); + votes.push(last ^ 0xD9); + if votes.len() >= sample { + return Ok(true); + } + } + Ok(false) + })?; + + if votes.len() < min_samples { + return Ok(None); + } + + let mut counts = [0usize; 256]; + for vote in &votes { + counts[*vote as usize] += 1; + } + let (xor_key, top_votes) = counts + .iter() + .enumerate() + .max_by_key(|(_, count)| *count) + .map(|(idx, count)| (idx as u8, *count)) + .expect("votes 非空"); + Ok(Some((xor_key, top_votes, votes.len()))) +} + +pub(crate) fn verify_aes_key(aes_key: &[u8; 16], templates: &[[u8; 16]]) -> bool { + !templates.is_empty() + && templates + .iter() + .all(|template| decrypt_template_block(aes_key, template).is_some()) +} + +pub(crate) fn ascii_alnum_candidates<'a>(buf: &'a [u8], len: usize) -> Vec<&'a [u8]> { + let re = match len { + 16 => regex16(), + 32 => regex32(), + _ => return Vec::new(), + }; + + re.find_iter(buf) + .filter_map(|matched| { + let start = matched.start(); + let end = matched.end(); + let left_ok = start == 0 || !buf[start - 1].is_ascii_alphanumeric(); + let right_ok = end == buf.len() || !buf[end].is_ascii_alphanumeric(); + (left_ok && right_ok).then_some(&buf[start..end]) + }) + .collect() +} + +fn collect_templates_with_suffix( + dir: &Path, + suffix: &str, + max_templates: usize, + max_files: usize, +) -> Result> { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + let mut examined = 0usize; + visit_files(dir, &mut |path| -> Result { + let Some(name) = path.file_name().and_then(|value| value.to_str()) else { + return Ok(false); + }; + if !name.ends_with(suffix) { + return Ok(false); + } + examined += 1; + let bytes = fs::read(path)?; + if bytes.len() >= 0x1F && bytes.starts_with(&V2_MAGIC) { + let template: [u8; 16] = bytes[0x0F..0x1F].try_into().unwrap(); + if seen.insert(template) { + out.push(template); + if out.len() >= max_templates { + return Ok(true); + } + } + } + Ok(examined >= max_files && !out.is_empty()) + })?; + Ok(out) +} + +fn visit_files(dir: &Path, f: &mut F) -> Result +where + F: FnMut(&Path) -> Result, +{ + let mut entries: Vec = fs::read_dir(dir)? + .flatten() + .map(|entry| entry.path()) + .collect(); + entries.sort(); + + for path in entries { + if path.is_dir() { + if visit_files(&path, f)? { + return Ok(true); + } + continue; + } + if f(&path)? { + return Ok(true); + } + } + Ok(false) +} + +fn decrypt_template_block(aes_key: &[u8; 16], ciphertext: &[u8; 16]) -> Option<&'static str> { + use aes::cipher::{generic_array::GenericArray, BlockDecrypt, KeyInit}; + + let cipher = aes::Aes128::new(aes_key.into()); + let mut block = GenericArray::clone_from_slice(ciphertext); + cipher.decrypt_block(&mut block); + let block: [u8; 16] = block.as_slice().try_into().ok()?; + let format = detect_image_format(&block); + (format != "bin").then_some(format) +} + +fn regex16() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"[A-Za-z0-9]{16}").unwrap()) +} + +fn regex32() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"[A-Za-z0-9]{32}").unwrap()) +} + +#[cfg(test)] +mod tests { + use super::{ascii_alnum_candidates, normalize_wxid, same_wxid}; + + #[test] + fn regex_candidates_respect_boundaries() { + let buf = b"xx 0123456789ABCDef yy"; + let hits = ascii_alnum_candidates(buf, 16); + assert_eq!(hits, vec![&buf[3..19]]); + } + + #[test] + fn regex_candidates_ignore_embedded_runs() { + let buf = b"x0123456789ABCDefz"; + assert!(ascii_alnum_candidates(buf, 16).is_empty()); + } + + #[test] + fn wxid_normalization_matches_expected_forms() { + assert_eq!(normalize_wxid("wxid_abc_def"), "wxid_abc"); + assert_eq!(normalize_wxid("your_wxid_a1b2"), "your_wxid"); + assert!(same_wxid("your_wxid_a1b2", "your_wxid")); + } +} diff --git a/src/attachment/image_key/windows.rs b/src/attachment/image_key/windows.rs new file mode 100644 index 0000000..0b7acd8 --- /dev/null +++ b/src/attachment/image_key/windows.rs @@ -0,0 +1,238 @@ +//! Windows V2 image AES key 提取。 +//! +//! 扫 `Weixin.exe` 进程内存,匹配模式 `[A-Za-z0-9]{32}` / `[A-Za-z0-9]{16}`, +//! 然后用 V2 模板 AES block 反验,控制 false positive。 + +use anyhow::{bail, Context, Result}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Mutex; + +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory; +use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS, +}; +use windows::Win32::System::Memory::{ + VirtualQueryEx, MEMORY_BASIC_INFORMATION, MEM_COMMIT, PAGE_EXECUTE_READWRITE, + PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOCACHE, PAGE_NOACCESS, PAGE_READWRITE, + PAGE_WRITECOMBINE, PAGE_WRITECOPY, +}; +use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ}; + +use crate::config; + +use super::{ + ascii_alnum_candidates, attach_root_for_db_dir, configured_db_dir_for_wxid, + derive_xor_key_from_v2_dat, find_v2_template_ciphertexts, verify_aes_key, ImageKeyMaterial, + ImageKeyProvider, +}; + +const CHUNK_SIZE: usize = 2 * 1024 * 1024; +const MAX_REGION_SIZE: usize = 50 * 1024 * 1024; + +pub struct WindowsImageKeyProvider { + configured_db_dir: Result, + cache: Mutex>, +} + +impl WindowsImageKeyProvider { + pub fn from_current_config() -> Self { + let configured_db_dir = config::load_config() + .map(|cfg| cfg.db_dir) + .map_err(|err| err.to_string()); + Self { + configured_db_dir, + cache: Mutex::new(HashMap::new()), + } + } +} + +impl ImageKeyProvider for WindowsImageKeyProvider { + fn get_key(&self, wxid: &str) -> Result { + let cache_key = wxid.trim().to_string(); + if let Some(found) = self.cache.lock().unwrap().get(&cache_key).copied() { + return Ok(found); + } + + let configured_db_dir = self + .configured_db_dir + .as_ref() + .map_err(|err| anyhow::anyhow!("读取 config.db_dir 失败: {}", err))?; + let db_dir = configured_db_dir_for_wxid(configured_db_dir, wxid); + let attach_dir = attach_root_for_db_dir(&db_dir); + let key = derive_key_for_paths(&attach_dir)?; + self.cache.lock().unwrap().insert(cache_key, key); + Ok(key) + } +} + +fn derive_key_for_paths(attach_dir: &std::path::Path) -> Result { + let templates = find_v2_template_ciphertexts(attach_dir, 3, 64)?; + if templates.is_empty() { + bail!("在 {} 下找不到 V2 模板文件", attach_dir.display()); + } + let xor_key = derive_xor_key_from_v2_dat(attach_dir, 10, 3)? + .map(|(key, _, _)| key) + .unwrap_or(0x88); + + let pid = find_wechat_pid().context("找不到 Weixin.exe 进程,请确认微信正在运行")?; + let process = unsafe { + OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, false, pid) + .context("OpenProcess 失败,请以管理员权限运行")? + }; + + let aes_key = scan_memory_for_key(process, &templates); + unsafe { + let _ = CloseHandle(process); + } + + Ok(ImageKeyMaterial { + aes_key: aes_key?, + xor_key, + }) +} + +fn find_wechat_pid() -> Option { + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()? }; + let mut entry = PROCESSENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + unsafe { + if Process32First(snapshot, &mut entry).is_err() { + let _ = CloseHandle(snapshot); + return None; + } + loop { + let name = + std::ffi::CStr::from_ptr(entry.szExeFile.as_ptr() as *const i8).to_string_lossy(); + if name.eq_ignore_ascii_case("Weixin.exe") { + let pid = entry.th32ProcessID; + let _ = CloseHandle(snapshot); + return Some(pid); + } + if Process32Next(snapshot, &mut entry).is_err() { + break; + } + } + let _ = CloseHandle(snapshot); + } + None +} + +fn scan_memory_for_key(process: HANDLE, templates: &[[u8; 16]]) -> Result<[u8; 16]> { + let mut seen = HashSet::<[u8; 16]>::new(); + let mut address = 0usize; + + loop { + let mut mbi = MEMORY_BASIC_INFORMATION::default(); + let ret = unsafe { + VirtualQueryEx( + process, + Some(address as *const _), + &mut mbi, + std::mem::size_of::(), + ) + }; + if ret == 0 { + break; + } + + let base = mbi.BaseAddress as usize; + let size = mbi.RegionSize; + if mbi.State == MEM_COMMIT && is_candidate_page(mbi.Protect.0) && size <= MAX_REGION_SIZE { + if let Some(aes_key) = scan_region(process, base, size, templates, &mut seen)? { + return Ok(aes_key); + } + } + + address = base.saturating_add(size); + if address == 0 { + break; + } + } + + bail!("Windows 进程内存里没有找到可验证的 V2 AES key") +} + +fn scan_region( + process: HANDLE, + base: usize, + size: usize, + templates: &[[u8; 16]], + seen: &mut HashSet<[u8; 16]>, +) -> Result> { + let overlap = 31usize; + let mut offset = 0usize; + + while offset < size { + let chunk_size = std::cmp::min(CHUNK_SIZE, size - offset); + let addr = base + offset; + let mut buf = vec![0u8; chunk_size]; + let mut bytes_read = 0usize; + + let ok = unsafe { + ReadProcessMemory( + process, + addr as *const _, + buf.as_mut_ptr() as *mut _, + chunk_size, + Some(&mut bytes_read), + ) + .is_ok() + }; + + if ok && bytes_read > 0 { + buf.truncate(bytes_read); + if let Some(key) = scan_candidate_buffer(&buf, templates, seen) { + return Ok(Some(key)); + } + } + + offset += if chunk_size > overlap { + chunk_size - overlap + } else { + chunk_size + }; + } + + Ok(None) +} + +fn scan_candidate_buffer( + buf: &[u8], + templates: &[[u8; 16]], + seen: &mut HashSet<[u8; 16]>, +) -> Option<[u8; 16]> { + for candidate in ascii_alnum_candidates(buf, 32) { + let mut key = [0u8; 16]; + key.copy_from_slice(&candidate[..16]); + if seen.insert(key) && verify_aes_key(&key, templates) { + return Some(key); + } + } + for candidate in ascii_alnum_candidates(buf, 16) { + let mut key = [0u8; 16]; + key.copy_from_slice(candidate); + if seen.insert(key) && verify_aes_key(&key, templates) { + return Some(key); + } + } + None +} + +fn is_candidate_page(protect: u32) -> bool { + if protect == PAGE_NOACCESS.0 || (protect & PAGE_GUARD.0) != 0 { + return false; + } + let base = protect & !(PAGE_GUARD.0 | PAGE_NOCACHE.0 | PAGE_WRITECOMBINE.0); + matches!( + base, + value if value == PAGE_READWRITE.0 + || value == PAGE_WRITECOPY.0 + || value == PAGE_EXECUTE_READWRITE.0 + || value == PAGE_EXECUTE_WRITECOPY.0 + ) +} diff --git a/src/attachment/mod.rs b/src/attachment/mod.rs new file mode 100644 index 0000000..43dd14e --- /dev/null +++ b/src/attachment/mod.rs @@ -0,0 +1,28 @@ +//! 聊天附件提取链路(图片 / 视频 / 语音 / 文件本体的本地解码) +//! +//! 整条链: +//! message_N.db (Msg_) → message_resource.db (ChatName2Id + MessageResourceInfo) +//! → packed_info protobuf md5 提取 → xwechat_files//msg/attach/.../Img/[_t|_h].dat +//! → magic 分发 (legacy XOR / V1 fixed-AES / V2 AES+XOR) → 写出实际图片 +//! +//! 模块切分: +//! - `attachment_id`:跨 IPC / CLI 的不透明 ID(base64url(json)) +//! - `resolver`:从 `attachment_id` 反查 message_resource.db,定位本地 .dat +//! - `decoder`:根据文件 magic 分发到具体解码器(V1 / V2 等) +//! - `image_key`:V2 image AES key 提取(macOS / Windows) +//! +//! V2 / image_key 模块由 codex 落地,先放空 stub 以便 V1 / resolver / CLI 不被 block。 + +// 此模块由分多个 PR/commit 增量启用: +// 1) 先落 attachment_id / decoder / resolver / image_key 骨架(本 commit) +// 2) IPC + CLI + daemon route 把它们串起来(后续 commit) +// 3) image_key 平台实现(codex 后续 commit) +// 在 step 1 完成、step 2 未到时,大量公开 API 仍未被引用,#[allow(dead_code)] 抑制噪音 +#![allow(dead_code)] + +pub mod attachment_id; +pub mod decoder; +pub mod resolver; +pub mod image_key; + +pub use attachment_id::{AttachmentId, AttachmentKind}; diff --git a/src/attachment/resolver.rs b/src/attachment/resolver.rs new file mode 100644 index 0000000..8db4f41 --- /dev/null +++ b/src/attachment/resolver.rs @@ -0,0 +1,439 @@ +//! 把 `AttachmentId` 翻译成本地 `.dat` 路径。 +//! +//! 流程: +//! 1. `chat` username → `ChatName2Id.rowid`(资源库) +//! 2. `(chat_id, local_id)` + `ORDER BY message_create_time DESC LIMIT 1` → +//! `MessageResourceInfo.packed_info` +//! 3. 从 `packed_info` (protobuf) 提取 32 字节 ASCII hex MD5 +//! 4. 在 `/msg/attach///Img/[_t|_h].dat` +//! 下找对应文件,按 full > _h > _t 优先级选一个 +//! +//! `` 由 daemon 已知(同 `db_dir` 的父目录),路径 layout 平台差异: +//! - Linux: `~/Documents/xwechat_files/` +//! - macOS: `~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/` +//! ⚠️ msg/attach/... 子树 layout 待我用真实账号验证;上游 docstring 只写了 Windows +//! - Windows: `\xwechat_files\`(root 从 `%APPDATA%\Tencent\xwechat\config\*.ini` 读) + +use anyhow::{anyhow, Context, Result}; +use chrono::TimeZone; +use rusqlite::Connection; +use std::path::{Path, PathBuf}; + +use super::AttachmentId; + +/// 单条 attachment 在资源库 + 本地 attach 树下的解析结果。 +#[derive(Debug, Clone)] +pub struct ResolvedAttachment { + pub id: AttachmentId, + /// 从 `packed_info` 提取出的资源 MD5(小写 hex) + pub md5: String, + /// 命中的本地 .dat 路径(按 full > _h > _t 优先级选一个) + pub dat_path: PathBuf, + /// 文件 size(字节) + pub size: u64, +} + +/// 仅 schema lookup(不去找本地 .dat)。 +/// 用于 `wx attachments` 列表时填 `md5` 字段——文件可能根本不在本地。 +#[derive(Debug, Clone)] +pub struct AttachmentMetadata { + pub md5: String, +} + +/// 用 `(chat, local_id)` 查 message_resource.db 拿 file md5。 +/// +/// 调用方传已经解密好的 `message_resource.db` 路径(由 daemon 的 `DBCache` 准备)。 +/// 同步函数 — caller 在 `spawn_blocking` 里跑。 +pub fn lookup_md5_blocking( + resource_db_path: &Path, + chat: &str, + local_id: i64, + create_time: i64, + msg_local_type_lo32: i64, +) -> Result> { + let conn = Connection::open_with_flags( + resource_db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_URI, + ) + .with_context(|| format!("打开 message_resource.db {:?}", resource_db_path))?; + + // 1) ChatName2Id: user_name -> rowid + let chat_id: Option = conn + .query_row( + "SELECT rowid FROM ChatName2Id WHERE user_name = ?1", + [chat], + |row| row.get(0), + ) + .ok(); + let Some(chat_id) = chat_id else { + return Ok(None); + }; + + // 2) MessageResourceInfo: + // 同 chat 内 local_id 会复用,所以先用 create_time 精确命中; + // 若资源库里的时间戳跟 message_N.db 不完全对齐,再 fallback 到“同 local_id/type 取最新” + // message_local_type 高 32 bit 是版本/会话 flag,低 32 bit 才是真实类型 + let packed_exact: Option> = conn + .query_row( + "SELECT packed_info FROM MessageResourceInfo + WHERE chat_id = ?1 + AND message_local_id = ?2 + AND (message_local_type = ?3 OR message_local_type % 4294967296 = ?3) + AND message_create_time = ?4 + ORDER BY rowid DESC + LIMIT 1", + rusqlite::params![chat_id, local_id, msg_local_type_lo32, create_time], + |row| row.get(0), + ) + .ok(); + + let packed: Option> = packed_exact.or_else(|| conn + .query_row( + "SELECT packed_info FROM MessageResourceInfo + WHERE chat_id = ?1 + AND message_local_id = ?2 + AND (message_local_type = ?3 OR message_local_type % 4294967296 = ?3) + ORDER BY message_create_time DESC + LIMIT 1", + rusqlite::params![chat_id, local_id, msg_local_type_lo32], + |row| row.get(0), + ) + .ok()); + + let Some(blob) = packed else { + return Ok(None); + }; + Ok(extract_md5_from_packed_info(&blob).map(|md5| AttachmentMetadata { md5 })) +} + +/// 从 `MessageResourceInfo.packed_info` (protobuf) 提取 32 字节 ASCII hex md5。 +/// +/// 主路径:搜 4 字节 marker `12 22 0a 20`(field=2 LEN, length=34, sub field=1 LEN, length=32), +/// 紧跟 32 字节 ASCII hex。 +/// Fallback:扫整个 blob 找连续 32 字节合法 hex 字符。 +pub fn extract_md5_from_packed_info(blob: &[u8]) -> Option { + const MARKER: &[u8; 4] = &[0x12, 0x22, 0x0A, 0x20]; + + // 主路径 + if let Some(pos) = find_subslice(blob, MARKER) { + let start = pos + MARKER.len(); + if start + 32 <= blob.len() { + if let Ok(s) = std::str::from_utf8(&blob[start..start + 32]) { + if s.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(s.to_ascii_lowercase()); + } + } + } + } + + // Fallback:连续 32 字节合法 hex + if blob.len() >= 32 { + for start in 0..=blob.len() - 32 { + let chunk = &blob[start..start + 32]; + if let Ok(s) = std::str::from_utf8(chunk) { + if s.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(s.to_ascii_lowercase()); + } + } + } + } + None +} + +/// 简单的子串扫描(避免拉 memchr/memmem 依赖;blob 通常 < 1KB) +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || needle.len() > haystack.len() { + return None; + } + haystack + .windows(needle.len()) + .position(|w| w == needle) +} + +/// 在 `///Img/[_t|_h].dat` 下找文件。 +/// +/// 优先级:full > `_h`(HD thumbnail)> `_t`(thumbnail)。返回最优的一个; +/// 找不到返回 None。 +/// +/// `attach_root` = `/msg/attach`。 +/// `create_time` 用于先定位 `` 子目录;找不到时再 fallback 全月份扫描, +/// 因为 WeChat 的 `YYYY-MM` 目录有时跟消息时间差 1 个月(按收到时间归档)。 +pub fn find_dat_file( + attach_root: &Path, + chat: &str, + file_md5: &str, + create_time: i64, +) -> Option { + let chat_hash = format!("{:x}", md5::compute(chat.as_bytes())); + let chat_dir = attach_root.join(&chat_hash); + if !chat_dir.is_dir() { + return None; + } + + // 第一步:试 create_time 当月 + 前后各一个月(共 3 个候选目录) + let candidates_ym: Vec = three_month_candidates(create_time); + for ym in &candidates_ym { + let img_dir = chat_dir.join(ym).join("Img"); + if let Some(p) = pick_best_in_img_dir(&img_dir, file_md5) { + return Some(p); + } + } + + // 第二步 fallback:扫整个 chat_dir 的所有月份子目录 + let entries = std::fs::read_dir(&chat_dir).ok()?; + let mut all_months: Vec = entries + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect(); + // 已经试过的 3 个候选可以跳过,但成本极小;保留全量扫 + all_months.sort(); + for month_dir in all_months { + let img_dir = month_dir.join("Img"); + if let Some(p) = pick_best_in_img_dir(&img_dir, file_md5) { + return Some(p); + } + } + None +} + +fn pick_best_in_img_dir(img_dir: &Path, file_md5: &str) -> Option { + if !img_dir.is_dir() { + return None; + } + let full = img_dir.join(format!("{}.dat", file_md5)); + if full.is_file() { + return Some(full); + } + let hd = img_dir.join(format!("{}_h.dat", file_md5)); + if hd.is_file() { + return Some(hd); + } + let thumb = img_dir.join(format!("{}_t.dat", file_md5)); + if thumb.is_file() { + return Some(thumb); + } + None +} + +fn three_month_candidates(unix_ts: i64) -> Vec { + use chrono::{Datelike, Duration}; + let dt = match chrono::Local.timestamp_opt(unix_ts, 0).single() { + Some(d) => d, + None => return Vec::new(), + }; + let prev = dt - Duration::days(31); + let next = dt + Duration::days(31); + [prev, dt, next] + .iter() + .map(|d| format!("{:04}-{:02}", d.year(), d.month())) + .collect() +} + +/// 把 `` (即 `db_storage` 父目录)拼成 `/msg/attach`。 +pub fn attach_root_for(wxchat_base: &Path) -> PathBuf { + wxchat_base.join("msg").join("attach") +} + +/// 完整流程:用 `attachment_id` 拿 md5 + 找 .dat。失败返回带具体诊断信息的 `Err`。 +/// +/// `resource_db_path` 由 daemon 提供(DBCache 已经解密好); +/// `attach_root` 由 caller 拼好(`attach_root_for(wxchat_base)`)。 +/// 同步函数 — caller 在 `spawn_blocking` 里跑。 +pub fn resolve_blocking( + id: &AttachmentId, + resource_db_path: &Path, + attach_root: &Path, +) -> Result { + let lo32_type: i64 = match id.kind { + super::AttachmentKind::Image => 3, + super::AttachmentKind::Voice => 34, + super::AttachmentKind::Video => 43, + super::AttachmentKind::File => 49, + }; + + let meta = lookup_md5_blocking( + resource_db_path, + &id.chat, + id.local_id, + id.create_time, + lo32_type, + )? + .ok_or_else(|| { + anyhow!( + "message_resource.db 中找不到 chat={} local_id={} type={} 的资源行(可能是非附件消息或资源库未同步)", + id.chat, + id.local_id, + lo32_type + ) + })?; + + let dat_path = find_dat_file(attach_root, &id.chat, &meta.md5, id.create_time).ok_or_else( + || { + anyhow!( + "找不到本地 .dat(md5={} chat={} create_time={})— 微信可能尚未下载该附件,或附件已被清理", + meta.md5, + id.chat, + id.create_time + ) + }, + )?; + let size = std::fs::metadata(&dat_path).map(|m| m.len()).unwrap_or(0); + + Ok(ResolvedAttachment { id: id.clone(), md5: meta.md5, dat_path, size }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_md5_main_path() { + // 构造一段含 12 22 0a 20 marker 的 blob + let mut blob = vec![0xAA, 0xBB, 0xCC]; + blob.extend_from_slice(&[0x12, 0x22, 0x0A, 0x20]); + blob.extend_from_slice(b"deadbeefcafebabe1234567890abcdef"); + blob.extend_from_slice(&[0xFF, 0xFF]); + assert_eq!( + extract_md5_from_packed_info(&blob), + Some("deadbeefcafebabe1234567890abcdef".to_string()) + ); + } + + #[test] + fn extract_md5_fallback_no_marker() { + // 没有 marker,但 blob 里有合法 32 字节 hex + let mut blob = vec![0xFF, 0x00]; + blob.extend_from_slice(b"00112233445566778899aabbccddeeff"); + blob.extend_from_slice(&[0x01]); + assert_eq!( + extract_md5_from_packed_info(&blob), + Some("00112233445566778899aabbccddeeff".to_string()) + ); + } + + #[test] + fn extract_md5_uppercase_normalized_to_lower() { + let mut blob = vec![0x12, 0x22, 0x0A, 0x20]; + blob.extend_from_slice(b"DEADBEEFCAFEBABE1234567890ABCDEF"); + // 上游/CI/本地 file md5 都是 lowercase;强制小写化避免大小写不一致导致命中失败 + assert_eq!( + extract_md5_from_packed_info(&blob), + Some("deadbeefcafebabe1234567890abcdef".to_string()) + ); + } + + #[test] + fn extract_md5_returns_none_on_garbage() { + let blob = vec![0; 16]; + assert!(extract_md5_from_packed_info(&blob).is_none()); + } + + #[test] + fn lookup_md5_prefers_exact_create_time_over_latest_reuse() { + let dir = tempdir_for_test(); + let db_path = dir.join("message_resource.db"); + let conn = Connection::open(&db_path).unwrap(); + conn.execute( + "CREATE TABLE ChatName2Id (user_name TEXT)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO ChatName2Id (rowid, user_name) VALUES (1, 'room@chatroom')", + [], + ) + .unwrap(); + conn.execute( + "CREATE TABLE MessageResourceInfo ( + chat_id INTEGER, + message_local_id INTEGER, + message_local_type INTEGER, + message_create_time INTEGER, + packed_info BLOB + )", + [], + ) + .unwrap(); + + let old_blob = { + let mut blob = vec![0x12, 0x22, 0x0A, 0x20]; + blob.extend_from_slice(b"11111111111111111111111111111111"); + blob + }; + let new_blob = { + let mut blob = vec![0x12, 0x22, 0x0A, 0x20]; + blob.extend_from_slice(b"22222222222222222222222222222222"); + blob + }; + + conn.execute( + "INSERT INTO MessageResourceInfo + (chat_id, message_local_id, message_local_type, message_create_time, packed_info) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![1i64, 7i64, 3i64, 1000i64, old_blob], + ) + .unwrap(); + conn.execute( + "INSERT INTO MessageResourceInfo + (chat_id, message_local_id, message_local_type, message_create_time, packed_info) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![1i64, 7i64, 3i64, 2000i64, new_blob], + ) + .unwrap(); + + let old = lookup_md5_blocking(&db_path, "room@chatroom", 7, 1000, 3) + .unwrap() + .unwrap(); + let new = lookup_md5_blocking(&db_path, "room@chatroom", 7, 2000, 3) + .unwrap() + .unwrap(); + assert_eq!(old.md5, "11111111111111111111111111111111"); + assert_eq!(new.md5, "22222222222222222222222222222222"); + } + + #[test] + fn three_month_candidates_includes_prev_curr_next() { + // 2025-08-15 (mid-month) → 2025-07, 2025-08, 2025-09 + let ts = chrono::Local + .with_ymd_and_hms(2025, 8, 15, 12, 0, 0) + .unwrap() + .timestamp(); + let v = three_month_candidates(ts); + assert!(v.contains(&"2025-07".to_string())); + assert!(v.contains(&"2025-08".to_string())); + assert!(v.contains(&"2025-09".to_string())); + } + + #[test] + fn pick_best_prefers_full_then_h_then_t() { + let tmp = tempdir_for_test(); + let img = tmp.join("Img"); + std::fs::create_dir_all(&img).unwrap(); + let md5 = "abcd1234"; + std::fs::write(img.join(format!("{}_t.dat", md5)), b"thumb").unwrap(); + std::fs::write(img.join(format!("{}_h.dat", md5)), b"hd").unwrap(); + // 只有 _t / _h 时取 _h + assert_eq!( + pick_best_in_img_dir(&img, md5).unwrap().file_name().unwrap(), + format!("{}_h.dat", md5).as_str() + ); + // 加 full 后取 full + std::fs::write(img.join(format!("{}.dat", md5)), b"full").unwrap(); + assert_eq!( + pick_best_in_img_dir(&img, md5).unwrap().file_name().unwrap(), + format!("{}.dat", md5).as_str() + ); + } + + fn tempdir_for_test() -> PathBuf { + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let p = std::env::temp_dir().join(format!("wx-cli-attach-test-{}-{}", pid, nanos)); + std::fs::create_dir_all(&p).unwrap(); + p + } +} diff --git a/src/cli/attachments.rs b/src/cli/attachments.rs new file mode 100644 index 0000000..87e4434 --- /dev/null +++ b/src/cli/attachments.rs @@ -0,0 +1,41 @@ +use anyhow::Result; + +use super::history::{parse_time, parse_time_end}; +use super::output::{emit_warnings, print_response, OutputOpts}; +use super::transport; +use crate::ipc::Request; + +/// `wx attachments` — 列出指定会话的附件消息(默认 image,可多选)。 +/// +/// 输出每条 `attachment_id`,再传给 `wx extract` 才真正读 message_resource.db +/// 与本地 .dat 解码。这一步只查 `Msg_` 表,几千条群聊也能秒返。 +pub fn cmd_attachments( + chat: String, + kinds: Vec, + limit: usize, + offset: usize, + since: Option, + until: Option, + opts: OutputOpts, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + let (with_meta, debug_source) = opts.request_flags(); + + // CLI 收上来的 Vec 为空时按默认(image)走,让 daemon 决定 fallback。 + let kinds_param = if kinds.is_empty() { None } else { Some(kinds) }; + + let req = Request::Attachments { + chat, + kinds: kinds_param, + limit, + offset, + since: since_ts, + until: until_ts, + with_meta, + debug_source, + }; + let resp = transport::send(req)?; + emit_warnings(&resp.data); + print_response(&resp.data, &opts) +} diff --git a/src/cli/biz_articles.rs b/src/cli/biz_articles.rs new file mode 100644 index 0000000..0c74874 --- /dev/null +++ b/src/cli/biz_articles.rs @@ -0,0 +1,30 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::history::{parse_time, parse_time_end}; +use super::transport; +use super::output::{resolve, print_value}; + +pub fn cmd_biz_articles( + limit: usize, + account: Option, + since: Option, + until: Option, + unread: bool, + json: bool, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + + let req = Request::BizArticles { + limit, + account, + since: since_ts, + until: until_ts, + unread, + }; + let resp = transport::send(req)?; + let data = resp.data.get("articles") + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + print_value(&data, &resolve(json)) +} diff --git a/src/cli/contacts.rs b/src/cli/contacts.rs new file mode 100644 index 0000000..e52a30b --- /dev/null +++ b/src/cli/contacts.rs @@ -0,0 +1,12 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::transport; +use super::output::{resolve, print_value}; + +pub fn cmd_contacts(query: Option, limit: usize, json: bool) -> Result<()> { + let resp = transport::send(Request::Contacts { query, limit })?; + let contacts = resp.data.get("contacts") + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + print_value(&contacts, &resolve(json)) +} diff --git a/src/cli/daemon_cmd.rs b/src/cli/daemon_cmd.rs new file mode 100644 index 0000000..ded6827 --- /dev/null +++ b/src/cli/daemon_cmd.rs @@ -0,0 +1,91 @@ +use crate::cli::transport; +use crate::cli::DaemonCommands; +use crate::config; +use anyhow::Result; + +pub fn cmd_daemon(cmd: DaemonCommands) -> Result<()> { + match cmd { + DaemonCommands::Status => cmd_status(), + DaemonCommands::Stop => cmd_stop(), + DaemonCommands::Logs { follow, lines } => cmd_logs(follow, lines), + } +} + +fn cmd_status() -> Result<()> { + if transport::is_alive() { + let pid_path = config::pid_path(); + let pid = std::fs::read_to_string(&pid_path) + .map(|s| { + serde_json::from_str::(&s) + .ok() + .and_then(|v| v.get("pid").and_then(|p| p.as_u64())) + .map(|pid| pid.to_string()) + .unwrap_or_else(|| s.trim().to_string()) + }) + .unwrap_or_else(|_| "?".into()); + println!("wx-daemon 运行中 (PID {})", pid); + } else { + println!("wx-daemon 未运行"); + } + Ok(()) +} + +fn cmd_stop() -> Result<()> { + if !transport::is_alive() { + println!("daemon 未运行"); + return Ok(()); + } + + transport::stop_daemon()?; + println!("已停止 wx-daemon"); + Ok(()) +} + +fn cmd_logs(follow: bool, lines: usize) -> Result<()> { + let log_path = config::log_path(); + if !log_path.exists() { + println!("暂无日志"); + return Ok(()); + } + + if follow { + #[cfg(unix)] + { + std::process::Command::new("tail") + .args([&format!("-{}", lines), "-f", &log_path.to_string_lossy()]) + .status()?; + } + #[cfg(windows)] + { + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::File::open(&log_path)?; + let len = file.seek(SeekFrom::End(0))?; + let start = len.saturating_sub((lines as u64) * 200); + file.seek(SeekFrom::Start(start))?; + let mut content = String::new(); + file.read_to_string(&mut content)?; + let all_lines: Vec<&str> = content.lines().collect(); + let show = &all_lines[all_lines.len().saturating_sub(lines)..]; + for line in show { + println!("{}", line); + } + loop { + std::thread::sleep(std::time::Duration::from_millis(500)); + let mut buf = String::new(); + file.read_to_string(&mut buf)?; + if !buf.is_empty() { + print!("{}", buf); + } + } + } + } else { + let content = std::fs::read_to_string(&log_path)?; + let all_lines: Vec<&str> = content.lines().collect(); + let show = &all_lines[all_lines.len().saturating_sub(lines)..]; + for line in show { + println!("{}", line); + } + } + + Ok(()) +} diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs new file mode 100644 index 0000000..3aa500e --- /dev/null +++ b/src/cli/doctor.rs @@ -0,0 +1,365 @@ +//! `wx doctor` — 环境 / 密钥 / 分片健康检查(多数项无需 daemon) + +use anyhow::Result; +use serde_json::json; +use std::path::Path; +use std::process::Command; + +use crate::config; +use crate::scanner::{self, KeyEntry}; + +#[derive(Debug)] +struct Check { + name: String, + ok: bool, + detail: String, + fix: Option, +} + +pub fn cmd_doctor(json: bool, fix: bool) -> Result<()> { + let checks = run_checks(); + if json { + let arr: Vec<_> = checks + .iter() + .map(|c| { + json!({ + "name": c.name, + "ok": c.ok, + "detail": c.detail, + "fix": c.fix, + }) + }) + .collect(); + println!("{}", serde_json::to_string_pretty(&json!({ "checks": arr }))?); + } else { + for c in &checks { + let icon = if c.ok { "✓" } else { "✗" }; + println!("{} {:<28} {}", icon, c.name, c.detail); + } + if fix { + let fixes: Vec<_> = checks + .iter() + .filter(|c| !c.ok) + .filter_map(|c| c.fix.as_ref()) + .collect(); + if !fixes.is_empty() { + println!("\n--- 修复建议 ---"); + for f in fixes { + println!("{f}"); + } + } + } + let all_ok = checks.iter().all(|c| c.ok); + if all_ok { + println!("\n全部检查通过。"); + } else { + println!( + "\n存在未通过项。补密钥:{}\n\ + (本机 GUI Terminal + 等待时打开相关聊天;SIP 无需关闭)", + config::RECOMMENDED_KEY_EXTRACT + ); + } + } + Ok(()) +} + +fn run_checks() -> Vec { + let mut out = Vec::new(); + + // WeChat process + let wechat_pid = find_wechat_pid(); + out.push(Check { + name: "WeChat 进程".into(), + ok: wechat_pid.is_some(), + detail: wechat_pid + .map(|p| format!("PID {p}")) + .unwrap_or_else(|| "未运行".into()), + fix: Some("请先登录并保持微信运行".into()), + }); + + // SIP — 非致命;状态仅供参考 + #[cfg(target_os = "macos")] + { + let sip = Command::new("csrutil").arg("status").output().ok(); + let text = sip + .map(|o| String::from_utf8_lossy(&o.stdout).to_string()) + .unwrap_or_default(); + let detail = if text.is_empty() { + "未知(取钥不依赖关闭 SIP;请用本机 Terminal + sudo)".into() + } else { + format!( + "{} — 取钥看 task_for_pid/TCC,不依赖关 SIP", + text.trim() + ) + }; + out.push(Check { + name: "SIP".into(), + ok: true, + detail, + fix: None, + }); + } + + // codesign + #[cfg(target_os = "macos")] + { + let sig = Command::new("codesign") + .args(["-dvv", "/Applications/WeChat.app"]) + .output() + .ok(); + let err = sig + .as_ref() + .map(|o| String::from_utf8_lossy(&o.stderr).to_string()) + .unwrap_or_default(); + let kind = if err.contains("adhoc") || err.contains("Signature=adhoc") { + "ad-hoc" + } else if err.contains("runtime") || err.contains("0x10000") { + "Hardened Runtime" + } else if err.is_empty() { + "未安装/无法读取" + } else { + "其他" + }; + out.push(Check { + name: "WeChat 签名".into(), + ok: Path::new("/Applications/WeChat.app").exists(), + detail: kind.into(), + fix: Some("官网包可为 ad-hoc;官方 Developer ID 包请 sudo 内存扫描".into()), + }); + } + + // lldb + #[cfg(target_os = "macos")] + { + let has_lldb = Command::new("which") + .arg("lldb") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + out.push(Check { + name: "lldb".into(), + ok: has_lldb, + detail: if has_lldb { + "可用".into() + } else { + "未找到".into() + }, + fix: Some("xcode-select --install".into()), + }); + } + + // config + let cfg = config::load_config().ok(); + out.push(Check { + name: "config.json".into(), + ok: cfg.is_some(), + detail: cfg + .as_ref() + .map(|c| c.db_dir.display().to_string()) + .unwrap_or_else(|| "未找到,请先 wx init".into()), + fix: Some("wx init".into()), + }); + + // keys + missing shards + if let Some(ref c) = cfg { + let keys_ok = c.keys_file.exists(); + let (n_keys, known) = if keys_ok { + load_known_entries(&c.keys_file) + } else { + (0, Vec::new()) + }; + out.push(Check { + name: "数据库密钥".into(), + ok: n_keys > 0, + detail: format!("{n_keys} 个密钥"), + fix: Some(config::RECOMMENDED_KEY_EXTRACT.into()), + }); + + let missing = scanner::list_missing_encrypted_dbs(&c.db_dir, &known); + let critical: Vec<_> = missing + .iter() + .filter(|m| scanner::is_critical_missing_db(&m.rel)) + .cloned() + .collect(); + let optional: Vec<_> = missing + .iter() + .filter(|m| !scanner::is_critical_missing_db(&m.rel)) + .cloned() + .collect(); + + if critical.is_empty() { + out.push(Check { + name: "关键分片密钥".into(), + ok: n_keys > 0, + detail: if n_keys > 0 { + "聊天 / session / contact 齐全".into() + } else { + "无密钥".into() + }, + fix: None, + }); + } else { + let preview = format_missing_preview(&critical, 6); + let total_mb: f64 = critical.iter().map(|m| m.size as f64).sum::() + / (1024.0 * 1024.0); + out.push(Check { + name: "关键分片密钥".into(), + ok: false, + detail: format!( + "{} 个缺失(约 {:.0}MB):{}", + critical.len(), + total_mb, + preview + ), + fix: Some(format!( + "{}\n\ + 等待期间在微信中打开对应聊天(触发冷分片加载):\n\ + {}", + config::RECOMMENDED_KEY_EXTRACT, + critical + .iter() + .take(8) + .map(|m| format!(" · {} ({})", m.rel, scanner::format_db_size(m.size))) + .collect::>() + .join("\n") + )), + }); + } + + if !optional.is_empty() { + let preview = format_missing_preview(&optional, 4); + out.push(Check { + name: "旁路库密钥".into(), + ok: true, // 不阻断日常查询 + detail: format!( + "{} 个可选缺失(如 migrate/*):{}", + optional.len(), + preview + ), + fix: Some(format!( + "一般可忽略;若需要再 {}", + config::RECOMMENDED_KEY_EXTRACT + )), + }); + } + + // SQLCipher online probe + if let Some(session_key) = read_key_for(&c.keys_file, "session/session.db") { + let session_path = c.db_dir.join("session/session.db"); + let online = + crate::crypto::sqlcipher::open_encrypted_readonly(&session_path, &session_key); + out.push(Check { + name: "SQLCipher 在线打开".into(), + ok: online.is_ok(), + detail: if online.is_ok() { + "session.db OK".into() + } else { + format!("{:#}", online.err().unwrap()) + }, + fix: Some(format!( + "确认密钥与微信版本匹配;{}", + config::RECOMMENDED_KEY_EXTRACT + )), + }); + } + + // FTS key + let fts = read_key_for(&c.keys_file, "message/message_fts.db"); + out.push(Check { + name: "message_fts 密钥".into(), + ok: fts.is_some(), + detail: if fts.is_some() { + "已配置(search 可走 FTS)".into() + } else { + "缺失(search 将回退全库扫描)".into() + }, + fix: Some(config::RECOMMENDED_KEY_EXTRACT.into()), + }); + } + + // daemon sock + let sock = config::sock_path(); + out.push(Check { + name: "daemon socket".into(), + ok: sock.exists(), + detail: if sock.exists() { + sock.display().to_string() + } else { + "未运行(首次查询会自动启动)".into() + }, + fix: Some("wx sessions 或 wx daemon start".into()), + }); + + out +} + +fn load_known_entries(keys_path: &Path) -> (usize, Vec) { + let content = std::fs::read_to_string(keys_path).unwrap_or_default(); + let v: serde_json::Value = serde_json::from_str(&content).unwrap_or(json!({})); + let mut known = Vec::new(); + if let Some(obj) = v.as_object() { + for (k, val) in obj { + if k.starts_with('_') { + continue; + } + let enc = val + .as_str() + .map(|s| s.to_string()) + .or_else(|| { + val.get("enc_key") + .and_then(|e| e.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_default(); + if enc.len() == 64 { + known.push(KeyEntry { + db_name: k.replace('\\', "/"), + enc_key: enc, + salt: String::new(), + }); + } + } + } + (known.len(), known) +} + +fn format_missing_preview(items: &[scanner::MissingDb], max: usize) -> String { + let parts: Vec = items + .iter() + .take(max) + .map(|m| format!("{} ({})", m.rel, scanner::format_db_size(m.size))) + .collect(); + if items.len() > max { + format!("{} …+{}", parts.join(", "), items.len() - max) + } else { + parts.join(", ") + } +} + +fn find_wechat_pid() -> Option { + let out = Command::new("pgrep").args(["-x", "WeChat"]).output().ok()?; + if !out.status.success() { + return None; + } + String::from_utf8_lossy(&out.stdout) + .lines() + .next()? + .trim() + .parse() + .ok() +} + +fn read_key_for(keys_path: &Path, rel: &str) -> Option { + let content = std::fs::read_to_string(keys_path).ok()?; + let v: serde_json::Value = serde_json::from_str(&content).ok()?; + let entry = v.get(rel)?; + if let Some(s) = entry.as_str() { + return Some(s.to_string()); + } + entry + .get("enc_key") + .and_then(|e| e.as_str()) + .map(|s| s.to_string()) +} diff --git a/src/cli/export.rs b/src/cli/export.rs new file mode 100644 index 0000000..d19a000 --- /dev/null +++ b/src/cli/export.rs @@ -0,0 +1,103 @@ +use super::history::{parse_time, parse_time_end}; +use super::output::{emit_warnings, warning_block_markdown, warning_block_text, OutputOpts}; +use super::transport; +use crate::ipc::Request; +use anyhow::Result; + +pub fn cmd_export( + chat: String, + since: Option, + until: Option, + limit: usize, + format: String, + output: Option, + opts: OutputOpts, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + let (with_meta, debug_source) = opts.request_flags(); + + let req = Request::History { + chat, + limit, + offset: 0, + since: since_ts, + until: until_ts, + after_ts: None, + before_ts: None, + msg_type: None, + with_meta, + debug_source, + }; + + let resp = transport::send(req)?; + emit_warnings(&resp.data); + let messages = resp.data["messages"] + .as_array() + .cloned() + .unwrap_or_default(); + let chat_name = resp.data["chat"].as_str().unwrap_or("").to_string(); + let is_group = resp.data["is_group"].as_bool().unwrap_or(false); + let count = messages.len(); + + let text = match format.as_str() { + "json" => serde_json::to_string_pretty(&resp.data)?, + "yaml" => serde_yaml::to_string(&resp.data)?, + "txt" => { + let group_str = if is_group { "[群]" } else { "" }; + let mut lines = vec![format!( + "=== {}{} ({} 条) ===\n", + chat_name, group_str, count + )]; + if let Some(warn) = warning_block_text(&resp.data) { + lines.push(warn); + lines.push(String::new()); + } + for m in &messages { + let time = m["time"].as_str().unwrap_or(""); + let sender = m["sender"].as_str().unwrap_or(""); + let content = m["content"].as_str().unwrap_or(""); + let sender_str = if !sender.is_empty() { + format!("{}: ", sender) + } else { + String::new() + }; + lines.push(format!("[{}] {}{}", time, sender_str, content)); + } + lines.join("\n") + } + _ => { + // markdown (default) + let group_str = if is_group { "(群聊)" } else { "" }; + let mut lines = vec![ + format!("# {}{}", chat_name, group_str), + format!("\n> 导出 {} 条消息\n", count), + ]; + if let Some(warn) = warning_block_markdown(&resp.data) { + lines.push(warn); + } + for m in &messages { + let time = m["time"].as_str().unwrap_or(""); + let sender = m["sender"].as_str().unwrap_or(""); + let content = m["content"].as_str().unwrap_or("").replace('\n', "\n> "); + let sender_md = if !sender.is_empty() { + format!("**{}**: ", sender) + } else { + String::new() + }; + lines.push(format!("### {}\n\n{}{}\n", time, sender_md, content)); + } + lines.join("\n") + } + }; + + match output { + Some(path) => { + std::fs::write(&path, &text)?; + println!("已导出 {} 条消息到 {}", count, path); + } + None => println!("{}", text), + } + + Ok(()) +} diff --git a/src/cli/extract.rs b/src/cli/extract.rs new file mode 100644 index 0000000..a0eba0d --- /dev/null +++ b/src/cli/extract.rs @@ -0,0 +1,25 @@ +use anyhow::Result; + +use crate::ipc::Request; +use super::output::{print_value, resolve}; +use super::transport; + +/// `wx extract` — 把单个 `attachment_id` 对应的资源解密写到指定路径。 +/// +/// daemon 端:解析 `attachment_id` → 查 `message_resource.db` 拿 file md5 → +/// 在 `/msg/attach/...` 找 .dat → 按 magic 分发到 v1/v2 解码器 → +/// 写出真实图片/文件。 +pub fn cmd_extract( + attachment_id: String, + output: String, + overwrite: bool, + json: bool, +) -> Result<()> { + let req = Request::Extract { + attachment_id, + output, + overwrite, + }; + let resp = transport::send(req)?; + print_value(&resp.data, &resolve(json)) +} diff --git a/src/cli/favorites.rs b/src/cli/favorites.rs new file mode 100644 index 0000000..84db1d6 --- /dev/null +++ b/src/cli/favorites.rs @@ -0,0 +1,29 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::transport; +use super::output::{resolve, print_value}; + +fn parse_fav_type(s: &str) -> Option { + match s { + "text" => Some(1), + "image" => Some(2), + "article" => Some(5), + "card" => Some(19), + "video" => Some(20), + _ => None, + } +} + +pub fn cmd_favorites( + limit: usize, + fav_type: Option, + query: Option, + json: bool, +) -> Result<()> { + let type_val = fav_type.as_deref().and_then(parse_fav_type); + let resp = transport::send(Request::Favorites { limit, fav_type: type_val, query })?; + let items = resp.data.get("items") + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + print_value(&items, &resolve(json)) +} diff --git a/src/cli/history.rs b/src/cli/history.rs new file mode 100644 index 0000000..99ad199 --- /dev/null +++ b/src/cli/history.rs @@ -0,0 +1,144 @@ +use super::output::{emit_warnings, print_response, OutputOpts}; +use super::transport; +use crate::ipc::Request; +use anyhow::Result; + +pub fn cmd_history( + chat: String, + limit: usize, + offset: usize, + since: Option, + until: Option, + after: Option, + before: Option, + msg_type: Option, + opts: OutputOpts, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + let after_ts = after.as_deref().map(parse_time_or_unix).transpose()?; + let before_ts = before.as_deref().map(parse_time_or_unix).transpose()?; + let type_val = match msg_type.as_deref() { + Some(s) => Some(parse_msg_type_required(s)?), + None => None, + }; + let (with_meta, debug_source) = opts.request_flags(); + + let req = Request::History { + chat, + limit, + offset, + since: since_ts, + until: until_ts, + after_ts, + before_ts, + msg_type: type_val, + with_meta, + debug_source, + }; + let resp = transport::send(req)?; + emit_warnings(&resp.data); + print_response(&resp.data, &opts) +} + +/// 支持 Unix 秒时间戳数字,或与 parse_time 相同的日期字符串。 +pub fn parse_time_or_unix(s: &str) -> Result { + if let Ok(n) = s.parse::() { + if n > 1_000_000_000 { + return Ok(n); + } + } + parse_time(s) +} + +pub fn parse_time(s: &str) -> Result { + use chrono::{Local, TimeZone}; + for fmt in &["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"] { + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) { + return Local + .from_local_datetime(&dt) + .single() + .map(|d| d.timestamp()) + .ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s)); + } + } + if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") { + let dt = d.and_hms_opt(0, 0, 0).unwrap(); + return Local + .from_local_datetime(&dt) + .single() + .map(|d| d.timestamp()) + .ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s)); + } + anyhow::bail!( + "无法解析时间 '{}',支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS", + s + ) +} + +pub fn parse_time_end(s: &str) -> Result { + use chrono::{Local, TimeZone}; + if s.len() == 10 { + if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") { + let dt = d.and_hms_opt(23, 59, 59).unwrap(); + return Local + .from_local_datetime(&dt) + .single() + .map(|d| d.timestamp()) + .ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s)); + } + } + parse_time(s) +} + +/// 将消息类型字符串转为 local_type 整数,未知类型返回 None +pub fn parse_msg_type(s: &str) -> Option { + match s { + "text" => Some(1), + "image" => Some(3), + "voice" => Some(34), + "video" => Some(43), + "card" => Some(42), + "sticker" => Some(47), + "location" => Some(48), + // appmsg 总类(链接/文件/引用…);与 type_id "appmsg" 对齐 + "link" | "file" | "appmsg" => Some(49), + "call" => Some(50), + "system" => Some(10000), + "revoke" => Some(10002), + // 允许 agent 直接传数字 type_code + other if other.chars().all(|c| c.is_ascii_digit()) => other.parse().ok(), + _ => None, + } +} + +/// Agent-first:未知 `--type` 必须失败,禁止静默忽略过滤器。 +pub fn parse_msg_type_required(s: &str) -> Result { + parse_msg_type(s).ok_or_else(|| { + anyhow::anyhow!( + "未知消息类型 '{}'。支持: text, image, voice, video, card, sticker, location, \ + link|file|appmsg, call, system, revoke,或数字 type_code", + s + ) + }) +} + +#[cfg(test)] +mod msg_type_tests { + use super::*; + + #[test] + fn parse_msg_type_accepts_slugs_and_codes() { + assert_eq!(parse_msg_type("text"), Some(1)); + assert_eq!(parse_msg_type("appmsg"), Some(49)); + assert_eq!(parse_msg_type("49"), Some(49)); + assert_eq!(parse_msg_type("revoke"), Some(10002)); + assert!(parse_msg_type("not-a-type").is_none()); + } + + #[test] + fn parse_msg_type_required_errors_on_unknown() { + assert!(parse_msg_type_required("nope").is_err()); + assert_eq!(parse_msg_type_required("image").unwrap(), 3); + } +} diff --git a/src/cli/init.rs b/src/cli/init.rs new file mode 100644 index 0000000..b37c7e4 --- /dev/null +++ b/src/cli/init.rs @@ -0,0 +1,405 @@ +use anyhow::{bail, Context, Result}; +use serde_json::json; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use crate::config; +use crate::scanner::{self, KeyEntry, ScanOptions}; + +pub fn cmd_init(force: bool, hook_seconds: Option) -> Result<()> { + // 查找 config.json + let config_path = find_or_create_config_path(); + + // 检查是否已初始化 + if !force && config_path.exists() { + if let Ok(content) = std::fs::read_to_string(&config_path) { + if let Ok(cfg) = serde_json::from_str::(&content) { + let db_dir = cfg.get("db_dir").and_then(|v| v.as_str()).unwrap_or(""); + let keys_file = cfg + .get("keys_file") + .and_then(|v| v.as_str()) + .unwrap_or("all_keys.json"); + let keys_path = resolve_keys_path(&config_path, keys_file); + if !db_dir.is_empty() + && !db_dir.contains("your_wxid") + && Path::new(db_dir).exists() + && keys_path.exists() + { + println!("已初始化,数据目录: {}", db_dir); + println!("如需重新扫描密钥,使用 --force"); + // 仍检查磁盘上是否有未收录的分片 + if let Ok(existing) = load_existing_entries(&keys_path, Path::new(db_dir)) { + let missing = scanner::missing_encrypted_dbs(Path::new(db_dir), &existing); + if !missing.is_empty() { + let critical: Vec<_> = missing + .iter() + .filter(|n| scanner::is_critical_missing_db(n)) + .collect(); + println!( + "[wx] 警告:磁盘上仍有 {} 个加密 DB 没有密钥(关键 {} 个,例如 {})。\n\ + 运行 {} 重新提取,并在等待期间打开对应聊天以触发冷分片解密。", + missing.len(), + critical.len(), + critical + .first() + .copied() + .or(missing.first()) + .map(|s| s.as_str()) + .unwrap_or(""), + config::RECOMMENDED_KEY_EXTRACT + ); + } + } + return Ok(()); + } + } + } + } + + // Step 1: 解析 db_dir —— 已有有效配置时优先沿用,避免多账号下 auto-detect 切错库。 + let db_dir = resolve_db_dir(&config_path)?; + println!("数据目录: {}", db_dir.display()); + + // 读取已有密钥(验证仍有效的保留,避免 force 扫描不全时丢 key) + let keys_file_path = config_path + .parent() + .unwrap_or(Path::new(".")) + .join("all_keys.json"); + let existing_entries = load_existing_entries(&keys_file_path, &db_dir).unwrap_or_default(); + if !existing_entries.is_empty() { + println!( + "已有 {} 个仍可解密的密钥,将与新扫描结果合并", + existing_entries.len() + ); + } + + // Step 2: 扫描密钥 + println!("扫描加密密钥…"); + let mut opts = ScanOptions { + known: &existing_entries, + ..ScanOptions::default() + }; + if let Some(secs) = hook_seconds { + opts.hook_seconds = secs; + opts.auto_hook = secs > 0; + } + let scanned = scanner::scan_keys_with_options(&db_dir, opts)?; + // scan 内部已合并 known;再 merge 一次保证兜底 + let entries = scanner::merge_key_entries(&scanned, &existing_entries); + + if entries.is_empty() { + bail!( + "没有任何候选 key 能解密所选数据目录中的数据库,已保留现有配置和 key 文件。\n\ + 当前数据目录: {}\n\ + 如果本机登录过多个微信账号,请确认该目录属于当前正在运行的账号;\ + 退出其他账号并让当前账号产生一条新消息后,再运行:\n\ + {}\n\ + 冷分片(久未打开的 message_N.db)同一命令,等待期间滚动/打开对应会话。", + db_dir.display(), + config::RECOMMENDED_KEY_EXTRACT + ); + } + + // === 权限边界 === + // 扫描完成后立即 drop 到调用用户身份,后续文件写入都是用户属主。 + #[cfg(unix)] + drop_privileges_if_sudo()?; + + // 确保父目录存在(如 ~/.wx-cli/),必须在任何写入之前 + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("创建目录失败: {}", parent.display()))?; + } + + // Step 3: 保存 all_keys.json(合并后的完整集合) + let mut keys_json = serde_json::Map::new(); + for entry in &entries { + keys_json.insert( + entry.db_name.clone(), + json!({ + "enc_key": entry.enc_key, + }), + ); + } + std::fs::write(&keys_file_path, serde_json::to_string_pretty(&keys_json)?) + .context("写入 all_keys.json 失败")?; + println!( + "成功保存 {} 个数据库密钥(本次新匹配 {})", + entries.len(), + scanned.len() + ); + println!("密钥已保存: {}", keys_file_path.display()); + + let missing = scanner::list_missing_encrypted_dbs(&db_dir, &entries); + let critical: Vec<_> = missing + .iter() + .filter(|m| scanner::is_critical_missing_db(&m.rel)) + .collect(); + if !missing.is_empty() { + println!( + "[wx] 警告:仍有 {} 个加密 DB 没有密钥(其中 {} 个影响聊天完整性):", + missing.len(), + critical.len() + ); + for m in missing.iter().take(12) { + let tag = if scanner::is_critical_missing_db(&m.rel) { + " [关键]" + } else { + "" + }; + println!( + " - {} ({}){}", + m.rel, + scanner::format_db_size(m.size), + tag + ); + } + if missing.len() > 12 { + println!(" … 另有 {} 个", missing.len() - 12); + } + if !critical.is_empty() { + println!( + "补齐关键分片:{}\n\ + 等待期间请在微信中打开相关聊天/滚动历史,触发冷分片加载。", + config::RECOMMENDED_KEY_EXTRACT + ); + } + } + + // Step 4: 保存 config.json + let mut cfg = HashMap::new(); + if config_path.exists() { + if let Ok(c) = std::fs::read_to_string(&config_path) { + if let Ok(v) = serde_json::from_str::>(&c) { + for (k, val) in v { + cfg.insert(k, val); + } + } + } + } + cfg.insert("db_dir".into(), json!(db_dir.to_string_lossy())); + cfg.entry("keys_file".into()) + .or_insert_with(|| json!("all_keys.json")); + cfg.entry("decrypted_dir".into()) + .or_insert_with(|| json!("decrypted")); + + std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?) + .context("写入 config.json 失败")?; + println!("配置已保存: {}", config_path.display()); + println!("初始化完成,可以使用 wx sessions / wx history 等命令了"); + + #[cfg(target_os = "macos")] + { + println!(); + println!("[macOS] 说明:"); + println!(" · SIP 无需关闭;wx-cli 不会自动 ad-hoc 重签 WeChat.app。"); + println!( + " · 官网部分 4.x 包本身已是 ad-hoc,可直接用户态 LLDB hook,无需 sudo 重签。" + ); + println!( + " · 官方 Hardened Runtime 包:内存扫描请 sudo;补冷分片用 --hook-seconds。" + ); + } + + Ok(()) +} + +fn resolve_keys_path(config_path: &Path, keys_file: &str) -> PathBuf { + if Path::new(keys_file).is_absolute() { + PathBuf::from(keys_file) + } else { + config_path + .parent() + .unwrap_or(Path::new(".")) + .join(keys_file) + } +} + +/// 优先使用 config 里已配置且仍存在的 `db_dir`;否则再 auto-detect。 +/// +/// 多账号场景下,`auto_detect` 按 mtime 选最新目录可能切到闲置号, +/// 导致 force 提取时已有密钥全部校验失败、用户误以为密钥丢了。 +fn resolve_db_dir(config_path: &Path) -> Result { + if config_path.exists() { + if let Ok(content) = std::fs::read_to_string(config_path) { + if let Ok(cfg) = serde_json::from_str::(&content) { + if let Some(dir) = cfg.get("db_dir").and_then(|v| v.as_str()) { + let p = PathBuf::from(dir); + if !dir.is_empty() + && !dir.contains("your_wxid") + && p.is_dir() + { + // 若磁盘上另有更新的账号目录,仅提示,不擅自切换 + if let Some(detected) = config::auto_detect_db_dir() { + if detected != p { + eprintln!( + "[wx] 提示:检测到更新的微信数据目录 {},\n\ + 当前仍使用已配置的 {}。\n\ + 若要切换账号,请编辑 config.json 的 db_dir 后重新 init。", + detected.display(), + p.display() + ); + } + } + return Ok(p); + } + } + } + } + } + println!("检测微信数据目录..."); + config::auto_detect_db_dir() + .context("未能自动检测到微信数据目录\n请手动编辑 config.json 中的 db_dir 字段") +} + +/// 加载已有 all_keys.json,并丢弃无法再解密对应 DB 的条目。 +fn load_existing_entries(keys_path: &Path, db_dir: &Path) -> Result> { + if !keys_path.exists() { + return Ok(Vec::new()); + } + let content = std::fs::read_to_string(keys_path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + let mut out = Vec::new(); + let Some(obj) = value.as_object() else { + return Ok(out); + }; + for (db_name, v) in obj { + if db_name.starts_with('_') { + continue; + } + let enc_key = if let Some(s) = v.as_str() { + s.to_string() + } else if let Some(o) = v.as_object() { + o.get("enc_key") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string() + } else { + continue; + }; + if enc_key.len() != 64 { + continue; + } + let db_path = db_dir.join(db_name); + if !db_path.exists() { + continue; + } + let Some(key) = scanner::decode_key_hex_pub(&enc_key) else { + continue; + }; + if crate::crypto::validate_raw_key_for_db(&db_path, &key) { + let salt = scanner::read_db_salt(&db_path).unwrap_or_default(); + out.push(KeyEntry { + db_name: db_name.replace('\\', "/"), + enc_key: enc_key.to_lowercase(), + salt, + }); + } + } + Ok(out) +} + +/// 如果当前以 root 身份运行且是通过 sudo 启动的,drop 到调用用户身份, +/// 并迁移旧版本遗留的 root 属主 `~/.wx-cli/`。 +/// +/// 只影响本进程;daemon(后续 fork)会继承调用用户身份。 +#[cfg(unix)] +fn drop_privileges_if_sudo() -> Result<()> { + use std::ffi::CString; + use std::os::unix::ffi::OsStrExt; + use std::path::Path; + + // 当前不是 root(用户直接以非 root 跑的 `wx init`)→ 什么都不做 + if unsafe { libc::geteuid() } != 0 { + return Ok(()); + } + + let sudo_uid: Option = std::env::var("SUDO_UID").ok().and_then(|s| s.parse().ok()); + let sudo_gid: Option = std::env::var("SUDO_GID").ok().and_then(|s| s.parse().ok()); + let (uid, gid) = match (sudo_uid, sudo_gid) { + (Some(u), Some(g)) if u != 0 => (u, g), + // 直接以 root 登陆(非 sudo),没有"调用用户"可还原 → 保持 root + _ => return Ok(()), + }; + + // 迁移旧版本遗留:如果 ~/.wx-cli/ 已存在且属 root,把它 chown 回调用用户, + // 顺便把 raw key 文件的权限也收紧到 0600(旧版默认 0644,世界可读等于泄露)。 + // 这些必须在 setuid 之前做:chown 需要 root,chmod 也只有属主或 root 能改。 + let cli_dir = config::cli_dir(); + if cli_dir.exists() { + let _ = chown_recursive(&cli_dir, uid, gid); + let _ = tighten_perms(&cli_dir); + } + + // 设置 umask,让后续 create 出来的文件/目录默认是 0600 / 0700。 + unsafe { + libc::umask(0o077); + } + + // 必须先 setgid 再 setuid:一旦 uid 降下来就没法再改 gid 了。 + unsafe { + if libc::setgid(gid) != 0 { + anyhow::bail!("setgid({}) 失败: {}", gid, std::io::Error::last_os_error()); + } + if libc::setuid(uid) != 0 { + anyhow::bail!("setuid({}) 失败: {}", uid, std::io::Error::last_os_error()); + } + } + + // chown 递归实现 + fn chown_recursive(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> { + chown_one(path, uid, gid)?; + let md = std::fs::symlink_metadata(path)?; + if md.is_dir() { + for entry in std::fs::read_dir(path)? { + chown_recursive(&entry?.path(), uid, gid)?; + } + } + Ok(()) + } + fn chown_one(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> { + let c = CString::new(path.as_os_str().as_bytes()).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL") + })?; + if unsafe { libc::chown(c.as_ptr(), uid, gid) } != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + } + + /// 目录收紧到 0700,所有 *.json 文件(含 all_keys.json 这类 raw key)收紧到 0600。 + fn tighten_perms(cli_dir: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(cli_dir, std::fs::Permissions::from_mode(0o700))?; + for entry in std::fs::read_dir(cli_dir)? { + let entry = entry?; + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("json") { + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + } + Ok(()) + } + + Ok(()) +} + +fn find_or_create_config_path() -> std::path::PathBuf { + // 如果当前工作目录或可执行文件目录已有 config.json,沿用它(支持便携模式) + if let Ok(cwd) = std::env::current_dir() { + let p = cwd.join("config.json"); + if p.exists() { + return p; + } + } + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + let p = dir.join("config.json"); + if p.exists() { + return p; + } + } + } + // 默认写入 ~/.wx-cli/config.json(与 load_config 的最终查找路径保持一致) + config::cli_dir().join("config.json") +} diff --git a/src/cli/key_cmd.rs b/src/cli/key_cmd.rs new file mode 100644 index 0000000..1173515 --- /dev/null +++ b/src/cli/key_cmd.rs @@ -0,0 +1,209 @@ +//! `wx key` — 密钥管理(extract / list / set) + +use anyhow::{bail, Context, Result}; +use serde_json::json; +use std::collections::BTreeMap; +use crate::config; +use crate::scanner::{self, KeyEntry}; + +pub fn cmd_key_list(json: bool, show_secrets: bool) -> Result<()> { + let cfg = config::load_config().context("请先 wx init")?; + let content = std::fs::read_to_string(&cfg.keys_file) + .with_context(|| format!("读取 {}", cfg.keys_file.display()))?; + let v: serde_json::Value = serde_json::from_str(&content)?; + let mut known = Vec::new(); + let mut rows = Vec::new(); + if let Some(obj) = v.as_object() { + for (k, val) in obj { + if k.starts_with('_') { + continue; + } + let enc = val + .as_str() + .map(|s| s.to_string()) + .or_else(|| { + val.get("enc_key") + .and_then(|e| e.as_str()) + .map(|s| s.to_string()) + }) + .unwrap_or_default(); + if enc.is_empty() { + continue; + } + let preview = format!("{}…", &enc[..enc.len().min(12)]); + known.push(KeyEntry { + db_name: k.replace('\\', "/"), + enc_key: enc.clone(), + salt: String::new(), + }); + if show_secrets { + rows.push(json!({ + "db": k, + "enc_key": enc, + "preview": preview, + })); + } else { + rows.push(json!({ + "db": k, + "preview": preview, + })); + } + } + } + rows.sort_by(|a, b| { + a["db"] + .as_str() + .unwrap_or("") + .cmp(b["db"].as_str().unwrap_or("")) + }); + + let missing = scanner::list_missing_encrypted_dbs(&cfg.db_dir, &known); + let critical: Vec<_> = missing + .iter() + .filter(|m| scanner::is_critical_missing_db(&m.rel)) + .cloned() + .collect(); + let optional: Vec<_> = missing + .iter() + .filter(|m| !scanner::is_critical_missing_db(&m.rel)) + .cloned() + .collect(); + + if json { + let miss_json: Vec<_> = missing + .iter() + .map(|m| { + json!({ + "db": m.rel, + "size": m.size, + "size_human": scanner::format_db_size(m.size), + "critical": scanner::is_critical_missing_db(&m.rel), + }) + }) + .collect(); + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "count": rows.len(), + "keys": rows, + "missing": miss_json, + "critical_missing": critical.len(), + "optional_missing": optional.len(), + }))? + ); + } else { + println!("密钥文件: {}", cfg.keys_file.display()); + println!("数据目录: {}", cfg.db_dir.display()); + println!("共 {} 个密钥", rows.len()); + for r in &rows { + println!( + " {} {}", + r["db"].as_str().unwrap_or(""), + r["preview"].as_str().unwrap_or("") + ); + } + if !show_secrets { + println!("(完整 enc_key 需 --show-secrets)"); + } + if !critical.is_empty() { + println!( + "\n✗ 关键缺失 {} 个(影响聊天完整性):", + critical.len() + ); + for m in &critical { + println!( + " · {} ({})", + m.rel, + scanner::format_db_size(m.size) + ); + } + println!( + "补齐:{}\n\ + 等待期间在微信中打开相关聊天。", + config::RECOMMENDED_KEY_EXTRACT + ); + } else if !missing.is_empty() { + println!("\n✓ 关键聊天分片密钥齐全"); + } else { + println!("\n✓ 磁盘加密 DB 均已覆盖"); + } + if !optional.is_empty() { + println!("旁路/可选缺失 {} 个:", optional.len()); + for m in optional.iter().take(6) { + println!( + " · {} ({})", + m.rel, + scanner::format_db_size(m.size) + ); + } + } + } + Ok(()) +} + +pub fn cmd_key_set(db_name: &str, enc_key: &str) -> Result<()> { + let key = enc_key.trim().to_lowercase(); + if key.len() != 64 || !key.chars().all(|c| c.is_ascii_hexdigit()) { + bail!("enc_key 必须是 64 位 hex"); + } + let cfg = config::load_config().context("请先 wx init")?; + let mut map: BTreeMap = if cfg.keys_file.exists() { + let content = std::fs::read_to_string(&cfg.keys_file)?; + serde_json::from_str(&content).unwrap_or_default() + } else { + BTreeMap::new() + }; + let rel = db_name.replace('\\', "/"); + // validate if file exists + let path = cfg.db_dir.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR)); + if path.exists() { + if let Some(raw) = scanner::decode_key_hex_pub(&key) { + if !crate::crypto::validate_raw_key_for_db(&path, &raw) { + bail!("密钥无法解密 {},请确认 hex 正确", rel); + } + } + } + map.insert(rel.clone(), json!({ "enc_key": key })); + if let Some(parent) = cfg.keys_file.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&cfg.keys_file, serde_json::to_string_pretty(&map)?)?; + println!("已写入密钥: {} → {}", rel, cfg.keys_file.display()); + // try hot-reload(会 invalidate 解密缓存) + match super::transport::send(crate::ipc::Request::ReloadConfig) { + Ok(resp) if resp.ok => { + println!( + "已热重载 daemon 配置(keys={})", + resp.data + .get("keys") + .and_then(|v| v.as_u64()) + .unwrap_or(0) + ); + } + Ok(resp) => { + eprintln!( + "daemon 热重载失败: {};请执行 wx daemon restart", + resp.error.unwrap_or_default() + ); + } + Err(e) => { + eprintln!("daemon 未运行或无法连接({});下次启动将加载新密钥", e); + } + } + Ok(()) +} + +pub fn cmd_key_extract(hook_seconds: Option) -> Result<()> { + println!( + "提取密钥(内存扫描 + 可选 LLDB hook;推荐:{})…", + config::RECOMMENDED_KEY_EXTRACT + ); + #[cfg(unix)] + if unsafe { libc::geteuid() } != 0 { + eprintln!( + "警告: 建议使用 {},以便 task_for_pid 读取进程内存", + config::RECOMMENDED_KEY_EXTRACT + ); + } + super::init::cmd_init(true, hook_seconds) +} diff --git a/src/cli/media.rs b/src/cli/media.rs new file mode 100644 index 0000000..1ef17a5 --- /dev/null +++ b/src/cli/media.rs @@ -0,0 +1,111 @@ +//! 媒体工具:语音等从本地加密库导出 + +use anyhow::{bail, Context, Result}; +use std::path::PathBuf; + +use crate::config; +use crate::crypto::sqlcipher; + +/// 从 message/media_*.db 的 VoiceInfo 表按 svr_id 导出 voice_data。 +pub fn cmd_voice_export(svr_id: i64, chat: Option, output: String) -> Result<()> { + let cfg = config::load_config().context("请先 wx init")?; + let keys: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&cfg.keys_file).context("读取密钥失败")?)?; + + // 候选 media 库 + let mut candidates: Vec<(PathBuf, String)> = Vec::new(); + for name in [ + "message/media_0.db", + "message/media_1.db", + "message/media_2.db", + "message/media_3.db", + ] { + if let Some(key) = key_of(&keys, name) { + let p = cfg.db_dir.join(name.replace('/', std::path::MAIN_SEPARATOR_STR)); + if p.exists() { + candidates.push((p, key)); + } + } + } + if candidates.is_empty() { + bail!( + "未找到带密钥的 media_*.db,请 {}", + crate::config::RECOMMENDED_KEY_EXTRACT + ); + } + + let out = PathBuf::from(&output); + if let Some(parent) = out.parent() { + std::fs::create_dir_all(parent)?; + } + + for (path, key) in candidates { + let conn = match sqlcipher::open_encrypted_readonly(&path, &key) { + Ok(c) => c, + Err(e) => { + eprintln!("skip {}: {:#}", path.display(), e); + continue; + } + }; + let has = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='VoiceInfo'", + [], + |r| r.get::<_, i64>(0), + ) + .is_ok(); + if !has { + continue; + } + + let blob: Option> = if let Some(ref chat_name) = chat { + conn.query_row( + "SELECT voice_data FROM VoiceInfo WHERE svr_id = ?1 AND chat_name_id = ?2 LIMIT 1", + rusqlite::params![svr_id, chat_name], + |r| r.get(0), + ) + .ok() + .or_else(|| { + conn.query_row( + "SELECT voice_data FROM VoiceInfo WHERE svr_id = ?1 LIMIT 1", + [svr_id], + |r| r.get(0), + ) + .ok() + }) + } else { + conn.query_row( + "SELECT voice_data FROM VoiceInfo WHERE svr_id = ?1 LIMIT 1", + [svr_id], + |r| r.get(0), + ) + .ok() + }; + + if let Some(data) = blob { + if data.is_empty() { + continue; + } + std::fs::write(&out, &data)?; + println!( + "已导出 {} 字节 → {} (from {})", + data.len(), + out.display(), + path.display() + ); + return Ok(()); + } + } + + bail!("未在 media_*.db 中找到 svr_id={}", svr_id); +} + +fn key_of(keys: &serde_json::Value, rel: &str) -> Option { + let e = keys.get(rel)?; + if let Some(s) = e.as_str() { + return Some(s.to_string()); + } + e.get("enc_key") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} diff --git a/src/cli/members.rs b/src/cli/members.rs new file mode 100644 index 0000000..2579fd1 --- /dev/null +++ b/src/cli/members.rs @@ -0,0 +1,12 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::transport; +use super::output::{resolve, print_value}; + +pub fn cmd_members(chat: String, json: bool) -> Result<()> { + let resp = transport::send(Request::Members { chat })?; + let members = resp.data.get("members") + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + print_value(&members, &resolve(json)) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..9855223 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,741 @@ +pub mod attachments; +pub mod biz_articles; +pub mod contacts; +pub mod daemon_cmd; +pub mod doctor; +pub mod export; +pub mod extract; +pub mod favorites; +pub mod history; +pub(crate) mod init; +pub mod key_cmd; +pub mod media; +pub mod members; +pub mod new_messages; +pub mod output; +pub mod search; +pub mod sessions; +pub mod sns_feed; +pub mod sns_notifications; +pub mod sns_search; +pub mod stats; +pub mod timeline; +pub mod transport; +pub mod unread; +pub mod watch; + +use self::output::OutputOpts; +use anyhow::Result; +use clap::{Parser, Subcommand}; + +/// Clap `value_parser` for `--type`: must accept every slug/`type_id` and numeric codes +/// that `history::parse_msg_type` knows — closed string lists reject agent round-trips. +fn clap_parse_msg_type(s: &str) -> std::result::Result { + history::parse_msg_type_required(s) + .map(|_| s.to_string()) + .map_err(|e| e.to_string()) +} + +const MSG_TYPE_HELP: &str = "消息类型过滤 [text|image|voice|video|card|sticker|location|link|file|appmsg|call|system|revoke|数字code]"; + +/// wx — 微信本地数据 CLI +#[derive(Parser)] +#[command(name = "wx", version = env!("CARGO_PKG_VERSION"), about = "wx — 微信本地数据 CLI")] +pub struct Cli { + /// 返回更重的 freshness/source 元数据(如 per-shard latest、cache modes) + #[arg(long, global = true)] + with_meta: bool, + /// 在 meta 里暴露真实 shard 路径(调试用) + #[arg(long, global = true, hide = true)] + debug_source: bool, + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// 初始化:检测数据目录并扫描加密密钥 + Init { + /// 强制重新扫描(与已有有效密钥合并,不会因部分失败而清空) + #[arg(long)] + force: bool, + /// macOS: LLDB hook 等待秒数(0=禁用)。内存扫描配不齐冷分片时, + /// 在等待期间打开微信会话可捕获 per-DB AES key。 + #[arg(long)] + hook_seconds: Option, + }, + /// 列出最近会话 + Sessions { + /// 会话数量 + #[arg(short = 'n', long, default_value = "20")] + limit: usize, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 查看聊天记录 + History { + /// 聊天对象名称(支持模糊匹配) + chat: String, + /// 消息数量 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + /// 分页偏移(深 offset 慢;优先用 --after 游标) + #[arg(long, default_value = "0")] + offset: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 游标:只返回比该时间更旧的消息(Unix 秒或日期;通常传上一页最旧 timestamp) + #[arg(long)] + after: Option, + /// 游标:只返回比该时间更新的消息 + #[arg(long)] + before: Option, + #[arg(long = "type", value_name = "TYPE", help = MSG_TYPE_HELP, value_parser = clap_parse_msg_type)] + msg_type: Option, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 搜索消息 + Search { + /// 搜索关键词 + keyword: String, + /// 限定聊天(可多次指定) + #[arg(long = "in", value_name = "CHAT")] + chats: Vec, + /// 结果数量 + #[arg(short = 'n', long, default_value = "20")] + limit: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + #[arg(long = "type", value_name = "TYPE", help = MSG_TYPE_HELP, value_parser = clap_parse_msg_type)] + msg_type: Option, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 查看联系人 + Contacts { + /// 按名字过滤 + #[arg(short = 'q', long)] + query: Option, + /// 显示数量 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 导出聊天记录到文件 + Export { + /// 聊天对象名称 + chat: String, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 最多导出条数 + #[arg(short = 'n', long, default_value = "500")] + limit: usize, + /// 输出格式 [markdown|txt|json|yaml] + #[arg(short = 'f', long, default_value = "markdown", value_parser = ["markdown", "txt", "json", "yaml"])] + format: String, + /// 输出文件(默认 stdout) + #[arg(short = 'o', long)] + output: Option, + }, + /// 显示有未读消息的会话 + Unread { + /// 显示数量 + #[arg(short = 'n', long, default_value = "20")] + limit: usize, + /// 按会话类型过滤,逗号分隔。示例:--filter private,group 只看真人的未读 + #[arg(long, value_name = "TYPES", value_delimiter = ',', + value_parser = ["all", "private", "group", "official", "folded"])] + filter: Vec, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 查看群成员 + Members { + /// 群聊名称(支持模糊匹配) + chat: String, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 获取自上次检查以来的新消息 + NewMessages { + /// 显示数量上限 + #[arg(short = 'n', long, default_value = "200")] + limit: usize, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 聊天统计分析 + Stats { + /// 聊天对象名称(支持模糊匹配) + chat: String, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 查看微信收藏内容 + Favorites { + /// 显示数量 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + /// 类型过滤 [text|image|article|card|video] + #[arg(long = "type", value_name = "TYPE", + value_parser = ["text","image","article","card","video"])] + fav_type: Option, + /// 内容关键词搜索 + #[arg(short = 'q', long)] + query: Option, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 朋友圈互动通知:别人对我的朋友圈点赞/评论 + 我评过的帖子下的跟帖 + SnsNotifications { + /// 显示数量 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 包含已读通知(默认仅未读) + #[arg(long)] + include_read: bool, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 朋友圈时间线:按时间/作者筛选本地缓存的朋友圈 + SnsFeed { + /// 显示数量 + #[arg(short = 'n', long, default_value = "20")] + limit: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 只看指定作者(昵称 / 备注名 / 微信 ID,模糊匹配) + #[arg(long)] + user: Option, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 查询公众号文章推送(本地缓存) + BizArticles { + /// 显示数量 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + /// 限定公众号(名称模糊匹配) + #[arg(long)] + account: Option, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 只看有未读的公众号,每个公众号取最新 1 篇 + #[arg(long)] + unread: bool, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 朋友圈全文搜索:匹配正文关键词 + SnsSearch { + /// 关键词 + keyword: String, + /// 结果数量 + #[arg(short = 'n', long, default_value = "20")] + limit: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 限定作者(昵称 / 备注名 / 微信 ID) + #[arg(long)] + user: Option, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 列出某会话的图片附件,返回不透明 attachment_id + Attachments { + /// 会话名称(联系人显示名 / wxid / @chatroom username 都可以) + chat: String, + /// 类型(当前仅支持 image) + #[arg(long = "kind", value_name = "KIND", + value_parser = ["image", "img"])] + kinds: Vec, + /// 显示数量 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + /// 分页偏移 + #[arg(long, default_value = "0")] + offset: usize, + /// 起始时间 YYYY-MM-DD + #[arg(long)] + since: Option, + /// 结束时间 YYYY-MM-DD + #[arg(long)] + until: Option, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 把单个 attachment_id 对应的资源解密写到指定文件路径 + Extract { + /// 由 `wx attachments` 输出的不透明 ID(base64url 字符串) + attachment_id: String, + /// 输出文件路径(绝对或相对当前工作目录均可;扩展名建议保留为 .jpg 等) + #[arg(short = 'o', long)] + output: String, + /// 目标已存在时覆盖 + #[arg(long)] + overwrite: bool, + /// 输出 JSON(默认 YAML) + #[arg(long)] + json: bool, + }, + /// 管理 wx-daemon + Daemon { + #[command(subcommand)] + cmd: DaemonCommands, + }, + /// 环境 / 密钥 / 分片健康检查 + Doctor { + /// 输出 JSON + #[arg(long)] + json: bool, + /// 打印修复建议命令 + #[arg(long)] + fix: bool, + }, + /// 密钥管理 + Key { + #[command(subcommand)] + action: KeyAction, + }, + /// 跨会话时间线(按时间合并多 chat 消息) + Timeline { + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + #[arg(long, default_value = "0")] + offset: usize, + #[arg(long)] + since: Option, + #[arg(long)] + until: Option, + /// 游标:只返回比该时间更旧的消息 + #[arg(long)] + after: Option, + #[arg(long = "type", value_name = "TYPE", help = MSG_TYPE_HELP, value_parser = clap_parse_msg_type)] + msg_type: Option, + #[arg(long)] + json: bool, + }, + /// 实时监听新消息(轮询 session.db) + Watch { + /// 轮询间隔毫秒 + #[arg(long, default_value = "1500")] + interval: u64, + /// 每轮最多拉取条数 + #[arg(short = 'n', long, default_value = "50")] + limit: usize, + #[arg(long)] + json: bool, + }, + /// 媒体工具(语音等) + Media { + #[command(subcommand)] + action: MediaAction, + }, +} + +#[derive(Subcommand)] +enum KeyAction { + /// 扫描进程内存 / LLDB hook 提取密钥(建议 sudo) + Extract { + #[arg(long)] + hook_seconds: Option, + }, + /// 列出 all_keys.json 中的密钥 + List { + #[arg(long)] + json: bool, + /// 输出完整 enc_key(默认仅 preview,防误粘贴泄露) + #[arg(long)] + show_secrets: bool, + }, + /// 手动写入某个 DB 的密钥 + Set { + /// 相对路径,如 message/message_1.db + db: String, + /// 64 位 hex + enc_key: String, + }, +} + +#[derive(Subcommand)] +enum MediaAction { + /// 按 svr_id 从 message/media_0.db 导出语音 silk 原始数据 + Voice { + /// 消息 server id / svr_id + svr_id: i64, + /// 可选 chat username(加速定位) + #[arg(long)] + chat: Option, + /// 输出路径(.silk) + #[arg(short = 'o', long)] + output: String, + }, +} + +#[derive(Subcommand)] +pub enum DaemonCommands { + /// 查看 daemon 运行状态 + Status, + /// 停止 daemon + Stop, + /// 查看 daemon 日志 + Logs { + /// 持续输出(tail -f) + #[arg(short = 'f', long)] + follow: bool, + /// 显示最近 N 行 + #[arg(short = 'n', long, default_value = "50")] + lines: usize, + }, +} + +pub fn run() { + let cli = Cli::parse(); + if let Err(e) = dispatch(cli) { + eprintln!("错误: {}", e); + std::process::exit(1); + } +} + +fn dispatch(cli: Cli) -> Result<()> { + let base_with_meta = cli.with_meta; + let base_debug_source = cli.debug_source; + match cli.command { + Commands::Init { + force, + hook_seconds, + } => init::cmd_init(force, hook_seconds), + Commands::Sessions { limit, json } => sessions::cmd_sessions( + limit, + OutputOpts { + json, + with_meta: base_with_meta, + debug_source: base_debug_source, + }, + ), + Commands::History { + chat, + limit, + offset, + since, + until, + after, + before, + msg_type, + json, + } => history::cmd_history( + chat, + limit, + offset, + since, + until, + after, + before, + msg_type, + OutputOpts { + json, + with_meta: base_with_meta, + debug_source: base_debug_source, + }, + ), + Commands::Search { + keyword, + chats, + limit, + since, + until, + msg_type, + json, + } => search::cmd_search( + keyword, + chats, + limit, + since, + until, + msg_type, + OutputOpts { + json, + with_meta: base_with_meta, + debug_source: base_debug_source, + }, + ), + Commands::Contacts { query, limit, json } => contacts::cmd_contacts(query, limit, json), + Commands::Export { + chat, + since, + until, + limit, + format, + output, + } => { + let export_json = format == "json"; + export::cmd_export( + chat, + since, + until, + limit, + format, + output, + OutputOpts { + json: export_json, + with_meta: base_with_meta, + debug_source: base_debug_source, + }, + ) + } + Commands::Unread { + limit, + filter, + json, + } => unread::cmd_unread( + limit, + filter, + OutputOpts { + json, + with_meta: base_with_meta, + debug_source: base_debug_source, + }, + ), + Commands::Members { chat, json } => members::cmd_members(chat, json), + Commands::NewMessages { limit, json } => new_messages::cmd_new_messages( + limit, + OutputOpts { + json, + with_meta: base_with_meta, + debug_source: base_debug_source, + }, + ), + Commands::Stats { + chat, + since, + until, + json, + } => stats::cmd_stats( + chat, + since, + until, + OutputOpts { + json, + with_meta: base_with_meta, + debug_source: base_debug_source, + }, + ), + Commands::Favorites { + limit, + fav_type, + query, + json, + } => favorites::cmd_favorites(limit, fav_type, query, json), + Commands::SnsNotifications { + limit, + since, + until, + include_read, + json, + } => sns_notifications::cmd_sns_notifications(limit, since, until, include_read, json), + Commands::SnsFeed { + limit, + since, + until, + user, + json, + } => sns_feed::cmd_sns_feed(limit, since, until, user, json), + Commands::SnsSearch { + keyword, + limit, + since, + until, + user, + json, + } => sns_search::cmd_sns_search(keyword, limit, since, until, user, json), + Commands::BizArticles { + limit, + account, + since, + until, + unread, + json, + } => biz_articles::cmd_biz_articles(limit, account, since, until, unread, json), + Commands::Attachments { + chat, + kinds, + limit, + offset, + since, + until, + json, + } => attachments::cmd_attachments( + chat, + kinds, + limit, + offset, + since, + until, + OutputOpts { + json, + with_meta: base_with_meta, + debug_source: base_debug_source, + }, + ), + Commands::Extract { + attachment_id, + output, + overwrite, + json, + } => extract::cmd_extract(attachment_id, output, overwrite, json), + Commands::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd), + Commands::Doctor { json, fix } => doctor::cmd_doctor(json, fix), + Commands::Key { action } => match action { + KeyAction::Extract { hook_seconds } => key_cmd::cmd_key_extract(hook_seconds), + KeyAction::List { json, show_secrets } => key_cmd::cmd_key_list(json, show_secrets), + KeyAction::Set { db, enc_key } => key_cmd::cmd_key_set(&db, &enc_key), + }, + Commands::Timeline { + limit, + offset, + since, + until, + after, + msg_type, + json, + } => timeline::cmd_timeline( + limit, + offset, + since, + until, + after, + msg_type, + OutputOpts { + json, + with_meta: base_with_meta, + debug_source: base_debug_source, + }, + ), + Commands::Watch { + interval, + limit, + json, + } => watch::cmd_watch( + interval, + limit, + OutputOpts { + json, + with_meta: false, + debug_source: false, + }, + ), + Commands::Media { action } => match action { + MediaAction::Voice { + svr_id, + chat, + output, + } => media::cmd_voice_export(svr_id, chat, output), + }, + } +} + +#[cfg(test)] +mod clap_msg_type_wiring_tests { + use super::Cli; + use clap::Parser; + + /// Real CLI parse path (not just parse_msg_type helper): clap value_parser must accept + /// agent type_id round-trips and numeric codes. + #[test] + fn history_accepts_appmsg_card_revoke_and_digit_type() { + for ty in ["appmsg", "card", "revoke", "49", "link", "text"] { + let cli = Cli::try_parse_from(["wx", "history", "someone", "--type", ty]) + .unwrap_or_else(|e| panic!("--type {ty} must parse: {e}")); + match cli.command { + super::Commands::History { msg_type, .. } => { + assert_eq!(msg_type.as_deref(), Some(ty)); + } + _ => panic!("expected History for --type {ty}"), + } + } + } + + #[test] + fn search_and_timeline_accept_appmsg() { + let s = Cli::try_parse_from(["wx", "search", "kw", "--type", "appmsg"]) + .expect("search --type appmsg"); + match s.command { + super::Commands::Search { msg_type, .. } => { + assert_eq!(msg_type.as_deref(), Some("appmsg")); + } + _ => panic!("expected Search"), + } + let t = Cli::try_parse_from(["wx", "timeline", "--type", "57"]).expect("timeline --type 57"); + match t.command { + super::Commands::Timeline { msg_type, .. } => { + assert_eq!(msg_type.as_deref(), Some("57")); + } + _ => panic!("expected Timeline"), + } + } + + #[test] + fn clap_rejects_unknown_type_before_dispatch() { + let err = match Cli::try_parse_from(["wx", "history", "x", "--type", "nope"]) { + Ok(_) => panic!("unknown --type must fail at clap parse"), + Err(e) => e, + }; + let msg = err.to_string(); + assert!( + msg.contains("未知消息类型") || msg.contains("nope"), + "unexpected error: {msg}" + ); + } +} diff --git a/src/cli/new_messages.rs b/src/cli/new_messages.rs new file mode 100644 index 0000000..5d73e4d --- /dev/null +++ b/src/cli/new_messages.rs @@ -0,0 +1,67 @@ +use super::output::{emit_warnings, print_response, OutputOpts}; +use super::transport; +use crate::ipc::Request; +use anyhow::Result; +use std::collections::HashMap; + +fn state_file() -> std::path::PathBuf { + dirs::home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".wx-cli") + .join("last_check.json") +} + +/// 加载上次的 per-session 时间戳快照 +/// 格式:{ "sessions": { "username": timestamp, ... } } +/// 旧格式(只有 timestamp 字段)直接丢弃,重新全量获取 +fn load_state() -> Option> { + let data = std::fs::read_to_string(state_file()).ok()?; + let v: serde_json::Value = serde_json::from_str(&data).ok()?; + // 旧格式(只有 timestamp 字段)没有 sessions key → 返回 None 触发首次运行逻辑 + let map: HashMap = v + .get("sessions")? + .as_object()? + .iter() + .filter_map(|(k, v)| v.as_i64().map(|ts| (k.clone(), ts))) + .collect(); + // 空 map 也是合法状态(账号无任何会话),返回 Some(empty) 而非 None + // 这样不会误触发全量历史拉取 + Some(map) +} + +fn save_state(new_state: &HashMap) -> Result<()> { + let path = state_file(); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write( + &path, + serde_json::to_string(&serde_json::json!({ "sessions": new_state }))?, + )?; + Ok(()) +} + +pub fn cmd_new_messages(limit: usize, opts: OutputOpts) -> Result<()> { + let state = load_state(); + let (with_meta, debug_source) = opts.request_flags(); + let resp = transport::send(Request::NewMessages { + state, + limit, + with_meta, + debug_source, + })?; + + // 保存 daemon 返回的 new_state + if let Some(obj) = resp.data.get("new_state").and_then(|v| v.as_object()) { + let map: HashMap = obj + .iter() + .filter_map(|(k, v)| v.as_i64().map(|ts| (k.clone(), ts))) + .collect(); + if !map.is_empty() { + let _ = save_state(&map); + } + } + + emit_warnings(&resp.data); + print_response(&resp.data, &opts) +} diff --git a/src/cli/output.rs b/src/cli/output.rs new file mode 100644 index 0000000..50de002 --- /dev/null +++ b/src/cli/output.rs @@ -0,0 +1,130 @@ +use chrono::{Local, TimeZone}; + +/// 输出格式 +pub enum Fmt { + Yaml, + Json, +} + +#[derive(Clone, Copy, Debug)] +pub struct OutputOpts { + pub json: bool, + pub with_meta: bool, + pub debug_source: bool, +} + +impl OutputOpts { + pub fn request_flags(self) -> (bool, bool) { + (self.with_meta || self.debug_source, self.debug_source) + } +} + +/// 默认 YAML,--json 时输出 JSON +pub fn resolve(json: bool) -> Fmt { + if json { + Fmt::Json + } else { + Fmt::Yaml + } +} + +pub fn print_value(value: &serde_json::Value, fmt: &Fmt) -> anyhow::Result<()> { + match fmt { + Fmt::Json => println!("{}", serde_json::to_string_pretty(value)?), + Fmt::Yaml => print!("{}", serde_yaml::to_string(value)?), + } + Ok(()) +} + +pub fn print_response(data: &serde_json::Value, opts: &OutputOpts) -> anyhow::Result<()> { + print_value(data, &resolve(opts.json)) +} + +pub fn emit_warnings(data: &serde_json::Value) { + for line in warning_lines(data) { + eprintln!("[wx] 警告:{}", line); + } +} + +pub fn warning_lines(data: &serde_json::Value) -> Vec { + let mut lines = Vec::new(); + let meta = match data.get("meta") { + Some(v) if v.is_object() => v, + _ => return lines, + }; + + let unknown_shards: Vec = meta + .get("unknown_shards") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + if !unknown_shards.is_empty() { + lines.push(format!( + "磁盘上发现 daemon 不认识的分片 {},结果可能不完整;请在本机 Terminal 运行 {}", + unknown_shards.join(", "), + crate::config::RECOMMENDED_KEY_EXTRACT_HINT + )); + } + + let status = meta.get("status").and_then(|v| v.as_str()).unwrap_or(""); + if status == "possibly_stale" || status == "possibly_stale_unknown_shards" { + let session_ts = meta.get("session_last_timestamp").and_then(|v| v.as_i64()); + let chat_ts = meta.get("chat_latest_timestamp").and_then(|v| v.as_i64()); + if let (Some(session_ts), Some(chat_ts)) = (session_ts, chat_ts) { + let subject = data + .get("chat") + .and_then(|v| v.as_str()) + .or_else(|| data.get("username").and_then(|v| v.as_str())) + .unwrap_or("当前查询"); + lines.push(format!( + "session.db 显示 '{}' 最新到 {},但本次扫描只到 {},结果可能过期或不完整。", + subject, + fmt_meta_ts(session_ts), + fmt_meta_ts(chat_ts), + )); + } + } + + lines +} + +pub fn warning_block_text(data: &serde_json::Value) -> Option { + let lines = warning_lines(data); + if lines.is_empty() { + return None; + } + Some( + lines + .into_iter() + .map(|line| format!("[wx] 警告:{}", line)) + .collect::>() + .join("\n"), + ) +} + +pub fn warning_block_markdown(data: &serde_json::Value) -> Option { + let lines = warning_lines(data); + if lines.is_empty() { + return None; + } + let mut out = String::from("> [!WARNING]\n"); + for line in lines { + out.push_str("> "); + out.push_str(&line); + out.push('\n'); + } + Some(out) +} + +fn fmt_meta_ts(ts: i64) -> String { + Local + .timestamp_opt(ts, 0) + .single() + .map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string()) + .unwrap_or_else(|| ts.to_string()) +} diff --git a/src/cli/search.rs b/src/cli/search.rs new file mode 100644 index 0000000..2570225 --- /dev/null +++ b/src/cli/search.rs @@ -0,0 +1,39 @@ +use super::history::{parse_time, parse_time_end}; +use super::output::{emit_warnings, print_response, OutputOpts}; +use super::transport; +use crate::ipc::Request; +use anyhow::Result; + +pub fn cmd_search( + keyword: String, + chats: Vec, + limit: usize, + since: Option, + until: Option, + msg_type: Option, + opts: OutputOpts, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + let type_val = match msg_type.as_deref() { + Some(s) => Some(super::history::parse_msg_type_required(s)?), + None => None, + }; + let chats_opt = if chats.is_empty() { None } else { Some(chats) }; + let (with_meta, debug_source) = opts.request_flags(); + + let req = Request::Search { + keyword, + chats: chats_opt, + limit, + since: since_ts, + until: until_ts, + msg_type: type_val, + with_meta, + debug_source, + }; + + let resp = transport::send(req)?; + emit_warnings(&resp.data); + print_response(&resp.data, &opts) +} diff --git a/src/cli/sessions.rs b/src/cli/sessions.rs new file mode 100644 index 0000000..3b70e63 --- /dev/null +++ b/src/cli/sessions.rs @@ -0,0 +1,15 @@ +use super::output::{emit_warnings, print_response, OutputOpts}; +use super::transport; +use crate::ipc::Request; +use anyhow::Result; + +pub fn cmd_sessions(limit: usize, opts: OutputOpts) -> Result<()> { + let (with_meta, debug_source) = opts.request_flags(); + let resp = transport::send(Request::Sessions { + limit, + with_meta, + debug_source, + })?; + emit_warnings(&resp.data); + print_response(&resp.data, &opts) +} diff --git a/src/cli/sns_feed.rs b/src/cli/sns_feed.rs new file mode 100644 index 0000000..afb30a5 --- /dev/null +++ b/src/cli/sns_feed.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::history::{parse_time, parse_time_end}; +use super::transport; +use super::output::{resolve, print_value}; + +pub fn cmd_sns_feed( + limit: usize, + since: Option, + until: Option, + user: Option, + json: bool, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + + let req = Request::SnsFeed { + limit, + since: since_ts, + until: until_ts, + user, + }; + let resp = transport::send(req)?; + let data = resp.data.get("posts") + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + print_value(&data, &resolve(json)) +} diff --git a/src/cli/sns_notifications.rs b/src/cli/sns_notifications.rs new file mode 100644 index 0000000..42fa30f --- /dev/null +++ b/src/cli/sns_notifications.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::history::{parse_time, parse_time_end}; +use super::transport; +use super::output::{resolve, print_value}; + +pub fn cmd_sns_notifications( + limit: usize, + since: Option, + until: Option, + include_read: bool, + json: bool, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + + let req = Request::SnsNotifications { + limit, + since: since_ts, + until: until_ts, + include_read, + }; + let resp = transport::send(req)?; + let data = resp.data.get("notifications") + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + print_value(&data, &resolve(json)) +} diff --git a/src/cli/sns_search.rs b/src/cli/sns_search.rs new file mode 100644 index 0000000..1ed4bda --- /dev/null +++ b/src/cli/sns_search.rs @@ -0,0 +1,30 @@ +use anyhow::Result; +use crate::ipc::Request; +use super::history::{parse_time, parse_time_end}; +use super::transport; +use super::output::{resolve, print_value}; + +pub fn cmd_sns_search( + keyword: String, + limit: usize, + since: Option, + until: Option, + user: Option, + json: bool, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + + let req = Request::SnsSearch { + keyword, + limit, + since: since_ts, + until: until_ts, + user, + }; + let resp = transport::send(req)?; + let data = resp.data.get("posts") + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + print_value(&data, &resolve(json)) +} diff --git a/src/cli/stats.rs b/src/cli/stats.rs new file mode 100644 index 0000000..87dcf4c --- /dev/null +++ b/src/cli/stats.rs @@ -0,0 +1,25 @@ +use super::history::{parse_time, parse_time_end}; +use super::output::{emit_warnings, print_response, OutputOpts}; +use super::transport; +use crate::ipc::Request; +use anyhow::Result; + +pub fn cmd_stats( + chat: String, + since: Option, + until: Option, + opts: OutputOpts, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + let (with_meta, debug_source) = opts.request_flags(); + let resp = transport::send(Request::Stats { + chat, + since: since_ts, + until: until_ts, + with_meta, + debug_source, + })?; + emit_warnings(&resp.data); + print_response(&resp.data, &opts) +} diff --git a/src/cli/timeline.rs b/src/cli/timeline.rs new file mode 100644 index 0000000..27adc64 --- /dev/null +++ b/src/cli/timeline.rs @@ -0,0 +1,42 @@ +//! `wx timeline` — 跨会话按时间拉取消息 + +use super::history::{parse_time, parse_time_end}; +use super::output::{emit_warnings, print_response, OutputOpts}; +use super::transport; +use crate::ipc::Request; +use anyhow::Result; + +pub fn cmd_timeline( + limit: usize, + offset: usize, + since: Option, + until: Option, + after: Option, + msg_type: Option, + opts: OutputOpts, +) -> Result<()> { + let since_ts = since.as_deref().map(parse_time).transpose()?; + let until_ts = until.as_deref().map(parse_time_end).transpose()?; + let after_ts = after + .as_deref() + .map(super::history::parse_time_or_unix) + .transpose()?; + let type_val = match msg_type.as_deref() { + Some(s) => Some(super::history::parse_msg_type_required(s)?), + None => None, + }; + let (with_meta, debug_source) = opts.request_flags(); + + let resp = transport::send(Request::Timeline { + limit, + offset, + since: since_ts, + until: until_ts, + after_ts, + msg_type: type_val, + with_meta, + debug_source, + })?; + emit_warnings(&resp.data); + print_response(&resp.data, &opts) +} diff --git a/src/cli/transport.rs b/src/cli/transport.rs new file mode 100644 index 0000000..23c3e18 --- /dev/null +++ b/src/cli/transport.rs @@ -0,0 +1,492 @@ +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::config; +use crate::ipc::{Request, Response}; + +const STARTUP_TIMEOUT_SECS: u64 = 15; +#[cfg(unix)] +const STOP_TIMEOUT_MS: u64 = 2_000; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PidFile { + pid: u32, + #[serde(default)] + exe: Option, +} + +/// 检查 daemon 是否存活 +pub fn is_alive() -> bool { + #[cfg(unix)] + { + ping_unix().unwrap_or(false) + } + #[cfg(windows)] + { + ping_windows().unwrap_or(false) + } + #[cfg(not(any(unix, windows)))] + { + false + } +} + +/// 确保 daemon 运行,必要时自动启动 +pub fn ensure_daemon() -> Result<()> { + if is_alive() { + return Ok(()); + } + eprintln!("启动 wx-daemon..."); + start_daemon()?; + Ok(()) +} + +/// 停止 daemon(如果正在运行) +pub fn stop_daemon() -> Result<()> { + let pid_path = config::pid_path(); + let pid_file = read_pid_file(&pid_path)?; + let daemon_alive = is_alive(); + + match pid_file { + Some(pid_file) => { + let belongs = pid_belongs_to_daemon(&pid_file)?; + if daemon_alive && !belongs { + bail!( + "daemon 正在运行,但 {} 指向的 PID {} 无法确认属于当前 wx-daemon", + pid_path.display(), + pid_file.pid + ); + } + if belongs { + terminate_pid(pid_file.pid)?; + } + } + None if daemon_alive => { + bail!( + "daemon 正在运行,但 {} 缺失或损坏,无法安全停止", + pid_path.display() + ); + } + None => {} + } + + cleanup_ipc_files(); + Ok(()) +} + +/// 启动 daemon 前检查 `~/.wx-cli/` 可写,给出比"超时"更明确的错误。 +/// +/// 典型坑:旧版本 `sudo wx init` 把目录留成 root 属主,非 root 的 daemon +/// 连 socket/log 都建不了,会静默失败 15s 超时。 +fn preflight_cli_dir_writable() -> Result<()> { + let cli_dir = config::cli_dir(); + std::fs::create_dir_all(&cli_dir) + .with_context(|| format!("创建 {} 失败", cli_dir.display()))?; + + let probe = cli_dir.join(".daemon_probe"); + match std::fs::File::create(&probe) { + Ok(_) => { + let _ = std::fs::remove_file(&probe); + Ok(()) + } + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + let dir = cli_dir.display(); + if cfg!(unix) { + bail!( + "无法写入 {dir}(权限不足)\n\n\ + 这通常是老版本的 `sudo wx init` 把目录属主留成了 root。\n\ + 修复:\n\n \ + sudo chown -R $(whoami) {dir}\n\n\ + (新版已修复此问题,下次 init 不会再发生)", + ) + } else { + bail!("无法写入 {dir}: {e}") + } + } + Err(e) => bail!("无法写入 {}: {}", cli_dir.display(), e), + } +} + +/// 启动 daemon 进程(自身二进制,设置 WX_DAEMON_MODE=1) +fn start_daemon() -> Result<()> { + let exe = std::env::current_exe().context("无法获取当前可执行文件路径")?; + let child_pid: u32; + + // 预检:当前用户是否能写 ~/.wx-cli/。如果不能,给出可操作的错误信息, + // 而不是 spawn 一个注定失败的 daemon 然后超时 15s。 + preflight_cli_dir_writable()?; + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // 日志文件:~/.wx-cli/daemon.log + let log_path = config::log_path(); + // 确保父目录存在 + if let Some(parent) = log_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let (stdout_stdio, stderr_stdio) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .and_then(|f| f.try_clone().map(|g| (f, g))) + .map(|(f, g)| (std::process::Stdio::from(f), std::process::Stdio::from(g))) + .unwrap_or_else(|_| (std::process::Stdio::null(), std::process::Stdio::null())); + let mut cmd = std::process::Command::new(&exe); + cmd.env("WX_DAEMON_MODE", "1") + .stdin(std::process::Stdio::null()) + .stdout(stdout_stdio) + .stderr(stderr_stdio); + // SAFETY: setsid() 在 fork 后的子进程中调用,使 daemon 脱离控制终端 + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + let child = cmd.spawn().context("无法启动 daemon 进程")?; + child_pid = child.id(); + } + + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + let log_path = config::log_path(); + if let Some(parent) = log_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let (stdout_stdio, stderr_stdio) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .and_then(|f| f.try_clone().map(|g| (f, g))) + .map(|(f, g)| (std::process::Stdio::from(f), std::process::Stdio::from(g))) + .unwrap_or_else(|_| (std::process::Stdio::null(), std::process::Stdio::null())); + let child = std::process::Command::new(&exe) + .env("WX_DAEMON_MODE", "1") + .stdin(std::process::Stdio::null()) + .stdout(stdout_stdio) + .stderr(stderr_stdio) + .creation_flags(0x00000008) // DETACHED_PROCESS + .spawn() + .context("无法启动 daemon 进程")?; + child_pid = child.id(); + } + + // 等待 daemon 就绪(最多 STARTUP_TIMEOUT_SECS 秒) + let deadline = std::time::Instant::now() + Duration::from_secs(STARTUP_TIMEOUT_SECS); + while std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(300)); + if is_alive() { + write_pid_file(child_pid, &exe)?; + return Ok(()); + } + } + + bail!( + "wx-daemon 启动超时(>{}s)\n请查看日志: {}", + STARTUP_TIMEOUT_SECS, + config::log_path().display() + ) +} + +fn write_pid_file(pid: u32, exe: &Path) -> Result<()> { + if let Some(parent) = config::pid_path().parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("创建 {} 失败", parent.display()))?; + } + let pid_file = PidFile { + pid, + exe: Some(exe.to_path_buf()), + }; + let content = serde_json::to_string(&pid_file)?; + std::fs::write(config::pid_path(), content) + .with_context(|| format!("写入 {} 失败", config::pid_path().display()))?; + Ok(()) +} + +fn read_pid_file(path: &Path) -> Result> { + let content = match std::fs::read_to_string(path) { + Ok(content) => content, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err).with_context(|| format!("读取 {} 失败", path.display())), + }; + if let Ok(pid_file) = serde_json::from_str::(&content) { + return Ok(Some(pid_file)); + } + if let Ok(pid) = content.trim().parse::() { + return Ok(Some(PidFile { + pid, + exe: std::env::current_exe().ok(), + })); + } + bail!("{} 不是合法的 PID 文件", path.display()) +} + +fn cleanup_ipc_files() { + let _ = std::fs::remove_file(config::sock_path()); + let _ = std::fs::remove_file(config::pid_path()); +} + +#[cfg(unix)] +fn ping_unix() -> Result { + use std::os::unix::net::UnixStream; + let sock_path = config::sock_path(); + if !sock_path.exists() { + return Ok(false); + } + let mut stream = UnixStream::connect(&sock_path)?; + stream.set_read_timeout(Some(Duration::from_secs(2))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(2))).ok(); + + let req = serde_json::to_string(&Request::Ping)? + "\n"; + stream.write_all(req.as_bytes())?; + + let mut line = String::new(); + let mut reader = BufReader::new(&stream); + reader.read_line(&mut line)?; + + let resp: Response = serde_json::from_str(&line)?; + Ok(resp.ok && resp.data.get("pong").and_then(|p| p.as_bool()) == Some(true)) +} + +#[cfg(windows)] +fn ping_windows() -> Result { + use interprocess::local_socket::{prelude::*, GenericNamespaced, Stream}; + + let name = "wx-cli-daemon".to_ns_name::()?; + let stream = Stream::connect(name)?; + let mut reader = BufReader::new(stream); + + let req = serde_json::to_string(&Request::Ping)? + "\n"; + reader.get_mut().write_all(req.as_bytes())?; + + let mut line = String::new(); + reader.read_line(&mut line)?; + + let resp: Response = serde_json::from_str(&line)?; + Ok(resp.ok && resp.data.get("pong").and_then(|p| p.as_bool()) == Some(true)) +} + +fn pid_belongs_to_daemon(pid_file: &PidFile) -> Result { + let expected_exe = pid_file + .exe + .clone() + .or_else(|| std::env::current_exe().ok()); + #[cfg(unix)] + { + unix_pid_matches_daemon(pid_file.pid, expected_exe.as_deref()) + } + #[cfg(windows)] + { + windows_pid_matches_daemon(pid_file.pid, expected_exe.as_deref()) + } + #[cfg(not(any(unix, windows)))] + { + let _ = expected_exe; + Ok(true) + } +} + +#[cfg(unix)] +fn unix_pid_matches_daemon(pid: u32, expected_exe: Option<&Path>) -> Result { + let Some(expected_exe) = expected_exe else { + return Ok(false); + }; + let output = std::process::Command::new("ps") + .args(["-o", "command=", "-p", &pid.to_string()]) + .output() + .with_context(|| format!("读取 PID {} 的 command 失败", pid))?; + if !output.status.success() { + return Ok(false); + } + let command = String::from_utf8_lossy(&output.stdout); + let expected = expected_exe.to_string_lossy(); + if command.contains(expected.as_ref()) { + return Ok(true); + } + let Some(exe_name) = expected_exe.file_name().and_then(|name| name.to_str()) else { + return Ok(false); + }; + Ok(command + .split_whitespace() + .any(|part| part == exe_name || part.ends_with(&format!("/{}", exe_name)))) +} + +#[cfg(windows)] +fn windows_pid_matches_daemon(pid: u32, expected_exe: Option<&Path>) -> Result { + use windows::core::PWSTR; + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Threading::{ + OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_FORMAT, + PROCESS_QUERY_LIMITED_INFORMATION, + }; + + let Some(expected_exe) = expected_exe else { + return Ok(false); + }; + let handle = match unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } { + Ok(handle) => handle, + Err(_) => return Ok(false), + }; + + let mut buf = vec![0u16; 260]; + let mut len = buf.len() as u32; + let actual = unsafe { + let result = QueryFullProcessImageNameW( + handle, + PROCESS_NAME_FORMAT(0), + PWSTR(buf.as_mut_ptr()), + &mut len, + ); + let _ = CloseHandle(handle); + result + }; + if actual.is_err() { + return Ok(false); + } + + let actual_path = PathBuf::from(String::from_utf16_lossy(&buf[..len as usize])); + Ok(normalize_exe_path(&actual_path) == normalize_exe_path(expected_exe)) +} + +#[cfg(windows)] +fn normalize_exe_path(path: &Path) -> String { + path.to_string_lossy() + .replace('\\', "/") + .to_ascii_lowercase() +} + +fn terminate_pid(pid: u32) -> Result<()> { + #[cfg(unix)] + { + terminate_pid_unix(pid) + } + #[cfg(windows)] + { + terminate_pid_windows(pid) + } + #[cfg(not(any(unix, windows)))] + { + let _ = pid; + Ok(()) + } +} + +#[cfg(unix)] +fn terminate_pid_unix(pid: u32) -> Result<()> { + let rc = unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + if rc != 0 { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ESRCH) { + return Ok(()); + } + bail!("停止 PID {} 失败: {}", pid, err); + } + + let deadline = std::time::Instant::now() + Duration::from_millis(STOP_TIMEOUT_MS); + while std::time::Instant::now() < deadline { + if !unix_process_exists(pid) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(50)); + } + + bail!("等待 PID {} 退出超时", pid) +} + +#[cfg(unix)] +fn unix_process_exists(pid: u32) -> bool { + let rc = unsafe { libc::kill(pid as i32, 0) }; + if rc == 0 { + return true; + } + let err = std::io::Error::last_os_error(); + err.raw_os_error() == Some(libc::EPERM) +} + +#[cfg(windows)] +fn terminate_pid_windows(pid: u32) -> Result<()> { + let status = std::process::Command::new("taskkill") + .args(["/F", "/PID", &pid.to_string()]) + .status() + .with_context(|| format!("执行 taskkill /PID {} 失败", pid))?; + if !status.success() { + bail!("停止 PID {} 失败: taskkill exit {:?}", pid, status.code()); + } + Ok(()) +} + +/// 向 daemon 发送请求并返回响应 +pub fn send(req: Request) -> Result { + ensure_daemon()?; + + #[cfg(unix)] + { + send_unix(req) + } + #[cfg(windows)] + { + send_windows(req) + } + #[cfg(not(any(unix, windows)))] + { + bail!("不支持当前平台") + } +} + +#[cfg(unix)] +fn send_unix(req: Request) -> Result { + use std::os::unix::net::UnixStream; + let sock_path = config::sock_path(); + let mut stream = UnixStream::connect(&sock_path).context("连接 daemon socket 失败")?; + stream.set_read_timeout(Some(Duration::from_secs(120))).ok(); + stream + .set_write_timeout(Some(Duration::from_secs(120))) + .ok(); + + let req_str = serde_json::to_string(&req)? + "\n"; + stream.write_all(req_str.as_bytes())?; + + let mut line = String::new(); + let mut reader = BufReader::new(&stream); + reader.read_line(&mut line)?; + + let resp: Response = serde_json::from_str(&line).context("解析 daemon 响应失败")?; + + if !resp.ok { + bail!("{}", resp.error.as_deref().unwrap_or("未知错误")); + } + + Ok(resp) +} + +#[cfg(windows)] +fn send_windows(req: Request) -> Result { + use interprocess::local_socket::{prelude::*, GenericNamespaced, Stream}; + + let name = "wx-cli-daemon" + .to_ns_name::() + .context("构造 pipe name 失败")?; + let stream = Stream::connect(name).context("连接 daemon named pipe 失败")?; + + // interprocess::Stream 同时实现 Read + Write,但需要拆分读写端 + let mut reader = BufReader::new(stream); + + let req_str = serde_json::to_string(&req)? + "\n"; + reader.get_mut().write_all(req_str.as_bytes())?; + + let mut line = String::new(); + reader.read_line(&mut line)?; + + let resp: Response = serde_json::from_str(&line).context("解析 daemon 响应失败")?; + + if !resp.ok { + bail!("{}", resp.error.as_deref().unwrap_or("未知错误")); + } + + Ok(resp) +} diff --git a/src/cli/unread.rs b/src/cli/unread.rs new file mode 100644 index 0000000..fcc4235 --- /dev/null +++ b/src/cli/unread.rs @@ -0,0 +1,22 @@ +use super::output::{emit_warnings, print_response, OutputOpts}; +use super::transport; +use crate::ipc::Request; +use anyhow::Result; + +pub fn cmd_unread(limit: usize, filter: Vec, opts: OutputOpts) -> Result<()> { + // 空或含 "all" 视为不过滤;其他值已被 clap value_parser 验证过,直接透传给 daemon。 + let filter_vec = if filter.is_empty() || filter.iter().any(|s| s == "all") { + None + } else { + Some(filter) + }; + let (with_meta, debug_source) = opts.request_flags(); + let resp = transport::send(Request::Unread { + limit, + filter: filter_vec, + with_meta, + debug_source, + })?; + emit_warnings(&resp.data); + print_response(&resp.data, &opts) +} diff --git a/src/cli/watch.rs b/src/cli/watch.rs new file mode 100644 index 0000000..df5b74b --- /dev/null +++ b/src/cli/watch.rs @@ -0,0 +1,153 @@ +//! `wx watch` — 轮询 session 变更并打印新消息事件 + +use super::output::{print_value, OutputOpts}; +use super::transport; +use crate::ipc::Request; +use anyhow::Result; +use std::collections::HashMap; +use std::path::PathBuf; +use std::thread; +use std::time::Duration; + +fn state_file() -> PathBuf { + crate::config::cli_dir().join("watch_state.json") +} + +fn load_state() -> HashMap { + let Ok(text) = std::fs::read_to_string(state_file()) else { + return HashMap::new(); + }; + let Ok(v) = serde_json::from_str::(&text) else { + return HashMap::new(); + }; + v.get("sessions") + .and_then(|s| s.as_object()) + .map(|m| { + m.iter() + .filter_map(|(k, v)| v.as_i64().map(|t| (k.clone(), t))) + .collect() + }) + .unwrap_or_default() +} + +fn save_state(map: &HashMap) { + let path = state_file(); + if let Some(p) = path.parent() { + let _ = std::fs::create_dir_all(p); + } + let _ = std::fs::write( + path, + serde_json::to_string_pretty(&serde_json::json!({ "sessions": map })).unwrap_or_default(), + ); +} + +fn file_mtime_ns(p: &std::path::Path) -> u64 { + std::fs::metadata(p) + .and_then(|m| m.modified()) + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) + }) + .unwrap_or(0) +} + +/// 取 session.db / -wal / -shm 的最大 mtime。 +/// WeChat 常只 append WAL,主文件 mtime 可能长时间不变。 +fn session_source_mtime() -> u64 { + let Ok(cfg) = crate::config::load_config() else { + return 0; + }; + let base = cfg.db_dir.join("session/session.db"); + let wal = cfg.db_dir.join("session/session.db-wal"); + let shm = cfg.db_dir.join("session/session.db-shm"); + file_mtime_ns(&base) + .max(file_mtime_ns(&wal)) + .max(file_mtime_ns(&shm)) +} + +pub fn cmd_watch(interval_ms: u64, limit: usize, opts: OutputOpts) -> Result<()> { + let interval = Duration::from_millis(interval_ms.max(200)); + let mut state = load_state(); + let mut last_mtime = session_source_mtime(); + eprintln!( + "watching session.db(+wal) (poll {}ms). Ctrl+C 退出…", + interval.as_millis() + ); + + // 首次:若无 state,只同步快照不刷屏 + if state.is_empty() { + let resp = transport::send(Request::NewMessages { + state: None, + limit, + with_meta: false, + debug_source: false, + })?; + if let Some(obj) = resp.data.get("new_state").and_then(|v| v.as_object()) { + state = obj + .iter() + .filter_map(|(k, v)| v.as_i64().map(|t| (k.clone(), t))) + .collect(); + save_state(&state); + eprintln!("已建立 baseline({} 会话),等待新消息…", state.len()); + } + } + + loop { + thread::sleep(interval); + let mt = session_source_mtime(); + if mt != 0 && mt == last_mtime { + continue; + } + last_mtime = mt; + + let resp = transport::send(Request::NewMessages { + state: Some(state.clone()), + limit, + with_meta: false, + debug_source: false, + })?; + if !resp.ok { + eprintln!("watch error: {}", resp.error.unwrap_or_default()); + continue; + } + + if let Some(obj) = resp.data.get("new_state").and_then(|v| v.as_object()) { + state = obj + .iter() + .filter_map(|(k, v)| v.as_i64().map(|t| (k.clone(), t))) + .collect(); + save_state(&state); + } + + let messages = resp + .data + .get("messages") + .cloned() + .unwrap_or(serde_json::Value::Array(vec![])); + let n = messages.as_array().map(|a| a.len()).unwrap_or(0); + if n == 0 { + continue; + } + + if opts.json { + let _ = print_value(&messages, &super::output::resolve(true)); + } else { + // 逐条打印一行摘要 + if let Some(arr) = messages.as_array() { + for m in arr { + let time = m.get("time").and_then(|v| v.as_str()).unwrap_or(""); + let chat = m.get("chat").and_then(|v| v.as_str()).unwrap_or(""); + let sender = m.get("sender").and_then(|v| v.as_str()).unwrap_or(""); + let content = m.get("content").and_then(|v| v.as_str()).unwrap_or(""); + let one_line: String = content.chars().take(80).collect(); + if sender.is_empty() { + println!("{time} [{chat}] {one_line}"); + } else { + println!("{time} [{chat}] {sender}: {one_line}"); + } + } + } + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..3e73bca --- /dev/null +++ b/src/config.rs @@ -0,0 +1,523 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// 产品统一推荐:补齐/重提数据库密钥(含冷分片 hook)。 +/// 所有用户可见错误/警告应指向此命令,避免 `init --force` 与 `key extract` 分叉。 +pub const RECOMMENDED_KEY_EXTRACT: &str = "sudo wx key extract --hook-seconds 90"; + +/// 带冷分片操作说明的完整提示(一行或多行均可嵌入)。 +pub const RECOMMENDED_KEY_EXTRACT_HINT: &str = + "sudo wx key extract --hook-seconds 90(等待期间在微信中打开相关聊天以捕获冷分片密钥)"; + +#[cfg(test)] +mod recommended_cmd_tests { + use super::*; + + #[test] + fn recommended_key_extract_is_key_cmd_with_hook() { + assert!(RECOMMENDED_KEY_EXTRACT.starts_with("sudo wx key extract")); + assert!(RECOMMENDED_KEY_EXTRACT.contains("--hook-seconds")); + assert!(!RECOMMENDED_KEY_EXTRACT.contains("init --force")); + assert!(RECOMMENDED_KEY_EXTRACT_HINT.contains(RECOMMENDED_KEY_EXTRACT) + || RECOMMENDED_KEY_EXTRACT_HINT.starts_with("sudo wx key extract")); + assert!(!RECOMMENDED_KEY_EXTRACT_HINT.contains("init --force")); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub db_dir: PathBuf, + pub keys_file: PathBuf, + pub decrypted_dir: PathBuf, + #[serde(default)] + pub wechat_process: String, +} + +/// 从当前工作目录 / / $HOME/.wx-cli 加载配置 +pub fn load_config() -> Result { + let config_path = find_config_file()?; + let content = std::fs::read_to_string(&config_path) + .with_context(|| format!("读取 config.json 失败: {}", config_path.display()))?; + let raw: serde_json::Value = + serde_json::from_str(&content).with_context(|| "config.json 格式错误")?; + + let db_dir = raw + .get("db_dir") + .and_then(|v| v.as_str()) + .map(PathBuf::from) + .unwrap_or_else(default_db_dir); + + let base_dir = config_path.parent().unwrap_or(Path::new(".")); + + let keys_file = raw + .get("keys_file") + .and_then(|v| v.as_str()) + .map(|s| { + let p = PathBuf::from(s); + if p.is_absolute() { + p + } else { + base_dir.join(p) + } + }) + .unwrap_or_else(|| base_dir.join("all_keys.json")); + + let decrypted_dir = raw + .get("decrypted_dir") + .and_then(|v| v.as_str()) + .map(|s| { + let p = PathBuf::from(s); + if p.is_absolute() { + p + } else { + base_dir.join(p) + } + }) + .unwrap_or_else(|| base_dir.join("decrypted")); + + let wechat_process = raw + .get("wechat_process") + .and_then(|v| v.as_str()) + .unwrap_or(default_wechat_process()) + .to_string(); + + Ok(Config { + db_dir, + keys_file, + decrypted_dir, + wechat_process, + }) +} + +fn find_config_file() -> Result { + let cwd_dir = std::env::current_dir().ok(); + let exe_dir = std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(PathBuf::from)); + let cli_home = cli_home_dir(); + let home_dir = (cli_home != PathBuf::from("/tmp")).then_some(cli_home.as_path()); + + if let Some(path) = find_existing_config_path(cwd_dir.as_deref(), exe_dir.as_deref(), home_dir) + { + return Ok(path); + } + + Ok(default_config_path( + cwd_dir.as_deref(), + exe_dir.as_deref(), + home_dir, + )) +} + +fn find_existing_config_path( + cwd_dir: Option<&Path>, + exe_dir: Option<&Path>, + home_dir: Option<&Path>, +) -> Option { + let candidates = [ + cwd_dir.map(config_path_in_dir), + exe_dir.map(config_path_in_dir), + home_dir.map(home_config_path), + ]; + candidates.into_iter().flatten().find(|path| path.exists()) +} + +fn default_config_path( + cwd_dir: Option<&Path>, + exe_dir: Option<&Path>, + home_dir: Option<&Path>, +) -> PathBuf { + cwd_dir + .map(config_path_in_dir) + .or_else(|| exe_dir.map(config_path_in_dir)) + .or_else(|| home_dir.map(home_config_path)) + .unwrap_or_else(|| PathBuf::from("config.json")) +} + +fn config_path_in_dir(dir: &Path) -> PathBuf { + dir.join("config.json") +} + +fn home_config_path(home_dir: &Path) -> PathBuf { + home_dir.join(".wx-cli").join("config.json") +} + +pub fn cli_dir() -> PathBuf { + cli_home_dir().join(".wx-cli") +} + +fn cli_home_dir() -> PathBuf { + resolve_cli_home( + dirs::home_dir().unwrap_or_else(|| PathBuf::from("/tmp")), + sudo_user_home_dir(), + ) +} + +fn resolve_cli_home(default_home: PathBuf, sudo_home: Option) -> PathBuf { + sudo_home.unwrap_or(default_home) +} + +#[cfg(unix)] +fn sudo_user_home_dir() -> Option { + use std::ffi::{CStr, CString}; + + let sudo_user = std::env::var("SUDO_USER").ok()?; + let sudo_user = sudo_user.trim(); + if sudo_user.is_empty() { + return None; + } + + let c_user = CString::new(sudo_user).ok()?; + unsafe { + let pwd = libc::getpwnam(c_user.as_ptr()); + if pwd.is_null() || (*pwd).pw_dir.is_null() { + return None; + } + let dir = CStr::from_ptr((*pwd).pw_dir).to_str().ok()?; + Some(PathBuf::from(dir)) + } +} + +#[cfg(not(unix))] +fn sudo_user_home_dir() -> Option { + None +} + +pub fn sock_path() -> PathBuf { + cli_dir().join("daemon.sock") +} + +pub fn pid_path() -> PathBuf { + cli_dir().join("daemon.pid") +} + +pub fn log_path() -> PathBuf { + cli_dir().join("daemon.log") +} + +pub fn cache_dir() -> PathBuf { + cli_dir().join("cache") +} + +pub fn mtime_file() -> PathBuf { + cache_dir().join("_mtimes.json") +} + +fn default_db_dir() -> PathBuf { + #[cfg(target_os = "macos")] + { + dirs::home_dir() + .unwrap_or_default() + .join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files") + } + #[cfg(target_os = "linux")] + { + dirs::home_dir() + .unwrap_or_default() + .join("Documents/xwechat_files") + } + #[cfg(target_os = "windows")] + { + PathBuf::from(std::env::var("APPDATA").unwrap_or_default()).join("Tencent/xwechat") + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + PathBuf::from(".") + } +} + +fn default_wechat_process() -> &'static str { + #[cfg(target_os = "macos")] + { + "WeChat" + } + #[cfg(target_os = "linux")] + { + "wechat" + } + #[cfg(target_os = "windows")] + { + "Weixin.exe" + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + "WeChat" + } +} + +/// 自动检测微信 db_storage 目录 +pub fn auto_detect_db_dir() -> Option { + detect_db_dir_impl() +} + +#[cfg(target_os = "macos")] +fn detect_db_dir_impl() -> Option { + let home = sudo_user_home_dir().or_else(dirs::home_dir)?; + + let base = home.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files"); + if !base.exists() { + return None; + } + let mut candidates: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&base) { + for entry in entries.flatten() { + let storage = entry.path().join("db_storage"); + if storage.is_dir() { + candidates.push(storage); + } + } + } + // 用 .db 最新 mtime,而不是 db_storage 目录自身 mtime: + // 多账号时闲置账号目录若被 Finder/备份碰过 mtime,会误选。 + candidates.sort_by_key(|p| latest_db_mtime(p).unwrap_or(std::time::SystemTime::UNIX_EPOCH)); + candidates.into_iter().next_back() +} + +#[cfg(target_os = "linux")] +fn detect_db_dir_impl() -> Option { + let home = dirs::home_dir()?; + let sudo_home = sudo_user_home_dir(); + + let mut candidates: Vec = Vec::new(); + for base_home in [Some(home.clone()), sudo_home].into_iter().flatten() { + let xwechat = base_home.join("Documents/xwechat_files"); + if xwechat.exists() { + if let Ok(entries) = std::fs::read_dir(&xwechat) { + for entry in entries.flatten() { + let storage = entry.path().join("db_storage"); + if storage.is_dir() { + candidates.push(storage); + } + } + } + } + let old = base_home.join(".local/share/weixin/data/db_storage"); + if old.is_dir() { + candidates.push(old); + } + } + candidates.sort_by_key(|p| { + // 排序:取 db_storage 目录下所有 .db 文件的最新 mtime,而非目录自身的 mtime + // 这样当收到新消息时(只有 .db 文件被更新),能正确识别最新目录 + latest_db_mtime(p).unwrap_or(std::time::SystemTime::UNIX_EPOCH) + }); + candidates.into_iter().next_back() +} + +#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))] +/// 递归查找 db_storage 目录下所有 .db 文件的最新 mtime +fn latest_db_mtime(dir: &Path) -> Option { + let mut latest = None; + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + let mtime = if path.is_dir() { + latest_db_mtime(&path).unwrap_or(std::time::SystemTime::UNIX_EPOCH) + } else if path.extension().and_then(|s| s.to_str()) == Some("db") { + entry + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH) + } else { + continue; + }; + latest = Some(latest.map_or(mtime, |cur| if mtime > cur { mtime } else { cur })); + } + } + latest +} + +#[cfg(target_os = "windows")] +fn detect_db_dir_impl() -> Option { + let appdata = std::env::var("APPDATA").ok()?; + let config_dir = PathBuf::from(&appdata).join("Tencent/xwechat/config"); + if !config_dir.exists() { + return None; + } + let mut candidates: Vec = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&config_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|e| e == "ini").unwrap_or(false) { + if let Ok(content) = std::fs::read_to_string(&path) { + let Some(data_root) = resolve_windows_data_root(content.trim()) else { + continue; + }; + if data_root.is_dir() { + let pattern = data_root.join("xwechat_files"); + if let Ok(entries2) = std::fs::read_dir(&pattern) { + for entry2 in entries2.flatten() { + let storage = entry2.path().join("db_storage"); + if storage.is_dir() { + candidates.push(storage); + } + } + } + } + } + } + } + } + candidates.sort_by_key(|p| latest_db_mtime(p).unwrap_or(std::time::SystemTime::UNIX_EPOCH)); + candidates.into_iter().next_back() +} + +/// Resolve the data-root path that Weixin writes to its `*.ini` file under +/// `%APPDATA%\Tencent\xwechat\config\`. +/// +/// Observed forms in the wild: +/// - A plain absolute path, e.g. `D:\WeChatFiles`. +/// - The literal token `MyDocument:` (sometimes with a trailing slash), +/// which is not a real filesystem path. Empirically this denotes +/// "the current user's Documents folder"; users who relocated +/// Documents to e.g. `D:\Documents` saw auto-detect fail silently +/// because `PathBuf::from("MyDocument:").is_dir()` is false. +/// +/// We accept either form. For the `MyDocument:` token we resolve via +/// `SHGetKnownFolderPath(FOLDERID_Documents)`, which respects the standard +/// shell-folder redirect at +/// `HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders\Personal`. +#[cfg(target_os = "windows")] +fn resolve_windows_data_root(content: &str) -> Option { + let trimmed = content.trim(); + // Strip an optional trailing slash so `MyDocument:\` and `MyDocument:/` also match. + let stripped = trimmed + .strip_suffix(['\\', '/']) + .unwrap_or(trimmed); + if stripped.eq_ignore_ascii_case("MyDocument:") { + return known_documents_dir(); + } + Some(PathBuf::from(trimmed)) +} + +#[cfg(target_os = "windows")] +fn known_documents_dir() -> Option { + use std::ffi::OsString; + use std::os::windows::ffi::OsStringExt; + use windows::Win32::Foundation::HANDLE; + use windows::Win32::System::Com::CoTaskMemFree; + use windows::Win32::UI::Shell::{ + FOLDERID_Documents, SHGetKnownFolderPath, KF_FLAG_DEFAULT, + }; + + // SAFETY: standard Win32 known-folder API. SHGetKnownFolderPath either returns + // a heap-allocated PWSTR that the caller must free with CoTaskMemFree, or an + // error — in which case the out-pointer is not allocated. We free on every + // success path. Passing a null token (HANDLE::default()) means "the calling + // user", which is exactly what we want. + unsafe { + let pwstr = + SHGetKnownFolderPath(&FOLDERID_Documents, KF_FLAG_DEFAULT, HANDLE::default()).ok()?; + if pwstr.0.is_null() { + return None; + } + // Walk the NUL-terminated wide string to compute its length. + let mut len = 0usize; + while *pwstr.0.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(pwstr.0, len); + let os_str = OsString::from_wide(slice); + CoTaskMemFree(Some(pwstr.0 as *const _)); + let path = PathBuf::from(os_str); + if path.as_os_str().is_empty() { + None + } else { + Some(path) + } + } +} + +#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] +fn detect_db_dir_impl() -> Option { + None +} + +#[cfg(test)] +mod tests { + use super::{ + config_path_in_dir, default_config_path, find_existing_config_path, home_config_path, + resolve_cli_home, + }; + #[cfg(target_os = "windows")] + use super::{known_documents_dir, resolve_windows_data_root}; + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir(name: &str) -> PathBuf { + let unique = format!( + "wx-cli-config-test-{}-{}-{}", + name, + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let dir = std::env::temp_dir().join(unique); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn resolve_cli_home_prefers_sudo_home_when_present() { + let home = resolve_cli_home(PathBuf::from("/root"), Some(PathBuf::from("/Users/alice"))); + assert_eq!(home, PathBuf::from("/Users/alice")); + } + + #[test] + fn resolve_cli_home_falls_back_to_default_home() { + let home = resolve_cli_home(PathBuf::from("/root"), None); + assert_eq!(home, PathBuf::from("/root")); + } + + #[test] + fn config_path_prefers_cwd_over_exe_and_home() { + let cwd = temp_dir("cwd"); + let exe = temp_dir("exe"); + let home = temp_dir("home"); + fs::write(config_path_in_dir(&cwd), "{}").unwrap(); + fs::write(config_path_in_dir(&exe), "{}").unwrap(); + fs::create_dir_all(home.join(".wx-cli")).unwrap(); + fs::write(home_config_path(&home), "{}").unwrap(); + + let path = find_existing_config_path(Some(&cwd), Some(&exe), Some(&home)).unwrap(); + assert_eq!(path, config_path_in_dir(&cwd)); + + fs::remove_dir_all(cwd).unwrap(); + fs::remove_dir_all(exe).unwrap(); + fs::remove_dir_all(home).unwrap(); + } + + #[test] + fn default_config_path_matches_init_write_order() { + let cwd = PathBuf::from("/tmp/cwd"); + let exe = PathBuf::from("/tmp/exe"); + let home = PathBuf::from("/tmp/home"); + + let path = default_config_path(Some(&cwd), Some(&exe), Some(&home)); + assert_eq!(path, cwd.join("config.json")); + } + + #[cfg(target_os = "windows")] + #[test] + fn resolve_windows_data_root_passes_through_absolute_path() { + let p = resolve_windows_data_root("D:\\WeChatFiles").unwrap(); + assert_eq!(p, PathBuf::from("D:\\WeChatFiles")); + } + + #[cfg(target_os = "windows")] + #[test] + fn resolve_windows_data_root_recognises_mydocument_keyword() { + // Should match the keyword exactly (case-insensitive, with or without trailing slash) + // and resolve to a non-empty Documents path via SHGetKnownFolderPath. + let docs = known_documents_dir().expect("Documents known folder must resolve"); + for keyword in ["MyDocument:", "mydocument:", "MyDocument:\\", "MyDocument:/"] { + let resolved = resolve_windows_data_root(keyword) + .unwrap_or_else(|| panic!("keyword {keyword:?} should resolve")); + assert_eq!(resolved, docs, "keyword {keyword:?}"); + } + } +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs new file mode 100644 index 0000000..486ced2 --- /dev/null +++ b/src/crypto/mod.rs @@ -0,0 +1,252 @@ +pub mod sqlcipher; +pub mod wal; + +use aes::Aes256; +use anyhow::{bail, Result}; +use cbc::cipher::{BlockDecryptMut, KeyIvInit}; +use cbc::Decryptor; +use hmac::{Hmac, Mac}; +use pbkdf2::pbkdf2_hmac; +use sha2::Sha512; +use std::io::{Read, Write}; +use std::path::Path; + +type Block = aes::cipher::Block; +type HmacSha512 = Hmac; + +pub const PAGE_SZ: usize = 4096; +pub const SALT_SZ: usize = 16; +pub const RESERVE_SZ: usize = 80; // IV(16) + HMAC(64) +pub const IV_SZ: usize = 16; +pub const HMAC_SZ: usize = 64; + +/// SQLite 文件头魔数(16字节) +pub const SQLITE_HDR: &[u8] = b"SQLite format 3\x00"; + +type Aes256CbcDec = Decryptor; + +/// 解密单个 SQLCipher 4 页 +/// +/// - `enc_key`: 32字节 AES 密钥 +/// - `page_data`: 原始加密页面数据(PAGE_SZ 字节) +/// - `pgno`: 页码(从1开始) +/// +/// 返回解密后的完整页面(PAGE_SZ 字节) +pub fn decrypt_page(enc_key: &[u8; 32], page_data: &[u8], pgno: u32) -> Result> { + if page_data.len() < PAGE_SZ { + bail!("页面数据不足 {} 字节", PAGE_SZ); + } + + // IV 位于页面末尾 RESERVE_SZ 区域的前16字节 + let iv_offset = PAGE_SZ - RESERVE_SZ; + let iv: &[u8; 16] = page_data[iv_offset..iv_offset + 16] + .try_into() + .expect("IV 长度固定为 16"); + + let mut result = vec![0u8; PAGE_SZ]; + + if pgno == 1 { + // 第一页:跳过 salt(16字节),解密 [SALT_SZ..PAGE_SZ-RESERVE_SZ] + let enc = &page_data[SALT_SZ..PAGE_SZ - RESERVE_SZ]; + let dec = aes_cbc_decrypt(enc_key, iv, enc)?; + // 写入 SQLite 文件头 + result[..16].copy_from_slice(SQLITE_HDR); + // 写入解密数据(从第16字节开始) + result[16..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec); + // 末尾 RESERVE_SZ 字节补零 + // (已经是零,无需显式操作) + } else { + // 其他页:解密 [0..PAGE_SZ-RESERVE_SZ] + let enc = &page_data[..PAGE_SZ - RESERVE_SZ]; + let dec = aes_cbc_decrypt(enc_key, iv, enc)?; + result[..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec); + // 末尾 RESERVE_SZ 字节补零 + } + + Ok(result) +} + +/// 用数据库第一页验证 32-byte raw key(优先 SQLCipher 4 HMAC-SHA512)。 +pub fn validate_raw_key_for_db(db_path: &Path, enc_key: &[u8; 32]) -> bool { + let mut page = [0u8; PAGE_SZ]; + let Ok(mut file) = std::fs::File::open(db_path) else { + return false; + }; + if file.read_exact(&mut page).is_err() { + return false; + } + if verify_hmac_page1(&page, enc_key) { + return true; + } + decrypt_page(enc_key, &page, 1) + .map(|plain| has_valid_sqlite_page1_header(&plain)) + .unwrap_or(false) +} + +/// SQLCipher 4 第 1 页 HMAC-SHA512 校验。 +pub fn verify_hmac_page1(page: &[u8], enc_key: &[u8; 32]) -> bool { + if page.len() < PAGE_SZ { + return false; + } + let salt = &page[..SALT_SZ]; + let mut mac_salt = [0u8; SALT_SZ]; + for (i, b) in salt.iter().enumerate() { + mac_salt[i] = b ^ 0x3a; + } + let mut mac_key = [0u8; 32]; + pbkdf2_hmac::(enc_key, &mac_salt, 2, &mut mac_key); + + let content_end = PAGE_SZ - RESERVE_SZ; + let content = &page[SALT_SZ..content_end]; + let iv = &page[content_end..content_end + IV_SZ]; + let stored = &page[content_end + IV_SZ..content_end + IV_SZ + HMAC_SZ]; + + let Ok(mut mac) = HmacSha512::new_from_slice(&mac_key) else { + return false; + }; + mac.update(content); + mac.update(iv); + mac.update(&1u32.to_le_bytes()); + mac.verify_slice(stored).is_ok() +} + +fn has_valid_sqlite_page1_header(page: &[u8]) -> bool { + if page.len() < 24 || &page[..16] != SQLITE_HDR { + return false; + } + let page_size = u16::from_be_bytes([page[16], page[17]]); + page_size as usize == PAGE_SZ + && matches!(page[18], 1 | 2) + && matches!(page[19], 1 | 2) + && page[20] as usize == RESERVE_SZ + && page[21..24] == [64, 32, 32] +} + +/// AES-256-CBC 解密(不去除 padding,SQLCipher 不使用 PKCS#7 padding) +fn aes_cbc_decrypt(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Result> { + if data.is_empty() || data.len() % 16 != 0 { + bail!("密文长度不是 AES 块大小的倍数: {}", data.len()); + } + // 将 &[u8] 复制为 Block 数组,避免 unsafe from_raw_parts_mut + let mut blocks: Vec = data.chunks_exact(16).map(Block::clone_from_slice).collect(); + Aes256CbcDec::new(key.into(), iv.into()).decrypt_blocks_mut(&mut blocks); + Ok(blocks.iter().flat_map(|b| b.iter().copied()).collect()) +} + +/// 完整解密一个 SQLCipher 数据库文件(流式,逐页读写避免全量载入内存) +/// +/// 读取 `db_path`,按 PAGE_SZ 分页解密,写入 `out_path` +pub fn full_decrypt(db_path: &Path, out_path: &Path, enc_key: &[u8; 32]) -> Result<()> { + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let mut input = std::fs::File::open(db_path)?; + let file_size = input.metadata()?.len() as usize; + if file_size == 0 { + bail!("数据库文件为空: {}", db_path.display()); + } + + let mut output = std::fs::File::create(out_path)?; + let total_pages = (file_size + PAGE_SZ - 1) / PAGE_SZ; + let mut page_buf = vec![0u8; PAGE_SZ]; + + for pgno in 1..=total_pages { + let page_start = (pgno - 1) * PAGE_SZ; + let bytes_remaining = file_size.saturating_sub(page_start); + read_page(&mut input, &mut page_buf, bytes_remaining)?; + let dec = decrypt_page(enc_key, &page_buf, pgno as u32)?; + output.write_all(&dec)?; + } + + Ok(()) +} + +fn read_page( + input: &mut impl Read, + page_buf: &mut [u8], + bytes_remaining: usize, +) -> std::io::Result { + let expected = bytes_remaining.min(PAGE_SZ); + input.read_exact(&mut page_buf[..expected])?; + if expected < PAGE_SZ { + page_buf[expected..].fill(0); + } + Ok(expected) +} + +#[cfg(test)] +mod tests { + use super::{read_page, PAGE_SZ}; + use std::io::{self, Read}; + + struct ChunkedReader { + chunks: Vec>, + chunk_idx: usize, + offset: usize, + } + + impl ChunkedReader { + fn new(chunks: Vec>) -> Self { + Self { + chunks, + chunk_idx: 0, + offset: 0, + } + } + } + + impl Read for ChunkedReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if self.chunk_idx >= self.chunks.len() { + return Ok(0); + } + let chunk = &self.chunks[self.chunk_idx]; + let remaining = &chunk[self.offset..]; + let n = remaining.len().min(buf.len()); + buf[..n].copy_from_slice(&remaining[..n]); + self.offset += n; + if self.offset == chunk.len() { + self.chunk_idx += 1; + self.offset = 0; + } + Ok(n) + } + } + + #[test] + fn read_page_reads_across_short_chunks() { + let mut reader = ChunkedReader::new(vec![vec![1; 32], vec![2; PAGE_SZ - 32]]); + let mut page_buf = vec![0u8; PAGE_SZ]; + + let n = read_page(&mut reader, &mut page_buf, PAGE_SZ).unwrap(); + + assert_eq!(n, PAGE_SZ); + assert_eq!(page_buf[0], 1); + assert_eq!(page_buf[31], 1); + assert_eq!(page_buf[32], 2); + assert_eq!(page_buf[PAGE_SZ - 1], 2); + } + + #[test] + fn read_page_zero_pads_last_partial_page() { + let mut reader = ChunkedReader::new(vec![vec![7; 8], vec![9; 4]]); + let mut page_buf = vec![0u8; PAGE_SZ]; + + let n = read_page(&mut reader, &mut page_buf, 12).unwrap(); + + assert_eq!(n, 12); + assert_eq!(&page_buf[..8], &[7; 8]); + assert_eq!(&page_buf[8..12], &[9; 4]); + assert!(page_buf[12..].iter().all(|&b| b == 0)); + } + + #[test] + fn read_page_errors_on_early_eof() { + let mut reader = ChunkedReader::new(vec![vec![1; 8]]); + let mut page_buf = vec![0u8; PAGE_SZ]; + + let err = read_page(&mut reader, &mut page_buf, 16).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof); + } +} diff --git a/src/crypto/sqlcipher.rs b/src/crypto/sqlcipher.rs new file mode 100644 index 0000000..416a5db --- /dev/null +++ b/src/crypto/sqlcipher.rs @@ -0,0 +1,85 @@ +//! 用 SQLCipher 在线打开微信加密数据库(无需 full_decrypt)。 +//! +//! 密钥来自 all_keys.json 的 32-byte raw page key(与我们的 AES 页解密一致)。 +//! keyspec 使用 SQLCipher raw-key 语法:`x'<64hex>'`(跳过 PBKDF2)。 + +use anyhow::{bail, Context, Result}; +use rusqlite::{Connection, OpenFlags}; +use std::path::Path; + +/// 用 32-byte hex 密钥只读打开加密 DB。 +/// +/// 成功后会探测 `sqlite_master` 校验密钥正确。 +pub fn open_encrypted_readonly(path: &Path, key_hex: &str) -> Result { + if key_hex.len() != 64 || !key_hex.chars().all(|c| c.is_ascii_hexdigit()) { + bail!("SQLCipher key 必须是 64 位 hex,实际 len={}", key_hex.len()); + } + if !path.exists() { + bail!("数据库不存在: {}", path.display()); + } + + let conn = Connection::open_with_flags( + path, + OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .with_context(|| format!("打开加密 DB 失败: {}", path.display()))?; + + // Prefer raw key only (matches our page-AES keys). Fall back to key||salt + // for SQLCipher builds that expect the salt-suffixed form. + let key_only = format!("x'{}'", key_hex.to_lowercase()); + if try_apply_key(&conn, &key_only).is_ok() { + let _ = conn.execute_batch("PRAGMA query_only = ON"); + return Ok(conn); + } + + let salt_hex = read_salt_hex(path).unwrap_or_default(); + if salt_hex.len() == 32 { + let key_salt = format!("x'{}{}'", key_hex.to_lowercase(), salt_hex); + try_apply_key(&conn, &key_salt) + .with_context(|| format!("SQLCipher 密钥不匹配: {}", path.display()))?; + } else { + bail!("SQLCipher 密钥不匹配: {}", path.display()); + } + + let _ = conn.execute_batch("PRAGMA query_only = ON"); + Ok(conn) +} + +fn try_apply_key(conn: &Connection, keyspec: &str) -> Result<()> { + let bytes = keyspec.as_bytes(); + // SAFETY: sqlite3_key is provided by SQLCipher; keyspec is a valid UTF-8 buffer + // owned by us for the duration of the call. + let rc = unsafe { + rusqlite::ffi::sqlite3_key( + conn.handle(), + bytes.as_ptr() as *const std::ffi::c_void, + bytes.len() as i32, + ) + }; + if rc != 0 { + bail!("sqlite3_key rc={}", rc); + } + // Probe: wrong key → SQLITE_NOTADB / error on first read + conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| r.get::<_, i64>(0)) + .context("incorrect key or not SQLCipher DB")?; + Ok(()) +} + +fn read_salt_hex(path: &Path) -> Option { + let mut buf = [0u8; 16]; + let mut f = std::fs::File::open(path).ok()?; + use std::io::Read; + f.read_exact(&mut buf).ok()?; + Some(buf.iter().map(|b| format!("{:02x}", b)).collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_bad_key_len() { + let err = open_encrypted_readonly(Path::new("/nonexistent"), "abcd").unwrap_err(); + assert!(err.to_string().contains("64")); + } +} diff --git a/src/crypto/wal.rs b/src/crypto/wal.rs new file mode 100644 index 0000000..4e3d85d --- /dev/null +++ b/src/crypto/wal.rs @@ -0,0 +1,73 @@ +use anyhow::Result; +use std::io::{SeekFrom, Seek, Write}; +use std::path::Path; + +use super::{decrypt_page, PAGE_SZ}; + +pub const WAL_HDR_SZ: usize = 32; +pub const WAL_FRAME_HDR: usize = 24; + +/// 将 WAL 文件中的变更应用到已解密的数据库文件 +/// +/// WAL 格式(SQLite 标准,SQLCipher 4 的 WAL 帧也被加密): +/// - WAL header (32 bytes): magic(4) + format(4) + page_sz(4) + ckpt_seq(4) + salt1(4) + salt2(4) + cksum1(4) + cksum2(4) +/// - 每帧:frame_header(24 bytes) + page_data(PAGE_SZ bytes) +/// - frame_header: pgno(4) + commit_pgcnt(4) + salt1(4) + salt2(4) + cksum1(4) + cksum2(4) +pub fn apply_wal(wal_path: &Path, out_path: &Path, enc_key: &[u8; 32]) -> Result<()> { + if !wal_path.exists() { + return Ok(()); + } + + let wal_data = std::fs::read(wal_path)?; + if wal_data.len() <= WAL_HDR_SZ { + return Ok(()); + } + + // 读取 WAL 头中的 salt1 / salt2 + let s1 = u32::from_be_bytes(wal_data[16..20].try_into().unwrap()); + let s2 = u32::from_be_bytes(wal_data[20..24].try_into().unwrap()); + + let frame_size = WAL_FRAME_HDR + PAGE_SZ; + let frame_area = &wal_data[WAL_HDR_SZ..]; + + // 打开输出文件做随机写 + let mut db_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(out_path)?; + + let mut pos = 0usize; + while pos + frame_size <= frame_area.len() { + let fh = &frame_area[pos..pos + WAL_FRAME_HDR]; + let page_data = &frame_area[pos + WAL_FRAME_HDR..pos + frame_size]; + + let pgno = u32::from_be_bytes(fh[0..4].try_into().unwrap()); + let fs1 = u32::from_be_bytes(fh[8..12].try_into().unwrap()); + let fs2 = u32::from_be_bytes(fh[12..16].try_into().unwrap()); + + pos += frame_size; + + // 跳过无效页码 + if pgno == 0 || pgno > 1_000_000 { + continue; + } + // salt 不匹配的帧属于已检查点或旧事务 + if fs1 != s1 || fs2 != s2 { + continue; + } + + let mut page_buf = page_data.to_vec(); + if page_buf.len() < PAGE_SZ { + page_buf.resize(PAGE_SZ, 0); + } + + // WAL 帧中的页数据不含 SALT 头,所以对 pgno=1 的帧也用普通页解密路径 + // (区别于主数据库第一页需要跳过 SALT 并写入 SQLite 魔数) + let dec = decrypt_page(enc_key, &page_buf, if pgno == 1 { 2 } else { pgno })?; + let file_offset = (pgno as u64 - 1) * PAGE_SZ as u64; + db_file.seek(SeekFrom::Start(file_offset))?; + db_file.write_all(&dec)?; + } + + Ok(()) +} diff --git a/src/daemon/cache.rs b/src/daemon/cache.rs new file mode 100644 index 0000000..5d1834a --- /dev/null +++ b/src/daemon/cache.rs @@ -0,0 +1,1140 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::sync::Mutex; + +use crate::config; +use crate::crypto; +use crate::crypto::sqlcipher; +use crate::crypto::wal; +use rusqlite::Connection; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MtimeEntry { + db_mt: u64, + wal_mt: u64, + path: String, +} + +#[derive(Debug, Clone)] +struct CacheEntry { + db_mtime: u64, + wal_mtime: u64, + decrypted_path: PathBuf, +} + +/// `DbCache::get_with_mode()` / `open_query_conn` 本次解析 rel_key 时实际走了哪条路径。 +/// +/// latency tier: +/// - `Online`:SQLCipher 在线打开加密源(首选,无全量解密) +/// - `CacheHit`:~0ms,只返回已有解密产物 +/// - `WalIncremental`:典型 <10s,只在 cached DB 上增量 apply WAL +/// - `FullDecrypt`:最慢路径,大库上可能到 ~120s +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CacheMode { + /// SQLCipher 直接读加密源文件(`sqlite3_key`)。 + Online, + /// Path 1:主 `.db` 和 WAL 都没变,直接命中缓存。 + CacheHit, + /// Path 2:主 `.db` 没变、只有 WAL 变了,在 cached DB 上增量 apply。 + WalIncremental, + /// Path 3:主 `.db` 变了或缓存 miss,重新 full decrypt。 + FullDecrypt, +} + +impl CacheMode { + /// 手工固定为 snake_case 字符串,避免未来给 enum 直接 derive `Serialize` + /// 时静默改变 wire 形态。 + pub fn as_str(self) -> &'static str { + match self { + CacheMode::Online => "online", + CacheMode::CacheHit => "cache_hit", + CacheMode::WalIncremental => "wal_incremental", + CacheMode::FullDecrypt => "full_decrypt", + } + } +} + +#[derive(Debug, Clone)] +pub struct CacheResolve { + pub path: PathBuf, + pub mode: CacheMode, +} + +/// 解密后数据库的 mtime-aware 缓存 +/// +/// 当数据库文件(.db)或 WAL 文件(.db-wal)的 mtime 发生变化时, +/// 自动重新解密并更新缓存。跨进程重启可通过持久化 mtime 文件复用已解密的 DB。 +/// +/// **并发**: +/// - Path 2 / Path 3 对同一 `rel_key` 串行(`rel_key_locks`) +/// - 写缓存一律 **temp + atomic rename**,不原地改已打开的解密文件,避免读者撕页 +/// - `key_epoch`:`replace_keys` 递增;Path2/3 安装前若 epoch 变了则丢弃产物(防旧 key 写回) +/// - Online open(`open_query_conn` 首选)不持 per-key 锁 +pub struct DbCache { + db_dir: PathBuf, + cache_dir: PathBuf, + mtime_file: PathBuf, + /// rel_key -> enc_key(hex)。`RwLock` 以便 `ReloadConfig` 热更新密钥。 + all_keys: std::sync::RwLock>, + /// 密钥世代:`replace_keys` 后 in-flight 解密结果不得再安装。 + key_epoch: AtomicU64, + inner: Arc>>, + /// per-`rel_key` 写锁:Path2/Path3 持有;不同 DB 仍可并行。 + rel_key_locks: Mutex>>>, + /// 序列化 mtime 持久化,避免并发 save 互相覆盖丢条目。 + save_lock: Mutex<()>, +} + +impl DbCache { + pub async fn new(db_dir: PathBuf, all_keys: HashMap) -> Result { + Self::with_dirs(db_dir, config::cache_dir(), config::mtime_file(), all_keys).await + } + + /// 注入 `cache_dir` / `mtime_file`(测试用 + 生产 `new()` 复用) + pub(crate) async fn with_dirs( + db_dir: PathBuf, + cache_dir: PathBuf, + mtime_file: PathBuf, + all_keys: HashMap, + ) -> Result { + tokio::fs::create_dir_all(&cache_dir).await?; + + let cache = DbCache { + db_dir, + cache_dir, + mtime_file, + all_keys: std::sync::RwLock::new(all_keys), + key_epoch: AtomicU64::new(0), + inner: Arc::new(Mutex::new(HashMap::new())), + rel_key_locks: Mutex::new(HashMap::new()), + save_lock: Mutex::new(()), + }; + + cache.load_persistent().await; + Ok(cache) + } + + fn current_key_epoch(&self) -> u64 { + self.key_epoch.load(Ordering::Acquire) + } + + /// Path2/3 安装前:若 `replace_keys` 已推进 epoch,丢弃本次产物。 + fn epoch_still_current(&self, started: u64) -> bool { + self.current_key_epoch() == started + } + + /// 取得同一 `rel_key` 共享的 async mutex(Path2/Path3 singleflight)。 + async fn lock_for_rel_key(&self, rel_key: &str) -> Arc> { + let mut map = self.rel_key_locks.lock().await; + map.entry(rel_key.to_string()) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + + fn cache_tmp_path(final_path: &Path) -> PathBuf { + let mut name = final_path + .file_name() + .map(|s| s.to_os_string()) + .unwrap_or_else(|| "cache.db".into()); + name.push(".tmp"); + final_path.with_file_name(name) + } + + /// 打开解密产物做 cheap 校验(sqlite_master),防止错 key 粘住 CacheHit。 + fn probe_decrypted_db(path: &Path) -> Result<()> { + let conn = Connection::open(path) + .with_context(|| format!("探测打开解密缓存失败: {}", path.display()))?; + let n: i64 = conn + .query_row("SELECT count(*) FROM sqlite_master", [], |r| r.get(0)) + .context("解密缓存 sqlite_master 探测失败(密钥可能错误)")?; + let _ = n; + Ok(()) + } + + /// temp → final。Unix 上 rename 替换后旧 fd 仍指向旧 inode;Windows 先删目标。 + fn atomic_install_cache(tmp: &Path, final_path: &Path) -> Result<()> { + #[cfg(windows)] + { + if final_path.exists() { + let _ = std::fs::remove_file(final_path); + } + } + std::fs::rename(tmp, final_path).with_context(|| { + format!( + "原子替换缓存失败: {} → {}", + tmp.display(), + final_path.display() + ) + })?; + Ok(()) + } + + /// 数据库根目录(即 `/db_storage`)。 + /// 上层(attachment resolver)需要 `db_dir.parent()` 来定位 `msg/attach/...` 解密图片。 + pub fn db_dir(&self) -> &Path { + &self.db_dir + } + + /// 加密源路径(`db_storage/...`)。 + pub fn source_path(&self, rel_key: &str) -> PathBuf { + self.db_dir.join( + rel_key + .replace('\\', std::path::MAIN_SEPARATOR_STR) + .replace('/', std::path::MAIN_SEPARATOR_STR), + ) + } + + pub fn key_hex(&self, rel_key: &str) -> Option { + self.all_keys + .read() + .ok() + .and_then(|g| g.get(rel_key).cloned()) + } + + /// 热替换密钥映射(`ReloadConfig` / `wx key set`)。 + /// + /// **顺序(第一性原理)**:必须先写新 keys,再 bump epoch。 + /// 若先 bump epoch 再换 keys,`get_with_mode` 可能 snapshot 到 + /// `(epoch=NEW, key=OLD)`,解密旧 key 后仍能通过 epoch 检查并粘住坏缓存。 + /// + /// 随后在 `inner` 下 clear,并删除确定性 cache 路径(含 rename 未 insert 孤儿)。 + pub async fn replace_keys(&self, new_keys: HashMap) { + let old_rels: Vec = self + .all_keys + .read() + .ok() + .map(|g| g.keys().cloned().collect()) + .unwrap_or_default(); + + // 1) keys first — readers never see new epoch with old keys + if let Ok(mut g) = self.all_keys.write() { + *g = new_keys.clone(); + } + // 2) then publish epoch — invalidates in-flight snapshots of old generation + self.key_epoch.fetch_add(1, Ordering::Release); + + let mut stale_paths: Vec = { + let mut inner = self.inner.lock().await; + let paths = inner.values().map(|e| e.decrypted_path.clone()).collect(); + inner.clear(); + paths + }; + for rel in old_rels.into_iter().chain(new_keys.keys().cloned()) { + stale_paths.push(self.cache_file_path(&rel)); + } + stale_paths.sort(); + stale_paths.dedup(); + for p in stale_paths { + let _ = tokio::fs::remove_file(&p).await; + let tmp = Self::cache_tmp_path(&p); + let _ = tokio::fs::remove_file(&tmp).await; + } + self.save_persistent().await; + } + + /// 原子 snapshot `(epoch, key_hex)`:双检 epoch,避免读到换代中间态。 + fn snapshot_key_for_decrypt(&self, rel_key: &str) -> Option<(u64, String)> { + for _ in 0..16 { + let e1 = self.current_key_epoch(); + let key = self.key_hex(rel_key)?; + let e2 = self.current_key_epoch(); + if e1 == e2 { + return Some((e1, key)); + } + } + None + } + + /// 持 `inner` 时安装:epoch 与 used_key 必须仍匹配当前表。 + /// 通过后 rename tmp→final 并 insert;否则删产物返回 None。 + fn commit_cache_product( + &self, + epoch_start: u64, + used_key_hex: &str, + rel_key: &str, + tmp: &Path, + final_path: &Path, + db_mt: u64, + wal_mt: u64, + mode: CacheMode, + inner: &mut HashMap, + ) -> Result> { + // 锁序:不在持有 all_keys write 时等 inner;此处仅 read keys(replace 不持 write 等 inner) + let key_ok = self + .key_hex(rel_key) + .as_deref() + .map(|k| k == used_key_hex) + .unwrap_or(false); + if !self.epoch_still_current(epoch_start) || !key_ok { + let _ = std::fs::remove_file(tmp); + let _ = std::fs::remove_file(final_path); + return Ok(None); + } + if tmp.exists() { + Self::atomic_install_cache(tmp, final_path)?; + } else if !final_path.exists() { + anyhow::bail!("缓存产物缺失: {}", final_path.display()); + } + // rename 后再确认一次(replace 可能刚结束) + let key_ok = self + .key_hex(rel_key) + .as_deref() + .map(|k| k == used_key_hex) + .unwrap_or(false); + if !self.epoch_still_current(epoch_start) || !key_ok { + let _ = std::fs::remove_file(final_path); + return Ok(None); + } + inner.insert( + rel_key.to_string(), + CacheEntry { + db_mtime: db_mt, + wal_mtime: wal_mt, + decrypted_path: final_path.to_path_buf(), + }, + ); + Ok(Some(CacheResolve { + path: final_path.to_path_buf(), + mode, + })) + } + + /// 查询用连接:优先 SQLCipher 在线打开加密源,失败再退回解密缓存。 + /// + /// 大库(message_0/1)上 online 路径避免 10s+ 全量解密。 + pub async fn open_query_conn(&self, rel_key: &str) -> Result> { + let Some(key_hex) = self.key_hex(rel_key) else { + return Ok(None); + }; + let src = self.source_path(rel_key); + if !src.exists() { + return Ok(None); + } + + let src2 = src.clone(); + let key2 = key_hex.clone(); + let online = tokio::task::spawn_blocking(move || sqlcipher::open_encrypted_readonly(&src2, &key2)) + .await + .context("online open task join failed")?; + + match online { + Ok(conn) => { + // 不刷屏:仅在 debug 或首次可考虑日志;这里用安静路径 + return Ok(Some((conn, CacheMode::Online))); + } + Err(e) => { + eprintln!( + "[cache] online 打开失败 {},回退解密缓存: {:#}", + rel_key, e + ); + } + } + + let resolved = self.get_with_mode(rel_key).await?; + let Some(r) = resolved else { + return Ok(None); + }; + let path = r.path.clone(); + let mode = r.mode; + let conn = tokio::task::spawn_blocking(move || Connection::open(&path)) + .await + .context("open cached db task join failed")??; + Ok(Some((conn, mode))) + } + + fn cache_file_path(&self, rel_key: &str) -> PathBuf { + let hash = format!("{:x}", md5::compute(rel_key.as_bytes())); + self.cache_dir.join(format!("{}.db", hash)) + } + + /// 从持久化文件加载 mtime 记录,复用未过期的解密文件 + async fn load_persistent(&self) { + let mtime_file = &self.mtime_file; + let content = match tokio::fs::read_to_string(&mtime_file).await { + Ok(c) => c, + Err(_) => return, + }; + let saved: HashMap = match serde_json::from_str(&content) { + Ok(v) => v, + Err(_) => return, + }; + + let mut inner = self.inner.lock().await; + let mut reused = 0usize; + for (rel_key, entry) in &saved { + let dec_path = PathBuf::from(&entry.path); + if !dec_path.exists() { + continue; + } + // 跳过无法打开的旧坏缓存(错 key 时代写入的 SQLITE_HDR 垃圾) + if Self::probe_decrypted_db(&dec_path).is_err() { + let _ = std::fs::remove_file(&dec_path); + continue; + } + let db_path = self.db_dir.join( + rel_key + .replace('\\', std::path::MAIN_SEPARATOR_STR) + .replace('/', std::path::MAIN_SEPARATOR_STR), + ); + let wal_path = wal_path_for(&db_path); + + let db_mt = mtime_nanos(&db_path); + let _wal_mt = if wal_path.exists() { + mtime_nanos(&wal_path) + } else { + 0 + }; + + // 只要主 .db 没变,就把 cached 产物载回来。 + // 如果 WAL mtime 变了,后续 `get()` 会自动走 Path 2:在已有 cached DB 上增量 apply_wal, + // 而不是 daemon 重启后第一条请求又退回全量解密。 + if db_mt == entry.db_mt { + inner.insert( + rel_key.clone(), + CacheEntry { + db_mtime: db_mt, + // 保留"cached 产物构建时看到的 wal_mtime",让 `get()` 去比较当前 WAL + // 是否发生了变化,从而决定 exact-hit 还是 WAL 增量。 + wal_mtime: entry.wal_mt, + decrypted_path: dec_path, + }, + ); + reused += 1; + } + } + if reused > 0 { + eprintln!("[cache] 复用 {} 个已解密 DB", reused); + } + } + + /// 持久化 mtime 记录 + async fn save_persistent(&self) { + let _save = self.save_lock.lock().await; + let mtime_file = &self.mtime_file; + let data: HashMap = { + let inner = self.inner.lock().await; + inner + .iter() + .map(|(k, v)| { + ( + k.clone(), + MtimeEntry { + db_mt: v.db_mtime, + wal_mt: v.wal_mtime, + path: v.decrypted_path.to_string_lossy().into_owned(), + }, + ) + }) + .collect() + }; + + if let Ok(json) = serde_json::to_string_pretty(&data) { + let tmp = mtime_file.with_extension("json.tmp"); + if tokio::fs::write(&tmp, json).await.is_ok() { + let _ = tokio::fs::rename(&tmp, mtime_file).await; + } + } + } + + /// 获取解密后的数据库路径 + /// + /// 三种命中路径: + /// 1. 主 `.db` 和 WAL mtime 都未变 → 直接返回缓存路径 + /// 2. 主 `.db` 未变、WAL mtime 变了 → 在已有 cached 产物上**增量** `apply_wal` + /// (apply_wal 是幂等的:旧帧 redo 同样的 page 写入,新帧追加生效;不重新 full_decrypt) + /// 3. 主 `.db` mtime 变了 → 重新 `full_decrypt` + `apply_wal` + /// + /// WeChat 在写消息时只 append WAL(除非触发 checkpoint),因此 path 2 是常态; + /// 这条路径把"每次请求都全量解密 ~1.8GB DB(~120s)"压到"只解 WAL 帧(典型 < 10s)"。 + pub async fn get(&self, rel_key: &str) -> Result> { + Ok(self.get_with_mode(rel_key).await?.map(|r| r.path)) + } + + pub async fn get_with_mode(&self, rel_key: &str) -> Result> { + let Some((epoch_start, enc_key_hex)) = self.snapshot_key_for_decrypt(rel_key) else { + return Ok(None); + }; + + let db_path = self.db_dir.join( + rel_key + .replace('\\', std::path::MAIN_SEPARATOR_STR) + .replace('/', std::path::MAIN_SEPARATOR_STR), + ); + if !db_path.exists() { + return Ok(None); + } + + let wal_path = wal_path_for(&db_path); + let db_mt = mtime_nanos(&db_path); + let wal_mt = if wal_path.exists() { + mtime_nanos(&wal_path) + } else { + 0 + }; + + // Path 1 fast path:不持 per-key 锁(只读,无写缓存文件) + { + let cached = { + let inner = self.inner.lock().await; + inner.get(rel_key).cloned() + }; + if let Some(entry) = cached { + if entry.db_mtime == db_mt + && entry.wal_mtime == wal_mt + && entry.decrypted_path.exists() + { + return Ok(Some(CacheResolve { + path: entry.decrypted_path, + mode: CacheMode::CacheHit, + })); + } + } + } + + // Path 2 / Path 3:写缓存文件。同一 rel_key 串行,避免并发撕页。 + let key_lock = self.lock_for_rel_key(rel_key).await; + let _guard = key_lock.lock().await; + + // 锁后:密钥可能已被 replace_keys 换掉 + if !self.epoch_still_current(epoch_start) { + return Ok(None); + } + + // 锁后再读 mtime / entry(前一任务可能已写完) + let db_mt = mtime_nanos(&db_path); + let wal_mt = if wal_path.exists() { + mtime_nanos(&wal_path) + } else { + 0 + }; + let cached = { + let inner = self.inner.lock().await; + inner.get(rel_key).cloned() + }; + + let enc_key_bytes = + hex_to_32bytes(&enc_key_hex).with_context(|| format!("密钥格式错误: {}", rel_key))?; + + if let Some(entry) = cached.as_ref() { + if entry.db_mtime == db_mt && entry.decrypted_path.exists() { + if entry.wal_mtime == wal_mt { + return Ok(Some(CacheResolve { + path: entry.decrypted_path.clone(), + mode: CacheMode::CacheHit, + })); + } + + // Path 2: 只写 temp;epoch+inner 锁下才 rename 安装 + let out_path = entry.decrypted_path.clone(); + let t0 = std::time::Instant::now(); + let tmp = Self::cache_tmp_path(&out_path); + let out_src = out_path.clone(); + let tmp2 = tmp.clone(); + let wal_path2 = wal_path.clone(); + let key_copy = enc_key_bytes; + tokio::task::spawn_blocking(move || { + if tmp2.exists() { + let _ = std::fs::remove_file(&tmp2); + } + std::fs::copy(&out_src, &tmp2).with_context(|| { + format!("复制缓存到 temp 失败: {}", out_src.display()) + })?; + if wal_path2.exists() { + wal::apply_wal(&wal_path2, &tmp2, &key_copy)?; + } + Self::probe_decrypted_db(&tmp2)?; + Ok::<_, anyhow::Error>(()) + }) + .await??; + eprintln!( + "[cache] WAL 增量 {} ({}ms)", + rel_key, + t0.elapsed().as_millis() + ); + + let committed = { + let mut inner = self.inner.lock().await; + self.commit_cache_product( + epoch_start, + &enc_key_hex, + rel_key, + &tmp, + &out_path, + db_mt, + wal_mt, + CacheMode::WalIncremental, + &mut inner, + )? + }; + if committed.is_some() { + self.save_persistent().await; + } + return Ok(committed); + } + } + + // Path 3: 全量解密只落 temp;持 inner 校验 epoch+key 后才 rename+insert + let out_path = self.cache_file_path(rel_key); + let t0 = std::time::Instant::now(); + let db_path2 = db_path.clone(); + let tmp = Self::cache_tmp_path(&out_path); + let tmp2 = tmp.clone(); + let wal_path3 = wal_path.clone(); + let key_copy = enc_key_bytes; + tokio::task::spawn_blocking(move || { + if !crypto::validate_raw_key_for_db(&db_path2, &key_copy) { + anyhow::bail!( + "密钥无法通过源库 HMAC/页头校验: {}(请 {} 或 wx key set)", + db_path2.display(), + crate::config::RECOMMENDED_KEY_EXTRACT + ); + } + if tmp2.exists() { + let _ = std::fs::remove_file(&tmp2); + } + crypto::full_decrypt(&db_path2, &tmp2, &key_copy)?; + if wal_path3.exists() { + wal::apply_wal(&wal_path3, &tmp2, &key_copy)?; + } + Self::probe_decrypted_db(&tmp2)?; + Ok::<_, anyhow::Error>(()) + }) + .await??; + + eprintln!( + "[cache] 全量解密 {} ({}ms)", + rel_key, + t0.elapsed().as_millis() + ); + + let committed = { + let mut inner = self.inner.lock().await; + self.commit_cache_product( + epoch_start, + &enc_key_hex, + rel_key, + &tmp, + &out_path, + db_mt, + wal_mt, + CacheMode::FullDecrypt, + &mut inner, + )? + }; + if committed.is_some() { + self.save_persistent().await; + } + Ok(committed) + } +} + +pub(super) fn mtime_nanos(path: &Path) -> u64 { + std::fs::metadata(path) + .and_then(|m| m.modified()) + .map(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64 + }) + .unwrap_or(0) +} + +/// `foo/bar.db` → `foo/bar.db-wal`(用 OsString 拼接,避免 display() 的 UTF-8 问题) +fn wal_path_for(db_path: &Path) -> PathBuf { + let mut name = db_path.file_name().unwrap_or_default().to_os_string(); + name.push("-wal"); + db_path.with_file_name(name) +} + +fn hex_to_32bytes(s: &str) -> Result<[u8; 32]> { + if s.len() != 64 { + anyhow::bail!("密钥 hex 长度应为 64,实际为 {}", s.len()); + } + let mut out = [0u8; 32]; + for i in 0..32 { + out[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16) + .with_context(|| format!("非法 hex 字符 at {}", i * 2))?; + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 64 字符 hex(测试用假 key;Path3 会在 HMAC 校验处拒绝) + const FAKE_KEY_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000000"; + + fn unique_tmpdir(tag: &str) -> PathBuf { + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let p = std::env::temp_dir().join(format!("wx-cli-cache-test-{}-{}-{}", tag, pid, nanos)); + std::fs::create_dir_all(&p).unwrap(); + p + } + + /// 写入最小合法 SQLite 文件(Path2 probe / CacheHit 内容校验用) + fn write_minimal_sqlite(path: &Path, marker: &str) { + let conn = Connection::open(path).unwrap(); + conn.execute_batch("CREATE TABLE t(x TEXT);") + .unwrap(); + conn.execute("INSERT INTO t(x) VALUES (?1)", [marker]) + .unwrap(); + } + + fn sqlite_marker(path: &Path) -> String { + let conn = Connection::open(path).unwrap(); + conn.query_row("SELECT x FROM t LIMIT 1", [], |r| r.get(0)) + .unwrap() + } + + /// 准备一份 "DbCache 已经 reuse 了 cached 解密产物" 的初始状态。 + /// 返回 (cache, db_path, decrypted_path, mtime_file, rel_key)。 + async fn setup_seeded_cache(tag: &str) -> (DbCache, PathBuf, PathBuf, PathBuf, String) { + let root = unique_tmpdir(tag); + let db_dir = root.join("db_storage"); + let cache_dir = root.join("cache"); + std::fs::create_dir_all(&db_dir).unwrap(); + std::fs::create_dir_all(&cache_dir).unwrap(); + + let rel_key = "message_0.db".to_string(); + let db_path = db_dir.join(&rel_key); + // 非完整页的假加密源:Path3 会 key 校验失败(预期) + std::fs::write(&db_path, b"fake encrypted db").unwrap(); + + let cached_hash = format!("{:x}", md5::compute(rel_key.as_bytes())); + let decrypted_path = cache_dir.join(format!("{}.db", cached_hash)); + write_minimal_sqlite(&decrypted_path, "seed-v1"); + + let db_mt = mtime_nanos(&db_path); + let mtime_file = cache_dir.join("_mtimes.json"); + let payload = serde_json::to_string(&serde_json::json!({ + &rel_key: { + "db_mt": db_mt, + "wal_mt": 0u64, + "path": decrypted_path.display().to_string(), + } + })) + .unwrap(); + std::fs::write(&mtime_file, payload).unwrap(); + + let mut all_keys = HashMap::new(); + all_keys.insert(rel_key.clone(), FAKE_KEY_HEX.to_string()); + let cache = DbCache::with_dirs(db_dir, cache_dir, mtime_file.clone(), all_keys) + .await + .unwrap(); + + (cache, db_path, decrypted_path, mtime_file, rel_key) + } + + #[tokio::test] + async fn lock_for_rel_key_returns_same_arc_for_same_key() { + let (cache, _db_path, _dec, _mt, rel_key) = setup_seeded_cache("lockshare").await; + let a = cache.lock_for_rel_key(&rel_key).await; + let b = cache.lock_for_rel_key(&rel_key).await; + assert!( + Arc::ptr_eq(&a, &b), + "same rel_key must share one mutex for singleflight" + ); + let other = cache.lock_for_rel_key("message/other.db").await; + assert!( + !Arc::ptr_eq(&a, &other), + "different rel_key must not share mutex" + ); + } + + #[tokio::test] + async fn exact_mtime_hit_skips_decrypt() { + let (cache, _db_path, decrypted_path, _mtime_file, rel_key) = + setup_seeded_cache("exact").await; + + let p = cache + .get(&rel_key) + .await + .unwrap() + .expect("cache should hit"); + assert_eq!(p, decrypted_path); + assert_eq!(sqlite_marker(&decrypted_path), "seed-v1"); + } + + #[tokio::test] + async fn wal_only_change_uses_incremental_path() { + let root = unique_tmpdir("walonly"); + let db_dir = root.join("db_storage"); + let cache_dir = root.join("cache"); + std::fs::create_dir_all(&db_dir).unwrap(); + std::fs::create_dir_all(&cache_dir).unwrap(); + + let rel_key = "message_0.db".to_string(); + let db_path = db_dir.join(&rel_key); + std::fs::write(&db_path, b"fake encrypted db").unwrap(); + + let wal_path = wal_path_for(&db_path); + std::fs::write(&wal_path, [0u8; 31]).unwrap(); // ≤ WAL_HDR_SZ=32 → apply_wal noop + + let cached_hash = format!("{:x}", md5::compute(rel_key.as_bytes())); + let decrypted_path = cache_dir.join(format!("{}.db", cached_hash)); + write_minimal_sqlite(&decrypted_path, "wal-seed"); + + let db_mt = mtime_nanos(&db_path); + let wal_mt0 = mtime_nanos(&wal_path); + let mtime_file = cache_dir.join("_mtimes.json"); + let payload = serde_json::to_string(&serde_json::json!({ + &rel_key: { + "db_mt": db_mt, + "wal_mt": wal_mt0, + "path": decrypted_path.display().to_string(), + } + })) + .unwrap(); + std::fs::write(&mtime_file, payload).unwrap(); + + let mut all_keys = HashMap::new(); + all_keys.insert(rel_key.clone(), FAKE_KEY_HEX.to_string()); + let cache = DbCache::with_dirs(db_dir, cache_dir, mtime_file, all_keys) + .await + .unwrap(); + + let p1 = cache.get(&rel_key).await.unwrap().expect("first get hits"); + assert_eq!(p1, decrypted_path); + assert_eq!(sqlite_marker(&decrypted_path), "wal-seed"); + + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write(&wal_path, [0xffu8; 31]).unwrap(); + let wal_mt1 = mtime_nanos(&wal_path); + assert_ne!(wal_mt0, wal_mt1, "rewriting WAL should bump mtime"); + + // WAL noop + temp/rename + probe:标记行应仍可读 + let r = cache.get_with_mode(&rel_key).await.unwrap().expect("wal path"); + assert_eq!(r.path, decrypted_path); + assert_eq!(r.mode, CacheMode::WalIncremental); + assert_eq!(sqlite_marker(&decrypted_path), "wal-seed"); + } + + #[tokio::test] + async fn invalid_key_full_decrypt_does_not_stick_bad_cache() { + let (cache, db_path, decrypted_path, _mtime_file, rel_key) = + setup_seeded_cache("badkey").await; + + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write(&db_path, b"different fake encrypted bytes").unwrap(); + + // Path3:HMAC/页头校验失败 → 不安装坏缓存 + let err = cache.get(&rel_key).await; + assert!(err.is_err(), "expected key validation error, got {err:?}"); + // 旧 seed 文件仍在(rename 未发生) + assert_eq!(sqlite_marker(&decrypted_path), "seed-v1"); + } + + #[tokio::test] + async fn replace_keys_clears_decrypt_cache_entries() { + let (cache, _db_path, decrypted_path, _mtime_file, rel_key) = + setup_seeded_cache("reload").await; + assert!(cache.inner.lock().await.contains_key(&rel_key)); + assert!(decrypted_path.exists()); + + let epoch0 = cache.current_key_epoch(); + let mut new_keys = HashMap::new(); + new_keys.insert(rel_key.clone(), FAKE_KEY_HEX.to_string()); + cache.replace_keys(new_keys).await; + + assert!( + cache.inner.lock().await.is_empty(), + "inner cache map must be cleared" + ); + assert!( + !decrypted_path.exists(), + "stale decrypt file must be deleted" + ); + assert!( + cache.current_key_epoch() > epoch0, + "replace_keys must advance key_epoch so in-flight installs are discarded" + ); + assert!(!cache.epoch_still_current(epoch0)); + } + + /// 真实驱动 C3:tmp 已写好,中途 replace_keys 后 commit 不得安装。 + #[tokio::test] + async fn commit_after_replace_keys_does_not_install_stale_product() { + let (cache, _db_path, decrypted_path, _mtime_file, rel_key) = + setup_seeded_cache("epoch-race").await; + + let (epoch_start, used_key) = cache.snapshot_key_for_decrypt(&rel_key).unwrap(); + let final_path = decrypted_path.clone(); + let tmp = DbCache::cache_tmp_path(&final_path); + write_minimal_sqlite(&tmp, "stale-product"); + + let mut new_keys = HashMap::new(); + new_keys.insert(rel_key.clone(), FAKE_KEY_HEX.to_string()); + cache.replace_keys(new_keys).await; + assert!(!cache.epoch_still_current(epoch_start)); + + if !tmp.exists() { + write_minimal_sqlite(&tmp, "stale-product-recreated"); + } + + let committed = { + let mut inner = cache.inner.lock().await; + cache + .commit_cache_product( + epoch_start, + &used_key, + &rel_key, + &tmp, + &final_path, + 1, + 2, + CacheMode::FullDecrypt, + &mut inner, + ) + .unwrap() + }; + + assert!(committed.is_none(), "stale epoch must not commit CacheResolve"); + assert!(!cache.inner.lock().await.contains_key(&rel_key)); + assert!(!final_path.exists(), "stale product must not remain as final"); + assert!(!tmp.exists(), "stale tmp must be cleaned on reject"); + } + + /// Skeptic C3 residual:若 epoch 已是 NEW 但仍用 OLD key 解密,commit 必须拒绝。 + /// (旧 bug:先 bump epoch 再换 keys → snapshot 到 NEW+OLD 仍可通过 epoch-only 检查) + #[tokio::test] + async fn commit_rejects_new_epoch_with_old_key_hex() { + let (cache, _db_path, decrypted_path, _mtime_file, rel_key) = + setup_seeded_cache("new-epoch-old-key").await; + + let old_key = FAKE_KEY_HEX.to_string(); + // 模拟错误顺序窗口:keys 已换成 OTHER,epoch 已 NEW,但 in-flight used_key 仍是 OLD + const OTHER_KEY: &str = + "1111111111111111111111111111111111111111111111111111111111111111"; + { + let mut g = cache.all_keys.write().unwrap(); + g.insert(rel_key.clone(), OTHER_KEY.to_string()); + } + cache.key_epoch.fetch_add(1, Ordering::Release); + let epoch_new = cache.current_key_epoch(); + + // 去掉 seed entry,只验证本次 commit 行为 + cache.inner.lock().await.clear(); + let final_path = decrypted_path.clone(); + let _ = std::fs::remove_file(&final_path); + let tmp = DbCache::cache_tmp_path(&final_path); + let _ = std::fs::remove_file(&tmp); + write_minimal_sqlite(&tmp, "wrong-key-product"); + + let committed = { + let mut inner = cache.inner.lock().await; + cache + .commit_cache_product( + epoch_new, + &old_key, + &rel_key, + &tmp, + &final_path, + 9, + 9, + CacheMode::FullDecrypt, + &mut inner, + ) + .unwrap() + }; + + assert!( + committed.is_none(), + "NEW epoch + OLD used_key must not install (key revalidation)" + ); + assert!( + !cache.inner.lock().await.contains_key(&rel_key), + "must not insert CacheEntry for wrong used_key" + ); + assert!( + !final_path.exists(), + "wrong-key product must not be installed as final" + ); + assert!(!tmp.exists(), "tmp cleaned on key mismatch reject"); + } + + #[test] + fn snapshot_key_is_consistent_after_keys_then_epoch_order() { + // pure ordering contract: after keys write + epoch bump, snapshot matches + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + rt.block_on(async { + let (cache, _, _, _, rel_key) = setup_seeded_cache("snap-order").await; + let mut new_keys = HashMap::new(); + const K2: &str = + "2222222222222222222222222222222222222222222222222222222222222222"; + new_keys.insert(rel_key.clone(), K2.to_string()); + cache.replace_keys(new_keys).await; + let (e, k) = cache.snapshot_key_for_decrypt(&rel_key).unwrap(); + assert_eq!(k, K2); + assert_eq!(e, cache.current_key_epoch()); + // 再 snapshot 稳定 + let (e2, k2) = cache.snapshot_key_for_decrypt(&rel_key).unwrap(); + assert_eq!((e, k), (e2, k2)); + }); + } + + /// replace_keys 必须删掉「确定性 cache 路径」上的文件,即使它不在 inner 里 + /// (in-flight Path3 rename 后、insert 前的窗口)。 + #[tokio::test] + async fn replace_keys_deletes_orphan_cache_file_not_in_inner() { + let (cache, _db_path, _dec, _mt, rel_key) = setup_seeded_cache("orphan").await; + // 清空 inner 但留下 orphan 文件,模拟「只 rename 未 insert」 + let orphan = { + let seed = cache + .inner + .lock() + .await + .get(&rel_key) + .unwrap() + .decrypted_path + .clone(); + cache.inner.lock().await.clear(); + seed + }; + let _ = std::fs::remove_file(&orphan); + write_minimal_sqlite(&orphan, "orphan"); + assert!(orphan.exists()); + + let mut new_keys = HashMap::new(); + new_keys.insert(rel_key.clone(), FAKE_KEY_HEX.to_string()); + cache.replace_keys(new_keys).await; + + assert!( + !orphan.exists(), + "replace_keys must delete deterministic cache paths even if not in inner" + ); + } + + #[tokio::test] + async fn get_with_mode_reports_hit_and_wal() { + let root = unique_tmpdir("getwithmode"); + let db_dir = root.join("db_storage"); + let cache_dir = root.join("cache"); + std::fs::create_dir_all(&db_dir).unwrap(); + std::fs::create_dir_all(&cache_dir).unwrap(); + + let rel_key = "message_0.db".to_string(); + let db_path = db_dir.join(&rel_key); + std::fs::write(&db_path, b"fake encrypted db").unwrap(); + let wal_path = wal_path_for(&db_path); + std::fs::write(&wal_path, [0u8; 31]).unwrap(); + + let cached_hash = format!("{:x}", md5::compute(rel_key.as_bytes())); + let decrypted_path = cache_dir.join(format!("{}.db", cached_hash)); + write_minimal_sqlite(&decrypted_path, "mode-seed"); + + let db_mt = mtime_nanos(&db_path); + let wal_mt0 = mtime_nanos(&wal_path); + let mtime_file = cache_dir.join("_mtimes.json"); + let payload = serde_json::to_string(&serde_json::json!({ + &rel_key: { + "db_mt": db_mt, + "wal_mt": wal_mt0, + "path": decrypted_path.display().to_string(), + } + })) + .unwrap(); + std::fs::write(&mtime_file, payload).unwrap(); + + let mut all_keys = HashMap::new(); + all_keys.insert(rel_key.clone(), FAKE_KEY_HEX.to_string()); + let cache = DbCache::with_dirs(db_dir, cache_dir, mtime_file, all_keys) + .await + .unwrap(); + + let hit = cache + .get_with_mode(&rel_key) + .await + .unwrap() + .expect("cache should hit"); + assert_eq!(hit.path, decrypted_path); + assert_eq!(hit.mode, CacheMode::CacheHit); + + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write(&wal_path, [0xffu8; 31]).unwrap(); + let wal = cache + .get_with_mode(&rel_key) + .await + .unwrap() + .expect("WAL-only change should stay incremental"); + assert_eq!(wal.path, decrypted_path); + assert_eq!(wal.mode, CacheMode::WalIncremental); + + // 坏 key + 源 mtime 变化 → Path3 拒绝,不返回 FullDecrypt 成功 + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write(&db_path, b"different bytes").unwrap(); + assert!( + cache.get_with_mode(&rel_key).await.is_err(), + "invalid key must not install FullDecrypt product" + ); + } + + #[tokio::test] + async fn restart_with_wal_change_still_reuses_cached_db_then_applies_wal() { + let root = unique_tmpdir("restart-wal"); + let db_dir = root.join("db_storage"); + let cache_dir = root.join("cache"); + std::fs::create_dir_all(&db_dir).unwrap(); + std::fs::create_dir_all(&cache_dir).unwrap(); + + let rel_key = "message_0.db".to_string(); + let db_path = db_dir.join(&rel_key); + std::fs::write(&db_path, b"fake encrypted db").unwrap(); + + let wal_path = wal_path_for(&db_path); + std::fs::write(&wal_path, [0u8; 31]).unwrap(); // WAL 增量仍是 noop + + let cached_hash = format!("{:x}", md5::compute(rel_key.as_bytes())); + let decrypted_path = cache_dir.join(format!("{}.db", cached_hash)); + write_minimal_sqlite(&decrypted_path, "restart-seed"); + + let db_mt = mtime_nanos(&db_path); + let wal_mt0 = mtime_nanos(&wal_path); + let mtime_file = cache_dir.join("_mtimes.json"); + let payload = serde_json::to_string(&serde_json::json!({ + &rel_key: { + "db_mt": db_mt, + "wal_mt": wal_mt0, + "path": decrypted_path.display().to_string(), + } + })) + .unwrap(); + std::fs::write(&mtime_file, payload).unwrap(); + + // 模拟 daemon 重启前又有新消息写入 WAL + std::thread::sleep(std::time::Duration::from_millis(20)); + std::fs::write(&wal_path, [0xffu8; 31]).unwrap(); + let wal_mt1 = mtime_nanos(&wal_path); + assert_ne!(wal_mt0, wal_mt1); + + let mut all_keys = HashMap::new(); + all_keys.insert(rel_key.clone(), FAKE_KEY_HEX.to_string()); + let cache = DbCache::with_dirs(db_dir, cache_dir, mtime_file, all_keys) + .await + .unwrap(); + + let r = cache + .get_with_mode(&rel_key) + .await + .unwrap() + .expect("cache should reuse persisted DB"); + assert_eq!(r.path, decrypted_path); + assert_eq!(r.mode, CacheMode::WalIncremental); + assert_eq!( + sqlite_marker(&decrypted_path), + "restart-seed", + "restart + WAL-only change should still reuse cached DB and avoid full_decrypt" + ); + } +} diff --git a/src/daemon/meta.rs b/src/daemon/meta.rs new file mode 100644 index 0000000..5b3feeb --- /dev/null +++ b/src/daemon/meta.rs @@ -0,0 +1,269 @@ +//! Freshness metadata appended to every q_* response. +//! +//! 背景:`all_keys.json` 是 `wx init` 时的快照。WeChat 在 daemon 启动后随时可能创建 +//! 新的 `message_N.db` 分片;如果只信任 init 时收到的 `msg_db_keys` 列表,新分片里 +//! 的数据对 daemon 完全不可见 → 调用方拿到的是看似正常但缺数据的结果("stale")。 +//! +//! 本模块的职责: +//! 1. 提供 `Meta` 结构体,由各 `q_*` 函数填充后塞进 response(顶层 `meta` 字段)。 +//! 2. 提供 `discover_unknown_shards(db_dir, msg_db_keys)`:扫描磁盘上当前真实存在的 +//! `message/message_*.db` 文件,diff 出 daemon 未持有 enc_key 的"未知分片"列表。 +//! 3. 集中 `MetaStatus` 的判定规则,避免 8 个 q_* 各自判,规则漂移。 + +use serde::Serialize; +use std::collections::HashMap; +use std::path::Path; + +/// 每条 q_* 响应附带的"新鲜度元数据"。 +/// +/// 序列化为 JSON 时,所有 `Option` 字段在 `None` 时省略,让最常见的命令调用 +/// 输出尽量短;重负载字段(per_shard_*、shard_paths)默认不填,由 CLI 层 +/// 通过 `--debug-source` 等开关显式请求时才放进来。 +#[derive(Debug, Clone, Serialize, Default)] +pub struct Meta { + /// 命中数据中最新一条的 create_time(unix 秒)。 + /// `q_history` / `q_search` / `q_new_messages` 等基于 Msg_ 表的查询都应填。 + /// `q_sessions` / `q_unread` 这类基于 SessionTable 的查询填会话维度的最新 ts。 + #[serde(skip_serializing_if = "Option::is_none")] + pub chat_latest_timestamp: Option, + + /// 上面那条最新消息所在的分片 rel_key(`message/message_3.db`)。 + /// 让 agent 一眼看出"当前命中的最新数据来自哪个分片"。 + #[serde(skip_serializing_if = "Option::is_none")] + pub chat_latest_db: Option, + + /// 该 chat 在 `session.db.SessionTable.last_timestamp` 里的值(如果可读)。 + /// 这是 WeChat 自己写的"最近一条消息时间",与上面 `chat_latest_timestamp` 比较 + /// 即可发现"session 说有更新但 history 没读到" → 漏分片。 + #[serde(skip_serializing_if = "Option::is_none")] + pub session_last_timestamp: Option, + + /// 本次查询实际遍历的分片数(即 `names.msg_db_keys.len()` 的子集;包括命中 0 行的)。 + pub shards_scanned: usize, + + /// 本次查询里至少返回了 1 行的分片数。 + pub shards_hit: usize, + + /// 磁盘上存在但 daemon 没有 enc_key 的分片 rel_key 列表。 + /// 非空 ⇒ `wx init` 之后 WeChat 又分裂了新分片 → 必须重跑 `wx init`。 + pub unknown_shards: Vec, + + /// 由上述字段派生出的总体状态,CLI / agent 主要看这一个。 + pub status: MetaStatus, + + // 重负载/调试字段:默认不填,CLI 层显式开启 + #[serde(skip_serializing_if = "Option::is_none")] + pub per_shard_latest: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_mode_per_shard: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub shard_paths: Option>, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum MetaStatus { + #[default] + Ok, + /// `session.db` 的最新时间明显领先于本次消息查询结果,说明数据可能过期或不完整。 + PossiblyStale, + /// 最强信号:磁盘上出现 daemon 不认识的新分片,通常必须重跑 key extract(见 `RECOMMENDED_KEY_EXTRACT`)。 + PossiblyStaleUnknownShards, + /// 调用方主动传了 `since` / `until` / `offset` 等窗口条件,结果天然是局部视图。 + Windowed, +} + +/// session 领先 history 多少秒就报 `PossiblyStale`。 +/// +/// 24h 的取值是故意保守的:活跃群聊/私聊很少会整整一天没有新消息, +/// 超过这个窗口就值得显式提醒 agent 不要把结果当成“当前最新状态”。 +pub const STALE_THRESHOLD_SECS: i64 = 24 * 3600; + +/// 统一 freshness status 的优先级: +/// 1. `unknown_shards` 非空:daemon 整体视图已经过期,优先返回 `PossiblyStaleUnknownShards` +/// 2. `windowed=true`:调用方本来就在看局部窗口,不参与 stale 推导 +/// 3. `session_last - chat_latest > STALE_THRESHOLD_SECS`:返回 `PossiblyStale` +/// 4. 其他情况:`Ok` +pub fn derive_status( + chat_latest: Option, + session_last: Option, + unknown_shards: &[String], + windowed: bool, +) -> MetaStatus { + if !unknown_shards.is_empty() { + return MetaStatus::PossiblyStaleUnknownShards; + } + if windowed { + return MetaStatus::Windowed; + } + match (chat_latest, session_last) { + (Some(c), Some(s)) if s - c > STALE_THRESHOLD_SECS => MetaStatus::PossiblyStale, + _ => MetaStatus::Ok, + } +} + +/// 扫描 `/message/` 下真实存在的 `message_*.db`,diff 出 daemon 当前没有 key +/// 的未知分片。 +/// +/// 契约: +/// - 返回值一律是 `/` 分隔的 rel_key(如 `message/message_3.db`),与 `all_keys.json` 对齐 +/// - 结果按字典序排序,方便测试和 CLI 稳定显示 +/// - 排除 `_fts*` / `_resource*`,因为它们是索引/附件库,不属于消息分片真相 +pub fn discover_unknown_shards(db_dir: &Path, known: &[String]) -> Vec { + let known_set: std::collections::HashSet = + known.iter().map(|k| k.replace('\\', "/")).collect(); + + let msg_dir = db_dir.join("message"); + let entries = match std::fs::read_dir(&msg_dir) { + Ok(it) => it, + Err(_) => return Vec::new(), + }; + + let mut unknown: Vec = Vec::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + if !is_message_shard(name_str) { + continue; + } + let rel = format!("message/{}", name_str); + if !known_set.contains(&rel) { + unknown.push(rel); + } + } + unknown.sort(); + unknown +} + +fn is_message_shard(file_name: &str) -> bool { + if !file_name.starts_with("message_") || !file_name.ends_with(".db") { + return false; + } + if file_name.contains("_fts") || file_name.contains("_resource") { + return false; + } + let stem = &file_name["message_".len()..file_name.len() - ".db".len()]; + !stem.is_empty() && stem.chars().all(|c| c.is_ascii_digit()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_message_shard_accepts_normal_shards() { + assert!(is_message_shard("message_0.db")); + assert!(is_message_shard("message_12.db")); + } + + #[test] + fn is_message_shard_rejects_fts_and_resource() { + assert!(!is_message_shard("message_0_fts.db")); + assert!(!is_message_shard("message_fts.db")); + assert!(!is_message_shard("message_0_resource.db")); + assert!(!is_message_shard("message_resource.db")); + } + + #[test] + fn is_message_shard_rejects_non_digits() { + assert!(!is_message_shard("message_a.db")); + assert!(!is_message_shard("message_.db")); + assert!(!is_message_shard("session.db")); + assert!(!is_message_shard("message_0.db.bak")); + } + + #[test] + fn discover_unknown_shards_finds_disk_only_shards() { + let dir = tempdir(); + let msg_dir = dir.join("message"); + std::fs::create_dir_all(&msg_dir).unwrap(); + for f in [ + "message_0.db", + "message_1.db", + "message_2.db", + "message_0_fts.db", + ] { + std::fs::write(msg_dir.join(f), b"").unwrap(); + } + let known = vec![ + "message/message_0.db".to_string(), + "message/message_1.db".to_string(), + ]; + let unknown = discover_unknown_shards(&dir, &known); + assert_eq!(unknown, vec!["message/message_2.db".to_string()]); + } + + #[test] + fn discover_unknown_shards_normalizes_backslash_in_known_keys() { + let dir = tempdir(); + let msg_dir = dir.join("message"); + std::fs::create_dir_all(&msg_dir).unwrap(); + std::fs::write(msg_dir.join("message_0.db"), b"").unwrap(); + + let known = vec!["message\\message_0.db".to_string()]; + assert!(discover_unknown_shards(&dir, &known).is_empty()); + } + + #[test] + fn discover_unknown_shards_returns_empty_when_message_dir_missing() { + let dir = tempdir(); + assert!(discover_unknown_shards(&dir, &[]).is_empty()); + } + + #[test] + fn derive_status_unknown_shards_overrides_windowed() { + let unknown = vec!["message/message_3.db".to_string()]; + assert_eq!( + derive_status(Some(100), Some(100), &unknown, true), + MetaStatus::PossiblyStaleUnknownShards + ); + } + + #[test] + fn derive_status_windowed_when_user_paginates() { + assert_eq!( + derive_status(Some(100), Some(999_999), &[], true), + MetaStatus::Windowed, + ); + } + + #[test] + fn derive_status_possibly_stale_when_session_far_ahead() { + let chat = Some(1_000_000); + let session = Some(1_000_000 + STALE_THRESHOLD_SECS + 1); + assert_eq!( + derive_status(chat, session, &[], false), + MetaStatus::PossiblyStale + ); + } + + #[test] + fn derive_status_ok_when_within_threshold() { + let chat = Some(1_000_000); + let session = Some(1_000_000 + STALE_THRESHOLD_SECS - 1); + assert_eq!(derive_status(chat, session, &[], false), MetaStatus::Ok); + } + + #[test] + fn derive_status_ok_when_either_side_unknown() { + assert_eq!( + derive_status(None, Some(999_999_999), &[], false), + MetaStatus::Ok + ); + assert_eq!(derive_status(Some(1), None, &[], false), MetaStatus::Ok); + assert_eq!(derive_status(None, None, &[], false), MetaStatus::Ok); + } + + fn tempdir() -> std::path::PathBuf { + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let p = std::env::temp_dir().join(format!("wx-cli-meta-test-{}-{}", pid, nanos)); + std::fs::create_dir_all(&p).unwrap(); + p + } +} diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs new file mode 100644 index 0000000..0851768 --- /dev/null +++ b/src/daemon/mod.rs @@ -0,0 +1,230 @@ +pub mod cache; +pub mod meta; +pub mod query; +pub mod server; +pub mod shard_meta; + +use anyhow::Result; +use std::collections::HashMap; +use std::sync::Arc; + +use crate::config; + +fn normalized_rel_key(rel_key: &str) -> String { + rel_key.replace('\\', "/") +} + +pub(crate) fn is_msg_db_key(rel_key: &str) -> bool { + let rel_key = normalized_rel_key(rel_key); + rel_key.starts_with("message/message_") + && rel_key.ends_with(".db") + && !rel_key.contains("_fts") + && !rel_key.contains("_resource") +} + +pub(crate) fn is_biz_msg_db_key(rel_key: &str) -> bool { + let rel_key = normalized_rel_key(rel_key); + rel_key.starts_with("message/biz_message_") + && rel_key.ends_with(".db") + && !rel_key.contains("_fts") + && !rel_key.contains("_resource") +} + +pub(crate) fn collect_db_keys( + all_keys: &HashMap, + predicate: fn(&str) -> bool, +) -> Vec { + let mut keys: Vec = all_keys + .keys() + .filter(|k| predicate(k)) + .cloned() + .collect(); + keys.sort(); + keys +} + +/// daemon 入口 +/// +/// 当 WX_DAEMON_MODE 环境变量设置时,main() 调用此函数 +pub fn run() { + let rt = tokio::runtime::Runtime::new().expect("无法创建 tokio runtime"); + if let Err(e) = rt.block_on(async_run()) { + eprintln!("[daemon] 启动失败: {}", e); + std::process::exit(1); + } +} + +async fn async_run() -> Result<()> { + // 确保工作目录存在 + let cli_dir = config::cli_dir(); + tokio::fs::create_dir_all(&cli_dir).await?; + tokio::fs::create_dir_all(config::cache_dir()).await?; + + let pid = std::process::id(); + + // 注册 SIGTERM / SIGINT 处理 + setup_signal_handler().await; + + eprintln!("[daemon] wx-daemon 启动 (PID {})", pid); + + // 加载配置 + let cfg = config::load_config()?; + eprintln!("[daemon] DB_DIR: {}", cfg.db_dir.display()); + + // 加载密钥 + let keys_content = tokio::fs::read_to_string(&cfg.keys_file) + .await + .map_err(|e| anyhow::anyhow!("读取密钥文件 {:?} 失败: {}", cfg.keys_file, e))?; + let keys_raw: serde_json::Value = serde_json::from_str(&keys_content)?; + let all_keys = extract_keys(&keys_raw); + eprintln!("[daemon] 密钥数量: {}", all_keys.len()); + warn_unknown_shards(&cfg.db_dir, &all_keys); + + // 初始化 DbCache + let db = Arc::new(cache::DbCache::new(cfg.db_dir.clone(), all_keys.clone()).await?); + + // 收集消息 DB 列表 + let msg_db_keys = collect_db_keys(&all_keys, is_msg_db_key); + let biz_msg_db_keys = collect_db_keys(&all_keys, is_biz_msg_db_key); + + // 预热:加载联系人 + 解密 session.db + eprintln!("[daemon] 预热..."); + let names_raw = query::load_names(&*db).await.unwrap_or_else(|e| { + eprintln!("[daemon] 加载联系人失败: {}", e); + query::Names { + map: HashMap::new(), + md5_to_uname: HashMap::new(), + msg_db_keys: Vec::new(), + biz_msg_db_keys: Vec::new(), + verify_flags: HashMap::new(), + } + }); + let mut names = names_raw; + names.msg_db_keys = msg_db_keys; + names.biz_msg_db_keys = biz_msg_db_keys; + + let _ = db.get("session/session.db").await; + let _ = db.get("sns/sns.db").await; + eprintln!("[daemon] 预热完成,联系人 {} 个", names.map.len()); + + // 包一层内部 Arc:IPC 请求取 guard 后只做 Arc::clone(O(1)), + // 避免每次请求都全量 clone 几千个联系人的 HashMap。 + // 用 tokio::sync::RwLock 允许 guard 跨 await(当前不跨,为未来 reload 留余地)。 + let names_arc = Arc::new(tokio::sync::RwLock::new(Arc::new(names))); + + // 启动 IPC server(阻塞) + let serve_result = server::serve(Arc::clone(&db), Arc::clone(&names_arc)).await; + cleanup_ipc_files(); + serve_result?; + + Ok(()) +} + +/// 磁盘上有加密分片但 all_keys 没有 → 查询结果可能不完整。 +fn warn_unknown_shards(db_dir: &std::path::Path, all_keys: &HashMap) { + let disk = crate::scanner::collect_db_salts(db_dir); + let mut missing: Vec = disk + .into_iter() + .map(|(_, name)| name) + .filter(|name| { + let n = name.replace('\\', "/"); + let interesting = n.contains("message/message_") + || n.contains("message/biz_message_") + || n.contains("session/") + || n == "contact/contact.db"; + interesting && !all_keys.contains_key(name) && !all_keys.contains_key(&n) + }) + .collect(); + missing.sort(); + if missing.is_empty() { + return; + } + eprintln!( + "[wx] 警告:磁盘上发现 daemon 不认识的分片 {},结果可能不完整;{}", + missing.join(", "), + crate::config::RECOMMENDED_KEY_EXTRACT_HINT + ); +} + +/// 从 all_keys.json 提取 rel_key -> enc_key 映射 +/// +/// 兼容两种格式: +/// - `{ "rel/path.db": { "enc_key": "hex" } }`(Python 版原生格式) +/// - `{ "rel/path.db": "hex" }`(简化格式) +pub(crate) fn extract_keys(json: &serde_json::Value) -> HashMap { + let mut result = HashMap::new(); + if let Some(obj) = json.as_object() { + for (k, v) in obj { + if k.starts_with('_') { + continue; + } + let enc_key = if let Some(s) = v.as_str() { + s.to_string() + } else if let Some(obj2) = v.as_object() { + obj2.get("enc_key") + .and_then(|e| e.as_str()) + .unwrap_or_default() + .to_string() + } else { + continue; + }; + if !enc_key.is_empty() { + // 统一路径分隔符 + let rel = k.replace('\\', "/"); + result.insert(rel, enc_key); + } + } + } + result +} + +/// 设置信号处理(Unix: SIGTERM/SIGINT) +async fn setup_signal_handler() { + #[cfg(unix)] + tokio::spawn(async move { + use tokio::signal::unix::{signal, SignalKind}; + let mut term = signal(SignalKind::terminate()).expect("无法监听 SIGTERM"); + let mut int = signal(SignalKind::interrupt()).expect("无法监听 SIGINT"); + tokio::select! { + _ = term.recv() => {}, + _ = int.recv() => {}, + } + cleanup_and_exit(); + }); +} + +#[cfg(unix)] +fn cleanup_and_exit() { + cleanup_ipc_files(); + std::process::exit(0); +} + +fn cleanup_ipc_files() { + let _ = std::fs::remove_file(config::sock_path()); + let _ = std::fs::remove_file(config::pid_path()); +} + +#[cfg(test)] +mod tests { + use super::{is_biz_msg_db_key, is_msg_db_key}; + + #[test] + fn message_db_key_filter_ignores_biz_and_auxiliary_files() { + assert!(is_msg_db_key("message/message_0.db")); + assert!(is_msg_db_key("message\\message_12.db")); + assert!(!is_msg_db_key("message/biz_message_0.db")); + assert!(!is_msg_db_key("message/message_0.db-wal")); + assert!(!is_msg_db_key("message/message_0_fts.db")); + assert!(!is_msg_db_key("message/message_0_resource.db")); + } + + #[test] + fn biz_message_db_key_filter_matches_only_biz_shards() { + assert!(is_biz_msg_db_key("message/biz_message_0.db")); + assert!(is_biz_msg_db_key("message\\biz_message_3.db")); + assert!(!is_biz_msg_db_key("message/message_0.db")); + assert!(!is_biz_msg_db_key("message/biz_message_0.db-wal")); + assert!(!is_biz_msg_db_key("message/biz_message_0_fts.db")); + assert!(!is_biz_msg_db_key("message/biz_message_0_resource.db")); + } +} diff --git a/src/daemon/query.rs b/src/daemon/query.rs new file mode 100644 index 0000000..e9a7620 --- /dev/null +++ b/src/daemon/query.rs @@ -0,0 +1,6052 @@ +use anyhow::{Context, Result}; +use chrono::{Local, TimeZone, Timelike}; +use regex::Regex; +use roxmltree::{Document, Node}; +use rusqlite::Connection; +use serde_json::{json, Value}; +use std::collections::{HashMap, HashSet}; +use std::sync::{Arc, OnceLock}; + +use super::cache::{CacheMode, DbCache}; +use super::meta::{derive_status, discover_unknown_shards, Meta}; + +/// 静态编译的 Msg 表名正则,避免在热路径中重复编译 +fn msg_table_re() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap()) +} + +/// 判定会话类型。返回值固定为 `group` / `official_account` / `folded` / `private` 之一。 +/// +/// 判据次序: +/// 1. `@chatroom` / 折叠入口特殊 username +/// 2. `contact.verify_flag` 非 0 —— 覆盖所有被微信官方打了认证标的账号, +/// 包括 username 为 `wxid_*` 但实为公众号的情况(如"人物"), +/// 以及品牌服务号 `cmb4008205555`、系统号 `qqsafe` / `mphelper` 等 +/// 3. username 前缀兜底(`gh_*` / `biz_*` / `@*` 等)—— 在 contact 表未加载或没记录时 +/// 仍能给出正确结果 +pub fn chat_type_of(username: &str, names: &Names) -> &'static str { + if username.contains("@chatroom") { + return "group"; + } + if username == "brandsessionholder" || username == "@placeholder_foldgroup" { + return "folded"; + } + if names.is_verified(username) { + return "official_account"; + } + if username.starts_with("gh_") || username.starts_with("biz_") { + return "official_account"; + } + // `@` 开头的剩余 username(如 `@opencustomerservicemsg`)是微信内部系统账号, + // 通常不落在 contact 表里,verify_flag 兜不住,按前缀兜底。 + if username.starts_with('@') { + return "official_account"; + } + "private" +} + +/// 联系人名称缓存 +#[derive(Clone)] +pub struct Names { + /// username -> display_name + pub map: HashMap, + /// md5(username) -> username(用于从 Msg_ 表名反推联系人) + pub md5_to_uname: HashMap, + /// 消息 DB 的相对路径列表(message/message_N.db) + pub msg_db_keys: Vec, + /// 公众号推送 DB 的相对路径列表(message/biz_message_N.db) + pub biz_msg_db_keys: Vec, + /// username -> contact.verify_flag(0=真人,非 0 通常为公众号/服务号/认证账号) + pub verify_flags: HashMap, +} + +#[derive(Debug, Clone)] +struct MessageShard { + rel_key: String, + path: std::path::PathBuf, + table: String, + max_ts: i64, + cache_mode: CacheMode, +} + +impl Names { + pub fn display(&self, username: &str) -> String { + self.map + .get(username) + .cloned() + .unwrap_or_else(|| username.to_string()) + } + + /// 是否被微信官方标了认证/服务号 flag。未在 contact 表中的 username 返回 false。 + pub fn is_verified(&self, username: &str) -> bool { + self.verify_flags.get(username).copied().unwrap_or(0) != 0 + } +} + +fn current_unknown_shards(db: &DbCache, names: &Names) -> Vec { + discover_unknown_shards(db.db_dir(), &names.msg_db_keys) +} + +fn meta_for_shards( + scanned: usize, + shards: &[MessageShard], + shard_hits: usize, + unknown_shards: Vec, + session_last_timestamp: Option, + windowed: bool, + with_meta: bool, + debug_source: bool, +) -> Meta { + let latest = shards.first(); + let chat_latest_timestamp = latest.map(|s| s.max_ts); + Meta { + chat_latest_timestamp, + chat_latest_db: latest.map(|s| s.rel_key.clone()), + session_last_timestamp, + shards_scanned: scanned, + shards_hit: shard_hits, + unknown_shards: unknown_shards.clone(), + status: derive_status( + chat_latest_timestamp, + session_last_timestamp, + &unknown_shards, + windowed, + ), + per_shard_latest: if with_meta || debug_source { + Some( + shards + .iter() + .map(|s| (s.rel_key.clone(), s.max_ts)) + .collect(), + ) + } else { + None + }, + cache_mode_per_shard: if with_meta || debug_source { + Some( + shards + .iter() + .map(|s| (s.rel_key.clone(), s.cache_mode.as_str().to_string())) + .collect(), + ) + } else { + None + }, + shard_paths: if debug_source { + Some( + shards + .iter() + .map(|s| (s.rel_key.clone(), s.path.to_string_lossy().into_owned())) + .collect(), + ) + } else { + None + }, + } +} + +fn meta_for_global_query( + scanned: usize, + hit: usize, + unknown_shards: Vec, + windowed: bool, + with_meta: bool, + debug_source: bool, + cache_modes: Option>, + shard_paths: Option>, +) -> Meta { + Meta { + chat_latest_timestamp: None, + chat_latest_db: None, + session_last_timestamp: None, + shards_scanned: scanned, + shards_hit: hit, + unknown_shards: unknown_shards.clone(), + status: derive_status(None, None, &unknown_shards, windowed), + per_shard_latest: if with_meta || debug_source { + Some(HashMap::new()) + } else { + None + }, + cache_mode_per_shard: if with_meta || debug_source { + cache_modes + } else { + None + }, + shard_paths: if debug_source { shard_paths } else { None }, + } +} + +async fn session_last_timestamp(db: &DbCache, username: &str) -> Option { + let path = match db.get("session/session.db").await { + Ok(Some(path)) => path, + Ok(None) => return None, + Err(e) => { + eprintln!( + "[freshness] skip session_last_timestamp {}: {}", + username, e + ); + return None; + } + }; + + let username = username.to_string(); + let username_for_query = username.clone(); + match tokio::task::spawn_blocking(move || -> Result> { + let conn = Connection::open(&path)?; + let ts = conn + .query_row( + "SELECT last_timestamp FROM SessionTable WHERE username = ?", + [&username_for_query], + |row| row.get::<_, i64>(0), + ) + .ok(); + Ok(ts) + }) + .await + { + Ok(Ok(ts)) => ts, + Ok(Err(e)) => { + eprintln!( + "[freshness] skip session_last_timestamp {}: {}", + username, e + ); + None + } + Err(e) => { + eprintln!( + "[freshness] task error session_last_timestamp {}: {}", + username, e + ); + None + } + } +} + +/// 加载联系人缓存(从 contact/contact.db) +pub async fn load_names(db: &DbCache) -> Result { + let path = db.get("contact/contact.db").await?; + let mut map = HashMap::new(); + let mut verify_flags: HashMap = HashMap::new(); + if let Some(p) = path { + let p2 = p.clone(); + let rows: Vec<(String, String, String, i64)> = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&p2).context("打开 contact.db 失败")?; + let mut stmt = + conn.prepare("SELECT username, nick_name, remark, verify_flag FROM contact")?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1).unwrap_or_default(), + row.get::<_, String>(2).unwrap_or_default(), + row.get::<_, i64>(3).unwrap_or(0), + )) + })? + .collect::>>()?; + Ok::<_, anyhow::Error>(rows) + }) + .await??; + + for (uname, nick, remark, vf) in rows { + let display = if !remark.is_empty() { + remark + } else if !nick.is_empty() { + nick + } else { + uname.clone() + }; + verify_flags.insert(uname.clone(), vf); + map.insert(uname, display); + } + } + + let md5_to_uname: HashMap = map + .keys() + .map(|u| (format!("{:x}", md5::compute(u.as_bytes())), u.clone())) + .collect(); + + Ok(Names { + map, + md5_to_uname, + msg_db_keys: Vec::new(), + biz_msg_db_keys: Vec::new(), + verify_flags, + }) +} + +/// 查询最近会话列表 +pub async fn q_sessions( + db: &DbCache, + names: &Names, + limit: usize, + with_meta: bool, + debug_source: bool, +) -> Result { + let path = db + .get("session/session.db") + .await? + .context("无法解密 session.db")?; + + let path2 = path.clone(); + let limit_val = limit; + let rows: Vec<(String, i64, Vec, i64, i64, String, String)> = + tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path2)?; + let mut stmt = conn.prepare( + "SELECT username, unread_count, summary, last_timestamp, + last_msg_type, last_msg_sender, last_sender_display_name + FROM SessionTable + WHERE last_timestamp > 0 + ORDER BY last_timestamp DESC LIMIT ?", + )?; + let rows = stmt + .query_map([limit_val as i64], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1).unwrap_or(0), + get_content_bytes(row, 2), + row.get::<_, i64>(3).unwrap_or(0), + row.get::<_, i64>(4).unwrap_or(0), + row.get::<_, String>(5).unwrap_or_default(), + row.get::<_, String>(6).unwrap_or_default(), + )) + })? + .collect::>>()?; + Ok::<_, anyhow::Error>(rows) + }) + .await??; + + let mut results = Vec::new(); + let mut group_nickname_cache: HashMap> = HashMap::new(); + for (username, unread, summary_bytes, ts, msg_type, sender, sender_name) in rows { + let display = names.display(&username); + let chat_type = chat_type_of(&username, names); + let is_group = chat_type == "group"; + + // 尝试 zstd 解压 summary + let summary = decompress_or_str(&summary_bytes); + let summary = strip_group_prefix(&summary); + + let sender_display = if is_group && !sender.is_empty() { + if !group_nickname_cache.contains_key(&username) { + let nicknames = load_group_nicknames(db, &username) + .await + .unwrap_or_default(); + group_nickname_cache.insert(username.clone(), nicknames); + } + let empty = HashMap::new(); + let group_nicknames = group_nickname_cache.get(&username).unwrap_or(&empty); + sender_display(&sender, &sender_name, &names.map, group_nicknames) + } else { + String::new() + }; + + results.push(json!({ + "chat": display, + "username": username, + "is_group": is_group, + "chat_type": chat_type, + "unread": unread, + "last_msg_type": fmt_type(msg_type), + "last_sender": sender_display, + "summary": summary, + "timestamp": ts, + "time": fmt_time(ts, "%m-%d %H:%M"), + })); + } + let latest_ts = results + .first() + .and_then(|v| v.get("timestamp")) + .and_then(|v| v.as_i64()); + let unknown_shards = current_unknown_shards(db, names); + let meta = Meta { + chat_latest_timestamp: latest_ts, + chat_latest_db: latest_ts.map(|_| "session/session.db".to_string()), + session_last_timestamp: None, + shards_scanned: 0, + shards_hit: 0, + unknown_shards: unknown_shards.clone(), + status: derive_status(latest_ts, None, &unknown_shards, false), + per_shard_latest: if with_meta || debug_source { + Some(HashMap::new()) + } else { + None + }, + cache_mode_per_shard: None, + shard_paths: None, + }; + Ok(json!({ "sessions": results, "meta": meta })) +} + +/// 查询聊天记录 +pub async fn q_history( + db: &DbCache, + names: &Names, + chat: &str, + limit: usize, + offset: usize, + since: Option, + until: Option, + // 游标:create_time 严格小于该值(向更旧翻页) + after_ts: Option, + // 游标:create_time 严格大于该值(向更新翻页) + before_ts: Option, + msg_type: Option, + with_meta: bool, + debug_source: bool, +) -> Result { + let username = + resolve_username(chat, names).with_context(|| format!("找不到联系人: {}", chat))?; + let display = names.display(&username); + let chat_type = chat_type_of(&username, names); + let is_group = chat_type == "group"; + + // after_ts/before_ts 收紧时间窗,便于分片路由跳过无关库 + let eff_since = match (since, before_ts) { + (Some(s), Some(b)) => Some(s.max(b.saturating_add(1))), + (s, None) => s, + (None, Some(b)) => Some(b.saturating_add(1)), + }; + let eff_until = match (until, after_ts) { + (Some(u), Some(a)) => Some(u.min(a.saturating_sub(1))), + (u, None) => u, + (None, Some(a)) => Some(a.saturating_sub(1)), + }; + + // 按时间路由分片:避免为「最近 N 条」解密全部 message_*.db + let (shards, scanned) = find_msg_shards(db, names, &username, eff_since, eff_until).await?; + if shards.is_empty() { + anyhow::bail!("找不到 {} 的消息记录", display); + } + + let mut all_msgs: Vec = Vec::new(); + let mut shard_hits = 0usize; + let group_nicknames = if is_group { + load_group_nicknames(db, &username) + .await + .unwrap_or_default() + } else { + HashMap::new() + }; + let needed = offset.saturating_add(limit); + // 无时间窗/类型过滤时,可按分片 max_ts 做安全早停(见 history_can_early_stop) + let allow_early_stop = since.is_none() + && until.is_none() + && before_ts.is_none() + && after_ts.is_none() + && msg_type.is_none(); + for (idx, shard) in shards.iter().enumerate() { + let rel = shard.rel_key.clone(); + let tname = shard.table.clone(); + let uname = username.clone(); + let is_group2 = is_group; + let names_map = names.map.clone(); + let group_nicknames2 = group_nicknames.clone(); + let since2 = eff_since; + let until2 = eff_until; + let after2 = after_ts; + let before2 = before_ts; + let limit2 = limit; + let offset2 = offset; + + let opened = match db.open_query_conn(&rel).await? { + Some(v) => v, + None => continue, + }; + let (conn, _mode) = opened; + + let msgs: Vec = tokio::task::spawn_blocking(move || { + let per_db_cap = offset2 + limit2; + query_messages_conn( + &conn, + &tname, + &uname, + is_group2, + &names_map, + &group_nicknames2, + since2, + until2, + after2, + before2, + msg_type, + per_db_cap, + 0, + ) + }) + .await??; + + if !msgs.is_empty() { + shard_hits += 1; + } + all_msgs.extend(msgs); + + if allow_early_stop { + all_msgs.sort_by_key(|m| std::cmp::Reverse(m["timestamp"].as_i64().unwrap_or(0))); + all_msgs.truncate(needed.max(1)); + let top_min = all_msgs + .iter() + .filter_map(|m| m["timestamp"].as_i64()) + .min(); + let next_max = shards.get(idx + 1).map(|s| s.max_ts); + if history_can_early_stop(needed, all_msgs.len(), top_min, next_max) { + break; + } + } + } + + all_msgs.sort_by_key(|m| std::cmp::Reverse(m["timestamp"].as_i64().unwrap_or(0))); + let paged: Vec = all_msgs.into_iter().skip(offset).take(limit).collect(); + let mut paged = paged; + paged.sort_by_key(|m| m["timestamp"].as_i64().unwrap_or(0)); + let windowed = offset > 0 + || since.is_some() + || until.is_some() + || after_ts.is_some() + || before_ts.is_some() + || msg_type.is_some(); + let unknown_shards = current_unknown_shards(db, names); + let session_ts = session_last_timestamp(db, &username).await; + let meta = meta_for_shards( + scanned, + &shards, + shard_hits, + unknown_shards, + session_ts, + windowed, + with_meta, + debug_source, + ); + + Ok(json!({ + "chat": display, + "username": username, + "is_group": is_group, + "chat_type": chat_type, + "count": paged.len(), + "messages": paged, + "meta": meta, + })) +} + +/// history 跨分片早停:堆已满 `needed` 且下一分片 max_ts 严格小于堆内最旧消息。 +/// +/// 若 `next_shard_max_ts` 为 None(无更多分片),可停。 +/// 重叠时间窗时禁止「凑够条数就停」,否则会丢掉仍可能进入 top-N 的消息。 +pub(crate) fn history_can_early_stop( + needed: usize, + collected_count: usize, + collected_top_min_ts: Option, + next_shard_max_ts: Option, +) -> bool { + if collected_count < needed { + return false; + } + let Some(min_ts) = collected_top_min_ts else { + return false; + }; + match next_shard_max_ts { + None => true, + Some(next_max) => next_max < min_ts, + } +} + +/// timeline 最多拉多少会话(按 last_timestamp 降序),避免 200×history。 +pub(crate) fn timeline_session_cap(needed: usize) -> usize { + needed.saturating_mul(2).clamp(8, 48) +} + +/// 每个会话最多取几条(history limit)。 +pub(crate) fn timeline_per_chat(needed: usize) -> usize { + (needed / 2).clamp(10, 80) +} + +/// 全局堆已满 `needed` 条,且剩余会话的 last_timestamp 都严格小于堆内最旧消息 → 可早停。 +pub(crate) fn timeline_can_early_stop( + needed: usize, + collected_count: usize, + top_needed_min_ts: Option, + remaining_sessions_max_last_ts: i64, +) -> bool { + if collected_count < needed { + return false; + } + let Some(min_ts) = top_needed_min_ts else { + return false; + }; + // 更冷会话不可能再贡献进 top-needed(按 create_time 排序) + remaining_sessions_max_last_ts < min_ts +} + +/// 从已收集消息中取时间最新的 `needed` 条的最小 timestamp(用于早停判定)。 +pub(crate) fn timeline_top_needed_min_ts(timestamps: &[i64], needed: usize) -> Option { + if needed == 0 || timestamps.is_empty() { + return None; + } + let mut ts: Vec = timestamps.to_vec(); + ts.sort_by_key(|t| std::cmp::Reverse(*t)); + if ts.len() < needed { + return None; + } + ts.into_iter().take(needed).min() +} + +/// 跨会话时间线:按时间合并多会话消息(适合日报 / Agent 记忆补全)。 +pub async fn q_timeline( + db: &DbCache, + names: &Names, + limit: usize, + offset: usize, + since: Option, + until: Option, + after_ts: Option, + msg_type: Option, + with_meta: bool, + debug_source: bool, +) -> Result { + let needed = offset.saturating_add(limit).max(limit); + let sess_cap = timeline_session_cap(needed); + let per_chat = timeline_per_chat(needed); + + // 1) 取最近会话作候选(有 cap,不扫 200) + let sess = q_sessions(db, names, sess_cap, false, false).await?; + let mut sessions = sess + .get("sessions") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + // 热会话优先,便于早停 + sessions.sort_by_key(|s| { + std::cmp::Reverse(s.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0)) + }); + if sessions.len() > sess_cap { + sessions.truncate(sess_cap); + } + + let mut all: Vec = Vec::new(); + let mut sessions_queried = 0usize; + + for (idx, s) in sessions.iter().enumerate() { + let uname = s.get("username").and_then(|v| v.as_str()).unwrap_or(""); + if uname.is_empty() { + continue; + } + let last = s.get("timestamp").and_then(|v| v.as_i64()).unwrap_or(0); + if let Some(s0) = since { + if last > 0 && last < s0 { + continue; + } + } + if let Some(u0) = until { + // session last 远早于 until 仍可能有消息落在窗内;仅跳过 last 明确晚于 until 之后全无 + let _ = u0; + } + + sessions_queried += 1; + match q_history( + db, + names, + uname, + per_chat, + 0, + since, + until, + after_ts, + None, + msg_type, + false, + false, + ) + .await + { + Ok(hist) => { + let chat = hist + .get("chat") + .and_then(|v| v.as_str()) + .unwrap_or(uname) + .to_string(); + let is_group = hist + .get("is_group") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if let Some(msgs) = hist.get("messages").and_then(|m| m.as_array()) { + for m in msgs { + let mut row = m.clone(); + if let Some(obj) = row.as_object_mut() { + obj.insert("chat".into(), json!(chat.clone())); + obj.insert("username".into(), json!(uname)); + obj.insert("is_group".into(), json!(is_group)); + obj.insert( + "chat_type".into(), + json!(chat_type_of(uname, names)), + ); + } + all.push(row); + } + } + } + Err(_) => continue, + } + + // 早停:堆内 top-needed 已满,且更冷会话 last_ts 进不了堆 + let ts: Vec = all + .iter() + .filter_map(|m| m["timestamp"].as_i64()) + .collect(); + let top_min = timeline_top_needed_min_ts(&ts, needed); + let remaining_max = sessions + .iter() + .skip(idx + 1) + .filter_map(|s| s.get("timestamp").and_then(|v| v.as_i64())) + .max() + .unwrap_or(0); + if timeline_can_early_stop(needed, ts.len(), top_min, remaining_max) { + break; + } + } + + all.sort_by_key(|m| std::cmp::Reverse(m["timestamp"].as_i64().unwrap_or(0))); + let paged: Vec = all.into_iter().skip(offset).take(limit).collect(); + let mut paged = paged; + paged.sort_by_key(|m| m["timestamp"].as_i64().unwrap_or(0)); + + let mut out = json!({ + "count": paged.len(), + "messages": paged, + }); + if with_meta || debug_source { + out["meta"] = json!({ + "source": "timeline", + "sessions_considered": sessions.len(), + "sessions_queried": sessions_queried, + "session_cap": sess_cap, + "per_chat": per_chat, + }); + } + Ok(out) +} + +/// 通过 message_fts.db 的 `*_content` 表做关键词搜索(不依赖 MMFtsTokenizer)。 +/// +/// 列布局:c0=acontent, c1=message_local_id, c2=sort_seq, c3=local_type, +/// c4=session_id, c5=sender_id, c6=create_time +async fn search_via_fts( + db: &DbCache, + names: &Names, + keyword: &str, + chats: Option<&Vec>, + limit: usize, + since: Option, + until: Option, +) -> Result> { + const FTS_REL: &str = "message/message_fts.db"; + if db.key_hex(FTS_REL).is_none() { + return Ok(None); + } + let Some((conn, _mode)) = db.open_query_conn(FTS_REL).await? else { + return Ok(None); + }; + + // 预先把 --in CHAT 解析成 username,在同一 conn 上查 name2id(勿二次 online-only open) + let filter_unames: Option> = if let Some(list) = chats { + let mut u = Vec::new(); + for chat in list { + if let Some(uname) = resolve_username(chat, names) { + u.push(uname); + } + } + if u.is_empty() { + return Ok(Some(json!({ + "keyword": keyword, + "count": 0, + "results": [], + }))); + } + Some(u) + } else { + None + }; + + let kw = keyword.to_string(); + let since2 = since; + let until2 = until; + let limit2 = limit; + let names_map = names.map.clone(); + + let results: Vec = tokio::task::spawn_blocking(move || { + // name2id: rowid -> username 与 username -> rowid(session filter) + let mut id2name: HashMap = HashMap::new(); + let mut name2id: HashMap = HashMap::new(); + { + let mut stmt = conn.prepare("SELECT rowid, username FROM name2id")?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?; + for row in rows.flatten() { + name2id.insert(row.1.clone(), row.0); + id2name.insert(row.0, row.1); + } + } + + let session_filter: Option> = if let Some(ref unames) = filter_unames { + let mut ids = HashSet::new(); + for uname in unames { + if let Some(id) = name2id.get(uname) { + ids.insert(*id); + } + } + if ids.is_empty() { + return Ok(Vec::new()); + } + Some(ids) + } else { + None + }; + + let like = format!("%{}%", escape_like_pattern(&kw)); + let content_tables = [ + "message_fts_v4_0_content", + "message_fts_v4_1_content", + "message_fts_v4_2_content", + "message_fts_v4_3_content", + ]; + let mut hits: Vec<(i64, i64, i64, i64, i64, String)> = Vec::new(); + // (create_time, sort_seq, session_id, sender_id, local_type, snippet) + + // session filter 必须进 SQL:LIMIT 在 filter 之前会导致 --in CHAT 结果不全 + let session_ids: Option> = + session_filter.as_ref().map(|s| s.iter().copied().collect()); + + for table in content_tables { + let exists: bool = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + [table], + |r| r.get::<_, i64>(0), + ) + .is_ok(); + if !exists { + continue; + } + let mut clauses = vec!["c0 LIKE ? ESCAPE '\\'".to_string()]; + let mut params: Vec> = Vec::new(); + params.push(Box::new(like.clone())); + if let Some(s) = since2 { + clauses.push("c6 >= ?".into()); + params.push(Box::new(s)); + } + if let Some(u) = until2 { + clauses.push("c6 <= ?".into()); + params.push(Box::new(u)); + } + if let Some(ref ids) = session_ids { + if ids.is_empty() { + continue; + } + let ph: Vec<&str> = ids.iter().map(|_| "?").collect(); + clauses.push(format!("c4 IN ({})", ph.join(","))); + for id in ids { + params.push(Box::new(*id)); + } + } + let sql = format!( + "SELECT c0, c1, c2, c3, c4, c5, c6 FROM [{}] WHERE {} ORDER BY c6 DESC LIMIT ?", + table, + clauses.join(" AND ") + ); + // 多 content 表时多取一些,最终再 truncate + params.push(Box::new((limit2 * 4) as i64)); + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(params_ref.as_slice(), |r| { + Ok(( + r.get::<_, String>(0).unwrap_or_default(), + r.get::<_, i64>(1).unwrap_or(0), + r.get::<_, i64>(2).unwrap_or(0), + r.get::<_, i64>(3).unwrap_or(0), + r.get::<_, i64>(4).unwrap_or(0), + r.get::<_, i64>(5).unwrap_or(0), + r.get::<_, i64>(6).unwrap_or(0), + )) + })?; + for row in rows.flatten() { + let (snippet, _local_id, sort_seq, local_type, session_id, sender_id, create_time) = + row; + hits.push(( + create_time, + sort_seq, + session_id, + sender_id, + local_type, + snippet, + )); + } + } + + hits.sort_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1))); + hits.truncate(limit2); + + let mut out = Vec::new(); + for (ts, _sort_seq, session_id, sender_id, local_type, snippet) in hits { + let talker = id2name + .get(&session_id) + .cloned() + .unwrap_or_else(|| format!("session:{}", session_id)); + let sender_u = id2name.get(&sender_id).cloned().unwrap_or_default(); + let display = names_map + .get(&talker) + .cloned() + .unwrap_or_else(|| talker.clone()); + let sender_display = if sender_u.is_empty() { + String::new() + } else { + names_map + .get(&sender_u) + .cloned() + .unwrap_or_else(|| sender_u.clone()) + }; + let mut row = json!({ + "timestamp": ts, + "time": fmt_time(ts, "%Y-%m-%d %H:%M"), + "chat": display, + "username": talker, + "sender": sender_display, + "content": snippet, + "type": fmt_type(local_type), + "source": "message_fts", + }); + // FTS 片段无完整 XML 时 appmsg_type 可能缺失;type_code/type_id 仍可机读 + attach_type_fields(&mut row, local_type, &snippet); + out.push(row); + } + Ok::<_, anyhow::Error>(out) + }) + .await??; + + Ok(Some(json!({ + "keyword": keyword, + "count": results.len(), + "results": results, + }))) +} + +/// 搜索消息 +pub async fn q_search( + db: &DbCache, + names: &Names, + keyword: &str, + chats: Option>, + limit: usize, + since: Option, + until: Option, + msg_type: Option, + with_meta: bool, + debug_source: bool, +) -> Result { + // 优先走 message_fts.db 的 content 表(LIKE),避免解密/扫描所有 message_N + // 自定义 tokenizer 的 MATCH 路径需要 MMFtsTokenizer,这里用 content 表兜底。 + if msg_type.is_none() { + match search_via_fts(db, names, keyword, chats.as_ref(), limit, since, until).await { + Ok(Some(mut v)) => { + // Agent-first:与 history/new_messages 一样始终带完整 freshness meta, + // 避免 FTS 快路径静默丢掉 unknown_shards / status。 + let unknown_shards = current_unknown_shards(db, names); + let windowed = since.is_some() || until.is_some() || chats.is_some(); + let hit = v.get("count").and_then(|c| c.as_u64()).unwrap_or(0) as usize; + let meta = meta_for_global_query( + 1, + if hit > 0 { 1 } else { 0 }, + unknown_shards, + windowed, + with_meta, + debug_source, + Some(HashMap::from([( + "message/message_fts.db".into(), + "online_or_cache".into(), + )])), + None, + ); + if let Some(obj) = v.as_object_mut() { + let mut meta_v = serde_json::to_value(&meta).unwrap_or(json!({})); + if let Some(m) = meta_v.as_object_mut() { + m.insert("source".into(), json!("message_fts")); + m.insert("mode".into(), json!("fts_content_like")); + } + obj.insert("meta".into(), meta_v); + } + return Ok(v); + } + Ok(None) => {} + Err(e) => { + eprintln!("[search] FTS 路径失败,回退全库扫描: {:#}", e); + } + } + } + + let mut targets: Vec<(String, String, String, String, String)> = Vec::new(); // (rel_key, path, table, display, uname) + let mut scanned_rel_keys: HashSet = HashSet::new(); + let mut cache_modes: HashMap = HashMap::new(); + let mut shard_paths: HashMap = HashMap::new(); + + if let Some(chat_names) = chats { + for chat_name in &chat_names { + if let Some(uname) = resolve_username(chat_name, names) { + let (shards, _) = find_msg_shards(db, names, &uname, None, None).await?; + for shard in shards { + scanned_rel_keys.insert(shard.rel_key.clone()); + cache_modes + .insert(shard.rel_key.clone(), shard.cache_mode.as_str().to_string()); + shard_paths.insert( + shard.rel_key.clone(), + shard.path.to_string_lossy().into_owned(), + ); + targets.push(( + shard.rel_key, + shard.path.to_string_lossy().into_owned(), + shard.table, + names.display(&uname), + uname.clone(), + )); + } + } + } + } else { + // 全局搜索回退:遍历所有消息 DB(优先 online open) + for rel_key in &names.msg_db_keys { + let opened = match db.open_query_conn(rel_key).await? { + Some(v) => v, + None => continue, + }; + let (conn, mode) = opened; + scanned_rel_keys.insert(rel_key.clone()); + cache_modes.insert(rel_key.clone(), mode.as_str().to_string()); + let path_label = db.source_path(rel_key).to_string_lossy().into_owned(); + shard_paths.insert(rel_key.clone(), path_label.clone()); + let md5_lookup = names.md5_to_uname.clone(); + let names_map = names.map.clone(); + let rel_key2 = rel_key.clone(); + let path_label2 = path_label.clone(); + + let table_targets: Vec<(String, String, String, String, String)> = + match tokio::task::spawn_blocking(move || { + let mut stmt = conn.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'", + )?; + let table_names: Vec = stmt + .query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + + let re = msg_table_re(); + let mut result = Vec::new(); + for tname in table_names { + if !re.is_match(&tname) { + continue; + } + let hash = &tname[4..]; + let uname = md5_lookup.get(hash).cloned().unwrap_or_default(); + let display = if uname.is_empty() { + String::new() + } else { + names_map + .get(&uname) + .cloned() + .unwrap_or_else(|| uname.clone()) + }; + result.push(( + rel_key2.clone(), + path_label2.clone(), + tname, + display, + uname, + )); + } + Ok::<_, anyhow::Error>(result) + }) + .await + { + Ok(Ok(v)) => v, + Ok(Err(e)) => { + eprintln!("[search] skip DB {}: {}", rel_key, e); + continue; + } + Err(e) => { + eprintln!("[search] task error {}: {}", rel_key, e); + continue; + } + }; + + targets.extend(table_targets); + } + } + + // 按 db_path 分组 + let mut by_path: HashMap> = HashMap::new(); + let mut path_to_rel_key: HashMap = HashMap::new(); + for (rel_key, p, t, d, u) in targets { + path_to_rel_key.insert(p.clone(), rel_key); + by_path.entry(p).or_default().push((t, d, u)); + } + + let mut group_usernames = HashSet::new(); + for table_list in by_path.values() { + for (_, _, uname) in table_list { + if uname.contains("@chatroom") { + group_usernames.insert(uname.clone()); + } + } + } + let group_nicknames_by_chat = load_group_nickname_maps(db, group_usernames) + .await + .unwrap_or_default(); + let group_nicknames_by_chat = Arc::new(group_nicknames_by_chat); + + // 多个 message_*.db 之间没有数据依赖,并发解密 + 查询。每个 DB 内部仍按 + // table 串行(共享同一 sqlite Connection 不能跨线程移动)。原版本是 N 个 DB + // 串行 await,活跃账号上 N 个分片要轮 N 次磁盘 IO;现在 JoinSet 把它们一次 + // 全部 dispatch 到 blocking pool,整体 latency 退化为单 DB 慢路径。 + let kw = keyword.to_string(); + let mut join_set: tokio::task::JoinSet)>> = + tokio::task::JoinSet::new(); + for (db_path, table_list) in by_path { + let Some(rel_key) = path_to_rel_key.get(&db_path).cloned() else { + continue; + }; + let Some((conn, _)) = db.open_query_conn(&rel_key).await? else { + continue; + }; + let kw2 = kw.clone(); + let since2 = since; + let until2 = until; + let limit2 = limit * 3; + let names_map2 = names.map.clone(); + let group_nicknames_by_chat2 = Arc::clone(&group_nicknames_by_chat); + let db_path_for_log = db_path.clone(); + + join_set.spawn_blocking(move || { + let mut all = Vec::new(); + let empty_group_nicknames = HashMap::new(); + for (tname, display, uname) in &table_list { + let is_group = uname.contains("@chatroom"); + let group_nicknames = group_nicknames_by_chat2 + .get(uname) + .unwrap_or(&empty_group_nicknames); + match search_in_table( + &conn, + tname, + &uname, + is_group, + &names_map2, + group_nicknames, + &kw2, + since2, + until2, + msg_type, + limit2, + ) { + Ok(rows) => { + for mut row in rows { + if row + .get("chat") + .map(|v| v.as_str().unwrap_or("")) + .unwrap_or("") + .is_empty() + { + if let Some(obj) = row.as_object_mut() { + obj.insert( + "chat".into(), + serde_json::Value::String(if display.is_empty() { + tname.clone() + } else { + display.clone() + }), + ); + } + } + all.push(row); + } + } + Err(e) => eprintln!( + "[search] skip table {} (db={}): {}", + tname, db_path_for_log, e + ), + } + } + Ok((db_path_for_log, all)) + }); + } + + let mut results: Vec = Vec::new(); + let mut hit_rel_keys: HashSet = HashSet::new(); + while let Some(joined) = join_set.join_next().await { + match joined { + Ok(Ok((db_path, rows))) => { + if !rows.is_empty() { + if let Some(rel_key) = path_to_rel_key.get(&db_path) { + hit_rel_keys.insert(rel_key.clone()); + } + } + results.extend(rows) + } + Ok(Err(e)) => eprintln!("[search] skip DB: {}", e), + Err(e) => eprintln!("[search] task error: {}", e), + } + } + + results.sort_by_key(|r| std::cmp::Reverse(r["timestamp"].as_i64().unwrap_or(0))); + let paged: Vec = results.into_iter().take(limit).collect(); + let unknown_shards = current_unknown_shards(db, names); + // 全局搜索 / keyword 过滤天然是窗口化结果,没有稳定的 chat-level latest baseline, + // 不参与 stale 推导;这里只保留 unknown_shards 这类 daemon 全局健康信号。 + let meta = meta_for_global_query( + scanned_rel_keys.len(), + hit_rel_keys.len(), + unknown_shards, + true, + with_meta, + debug_source, + Some(cache_modes), + Some(shard_paths), + ); + Ok(json!({ "keyword": keyword, "count": paged.len(), "results": paged, "meta": meta })) +} + +/// 查询联系人 +/// +/// 只返回真实联系人(`chat_type_of == "private"`)。`names.map` 是从 `contact` 表 +/// 全量加载的,里面同时包含群(`@chatroom`)、公众号(`gh_*` / `biz_*` / verify_flag != 0)、 +/// 折叠入口(`brandsessionholder` / `@placeholder_foldgroup`)以及微信内部 `@xxx` 系统账号。 +/// 这些都不应该出现在 `wx contacts` 输出里,统一走 `chat_type_of` 这条同样的真相判定。 +pub async fn q_contacts(names: &Names, query: Option<&str>, limit: usize) -> Result { + let mut contacts: Vec = names + .map + .iter() + .filter(|(u, _)| chat_type_of(u, names) == "private") + .map(|(u, d)| json!({ "username": u, "display": d })) + .collect(); + + if let Some(q) = query { + let low = q.to_lowercase(); + contacts.retain(|c| { + c["display"] + .as_str() + .map(|s| s.to_lowercase().contains(&low)) + .unwrap_or(false) + || c["username"] + .as_str() + .map(|s| s.to_lowercase().contains(&low)) + .unwrap_or(false) + }); + } + + contacts.sort_by(|a, b| { + a["display"] + .as_str() + .unwrap_or("") + .cmp(b["display"].as_str().unwrap_or("")) + }); + + let total = contacts.len(); + contacts.truncate(limit); + Ok(json!({ "contacts": contacts, "total": total })) +} + +// ─── 内部辅助函数 ──────────────────────────────────────────────────────────── + +fn resolve_username(chat_name: &str, names: &Names) -> Option { + if names.map.contains_key(chat_name) + || chat_name.contains("@chatroom") + || chat_name.starts_with("wxid_") + { + return Some(chat_name.to_string()); + } + let low = chat_name.to_lowercase(); + // 精确匹配显示名:排序后取第一个,保证确定性 + let mut exact: Vec<&String> = names + .map + .iter() + .filter(|(_, display)| display.to_lowercase() == low) + .map(|(uname, _)| uname) + .collect(); + exact.sort(); + if let Some(u) = exact.into_iter().next() { + return Some(u.clone()); + } + // 模糊匹配:取 display name 最短的(最精确),相同长度取字典序最小 + let mut candidates: Vec<(&String, &String)> = names + .map + .iter() + .filter(|(_, display)| display.to_lowercase().contains(&low)) + .collect(); + candidates.sort_by_key(|(uname, display)| (display.len(), uname.as_str())); + candidates + .into_iter() + .next() + .map(|(uname, _)| uname.clone()) +} + +/// 定位 talker 所在消息分片。 +/// +/// 优化点(对齐 other-wx-cli / 用户反馈): +/// 1. 加密源 mtime 降序:优先热分片,避免先解密冷库 +/// 2. `shard-meta.json` **按表** 时间窗过滤:since/until 可跳过不重叠分片 +/// 3. 打开后写回 per-table min/max create_time + Timestamp 表 +async fn find_msg_shards( + db: &DbCache, + names: &Names, + username: &str, + since: Option, + until: Option, +) -> Result<(Vec, usize)> { + let table_name = format!("Msg_{:x}", md5::compute(username.as_bytes())); + if !msg_table_re().is_match(&table_name) { + return Ok((Vec::new(), 0)); + } + + let mut meta_file = super::shard_meta::load(); + let ordered = super::shard_meta::sort_rel_keys_by_mtime(db.db_dir(), &names.msg_db_keys); + + let mut scanned = 0usize; + let mut results: Vec = Vec::new(); + let mut meta_dirty = false; + + for rel_key in &ordered { + let src_path = db + .db_dir() + .join(rel_key.replace('/', std::path::MAIN_SEPARATOR_STR)); + let src_mtime = super::shard_meta::source_mtime_ns(&src_path); + let entry = meta_file.shards.get(rel_key); + if !super::shard_meta::may_overlap(entry, src_mtime, &table_name, since, until) { + continue; + } + if entry + .map(|e| e.missing_tables.iter().any(|t| t == &table_name)) + .unwrap_or(false) + && entry + .map(|e| e.source_mtime_ns == src_mtime || e.source_mtime_ns == 0) + .unwrap_or(false) + { + // 上次确认该 talker 表不在此分片,且源文件未变 + continue; + } + + let opened = match db.open_query_conn(rel_key).await? { + Some(v) => v, + None => continue, + }; + scanned += 1; + let (conn, cache_mode) = opened; + let tname = table_name.clone(); + let stats: (bool, Option, Option, Option) = + tokio::task::spawn_blocking(move || { + let table_exists: bool = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", + [&tname], + |row| row.get::<_, i64>(0), + ) + .is_ok(); + if !table_exists { + return Ok::<_, anyhow::Error>((false, None, None, None)); + } + let max_ts: Option = conn + .query_row( + &format!("SELECT MAX(create_time) FROM [{}]", tname), + [], + |row| row.get(0), + ) + .ok() + .flatten(); + let min_ts: Option = conn + .query_row( + &format!("SELECT MIN(create_time) FROM [{}]", tname), + [], + |row| row.get(0), + ) + .ok() + .flatten(); + let shard_start: Option = conn + .query_row("SELECT timestamp FROM Timestamp LIMIT 1", [], |row| { + row.get(0) + }) + .ok(); + Ok((true, min_ts, max_ts, shard_start)) + }) + .await??; + + let (exists, min_ts, max_ts, shard_start) = stats; + if !exists { + let e = meta_file.shards.entry(rel_key.clone()).or_default(); + if !e.missing_tables.iter().any(|t| t == &table_name) { + e.missing_tables.push(table_name.clone()); + } + e.source_mtime_ns = src_mtime; + meta_dirty = true; + continue; + } + + super::shard_meta::record_open( + &mut meta_file, + rel_key, + &src_path, + &table_name, + min_ts, + max_ts, + shard_start, + ); + meta_dirty = true; + + // 打开后二次时间窗过滤(meta 可能首次建立) + if let (Some(s), Some(hi)) = (since, max_ts) { + if hi < s { + continue; + } + } + if let (Some(u), Some(lo)) = (until, min_ts.or(shard_start)) { + if lo > u { + continue; + } + } + + if let Some(ts) = max_ts { + results.push(MessageShard { + rel_key: rel_key.clone(), + path: src_path.clone(), + table: table_name.clone(), + max_ts: ts, + cache_mode, + }); + } + } + + if meta_dirty { + super::shard_meta::save(&meta_file); + } + + // 按最大时间戳降序排列(最新的优先) + results.sort_by_key(|s| std::cmp::Reverse(s.max_ts)); + Ok((results, scanned)) +} + +#[allow(dead_code)] // tests still open by path +fn query_messages( + db_path: &std::path::Path, + table: &str, + chat_username: &str, + is_group: bool, + names_map: &HashMap, + group_nicknames: &HashMap, + since: Option, + until: Option, + msg_type: Option, + limit: usize, + offset: usize, +) -> Result> { + let conn = Connection::open(db_path)?; + query_messages_conn( + &conn, + table, + chat_username, + is_group, + names_map, + group_nicknames, + since, + until, + None, + None, + msg_type, + limit, + offset, + ) +} + +fn query_messages_conn( + conn: &Connection, + table: &str, + chat_username: &str, + is_group: bool, + names_map: &HashMap, + group_nicknames: &HashMap, + since: Option, + until: Option, + // create_time < after_ts(向更旧翻页) + after_ts: Option, + // create_time > before_ts(向更新翻页) + before_ts: Option, + msg_type: Option, + limit: usize, + offset: usize, +) -> Result> { + let id2u = load_id2u(conn); + + let mut clauses: Vec = Vec::new(); + let mut params: Vec> = Vec::new(); + if let Some(s) = since { + clauses.push("create_time >= ?".into()); + params.push(Box::new(s)); + } + if let Some(u) = until { + clauses.push("create_time <= ?".into()); + params.push(Box::new(u)); + } + if let Some(a) = after_ts { + clauses.push("create_time < ?".into()); + params.push(Box::new(a)); + } + if let Some(b) = before_ts { + clauses.push("create_time > ?".into()); + params.push(Box::new(b)); + } + if let Some(t) = msg_type { + push_msg_type_filter(&mut clauses, &mut params, t); + } + let where_clause = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + + let sql = format!( + "SELECT local_id, local_type, create_time, real_sender_id, + message_content, WCDB_CT_message_content + FROM [{}] {} ORDER BY create_time DESC LIMIT ? OFFSET ?", + table, where_clause + ); + + params.push(Box::new(limit as i64)); + params.push(Box::new(offset as i64)); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt + .query_map(params_ref.as_slice(), |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + get_content_bytes(row, 4), + row.get::<_, i64>(5).unwrap_or(0), + )) + })? + .filter_map(|r| r.ok()) + .collect::>(); + + let mut result = Vec::new(); + for (local_id, local_type, ts, real_sender_id, content_bytes, ct) in rows { + let content = decompress_message(&content_bytes, ct); + let sender_username = sender_username(real_sender_id, &content, is_group, chat_username, &id2u); + let sender = sender_label( + real_sender_id, + &content, + is_group, + chat_username, + &id2u, + names_map, + group_nicknames, + ); + let text = fmt_content(local_id, local_type, &content, is_group); + let url = appmsg_url_for_message(local_type, &content); + + let mut msg = json!({ + "timestamp": ts, + "time": fmt_time(ts, "%Y-%m-%d %H:%M"), + "sender": sender, + "content": text, + "type": fmt_type(local_type), + "local_id": local_id, + }); + attach_type_fields(&mut msg, local_type, &content); + add_sender_identity(&mut msg, is_group, &sender_username, names_map, group_nicknames); + if let Some(u) = url { + msg["url"] = serde_json::Value::String(u); + } + result.push(msg); + } + Ok(result) +} + +fn search_in_table( + conn: &Connection, + table: &str, + chat_username: &str, + is_group: bool, + names_map: &HashMap, + group_nicknames: &HashMap, + keyword: &str, + since: Option, + until: Option, + msg_type: Option, + limit: usize, +) -> Result> { + let id2u = load_id2u(conn); + // 转义 LIKE 通配符,使用 '\' 作为 ESCAPE 字符 + let escaped_kw = keyword + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + let search_decoded_content = msg_type == Some(49); + let keyword_lower = keyword.to_lowercase(); + let mut clauses: Vec = Vec::new(); + let mut params: Vec> = Vec::new(); + if !search_decoded_content { + clauses.push("message_content LIKE ? ESCAPE '\\'".to_string()); + params.push(Box::new(format!("%{}%", escaped_kw))); + } + if let Some(s) = since { + clauses.push("create_time >= ?".into()); + params.push(Box::new(s)); + } + if let Some(u) = until { + clauses.push("create_time <= ?".into()); + params.push(Box::new(u)); + } + if let Some(t) = msg_type { + push_msg_type_filter(&mut clauses, &mut params, t); + } + let where_clause = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + let limit_clause = if search_decoded_content { + "" + } else { + " LIMIT ?" + }; + let sql = format!( + "SELECT local_id, local_type, create_time, real_sender_id, + message_content, WCDB_CT_message_content + FROM [{}] {} ORDER BY create_time DESC{}", + table, where_clause, limit_clause + ); + if !search_decoded_content { + params.push(Box::new(limit as i64)); + } + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt + .query_map(params_ref.as_slice(), |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + get_content_bytes(row, 4), + row.get::<_, i64>(5).unwrap_or(0), + )) + })? + .filter_map(|r| r.ok()) + .collect::>(); + + let mut result = Vec::new(); + for (local_id, local_type, ts, real_sender_id, content_bytes, ct) in rows { + let content = decompress_message(&content_bytes, ct); + let sender_username = sender_username(real_sender_id, &content, is_group, chat_username, &id2u); + let sender = sender_label( + real_sender_id, + &content, + is_group, + chat_username, + &id2u, + names_map, + group_nicknames, + ); + let text = fmt_content(local_id, local_type, &content, is_group); + if search_decoded_content && !matches_search_text(&content, &text, keyword, &keyword_lower) + { + continue; + } + let url = appmsg_url_for_message(local_type, &content); + + let mut msg = json!({ + "timestamp": ts, + "time": fmt_time(ts, "%Y-%m-%d %H:%M"), + "chat": "", + "sender": sender, + "content": text, + "type": fmt_type(local_type), + }); + attach_type_fields(&mut msg, local_type, &content); + add_sender_identity(&mut msg, is_group, &sender_username, names_map, group_nicknames); + if let Some(u) = url { + msg["url"] = serde_json::Value::String(u); + } + result.push(msg); + if search_decoded_content && result.len() >= limit { + break; + } + } + Ok(result) +} + +fn push_msg_type_filter( + clauses: &mut Vec, + params: &mut Vec>, + msg_type: i64, +) { + clauses.push("(local_type & 4294967295) = ?".into()); + params.push(Box::new(msg_type)); +} + +fn matches_search_text(raw: &str, formatted: &str, keyword: &str, keyword_lower: &str) -> bool { + contains_search_text(raw, keyword, keyword_lower) + || contains_search_text(formatted, keyword, keyword_lower) +} + +fn contains_search_text(haystack: &str, keyword: &str, keyword_lower: &str) -> bool { + haystack.contains(keyword) + || (!keyword_lower.is_empty() && haystack.to_lowercase().contains(keyword_lower)) +} + +fn load_id2u(conn: &Connection) -> HashMap { + let mut map = HashMap::new(); + if let Ok(mut stmt) = conn.prepare("SELECT rowid, user_name FROM Name2Id") { + let _ = stmt + .query_map([], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + }) + .map(|rows| { + for r in rows.flatten() { + map.insert(r.0, r.1); + } + }); + } + map +} + +async fn load_group_nicknames( + db: &DbCache, + chat_username: &str, +) -> Result> { + if !chat_username.contains("@chatroom") { + return Ok(HashMap::new()); + } + let Some(contact_p) = db.get("contact/contact.db").await? else { + return Ok(HashMap::new()); + }; + let chat = chat_username.to_string(); + tokio::task::spawn_blocking(move || { + let conn = Connection::open(&contact_p)?; + Ok::<_, anyhow::Error>(load_group_nickname_map_from_conn(&conn, &chat, None)) + }) + .await? +} + +async fn load_group_nickname_maps( + db: &DbCache, + chat_usernames: HashSet, +) -> Result>> { + if chat_usernames.is_empty() { + return Ok(HashMap::new()); + } + let Some(contact_p) = db.get("contact/contact.db").await? else { + return Ok(HashMap::new()); + }; + tokio::task::spawn_blocking(move || { + let conn = Connection::open(&contact_p)?; + let mut out = HashMap::new(); + for chat in chat_usernames { + let nicknames = load_group_nickname_map_from_conn(&conn, &chat, None); + if !nicknames.is_empty() { + out.insert(chat, nicknames); + } + } + Ok::<_, anyhow::Error>(out) + }) + .await? +} + +fn load_group_nickname_map_from_conn( + conn: &Connection, + chat_username: &str, + targets: Option<&HashSet>, +) -> HashMap { + if !chat_username.contains("@chatroom") { + return HashMap::new(); + } + let ext = load_group_ext_buffer(conn, chat_username); + + let owned_targets = if targets.is_none() { + load_group_member_username_set(conn, chat_username) + } else { + None + }; + let targets = targets.or(owned_targets.as_ref()); + + ext.as_deref() + .map(|buf| parse_group_nickname_map(buf, targets)) + .unwrap_or_default() +} + +fn load_group_ext_buffer(conn: &Connection, chat_username: &str) -> Option> { + [ + "SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1", + "SELECT ext_buffer FROM chat_room WHERE chat_room_name = ? LIMIT 1", + "SELECT ext_buffer FROM chat_room WHERE name = ? LIMIT 1", + ] + .iter() + .find_map(|sql| { + conn.query_row(sql, [chat_username], |row| row.get::<_, Option>>(0)) + .ok() + .flatten() + }) +} + +fn load_group_member_username_set( + conn: &Connection, + chat_username: &str, +) -> Option> { + let room_id: i64 = [ + "SELECT id FROM chat_room WHERE username = ?", + "SELECT id FROM chat_room WHERE chat_room_name = ?", + "SELECT id FROM chat_room WHERE name = ?", + ] + .iter() + .find_map(|sql| { + conn.query_row(sql, [chat_username], |row| row.get::<_, i64>(0)) + .ok() + }) + .unwrap_or(0); + + if room_id == 0 { + return None; + } + + let mut stmt = conn + .prepare( + "SELECT c.username + FROM chatroom_member cm + LEFT JOIN contact c ON c.id = cm.member_id + WHERE cm.room_id = ?", + ) + .ok()?; + let usernames: HashSet = stmt + .query_map([room_id], |row| row.get::<_, String>(0)) + .ok()? + .filter_map(|r| r.ok()) + .filter(|uid| !uid.is_empty()) + .collect(); + + if usernames.is_empty() { + None + } else { + Some(usernames) + } +} + +fn decode_proto_varint(raw: &[u8], offset: usize) -> Option<(u64, usize)> { + let mut value = 0u64; + let mut shift = 0u32; + let mut pos = offset; + while pos < raw.len() { + let byte = raw[pos]; + pos += 1; + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Some((value, pos)); + } + shift += 7; + if shift > 63 { + return None; + } + } + None +} + +fn proto_len_fields<'a>(raw: &'a [u8]) -> Vec<(u64, &'a [u8])> { + let mut fields = Vec::new(); + let mut idx = 0usize; + while idx < raw.len() { + let Some((tag, next)) = decode_proto_varint(raw, idx) else { + break; + }; + if next <= idx { + break; + } + idx = next; + let field_no = tag >> 3; + let wire_type = tag & 0x07; + match wire_type { + 0 => { + let Some((_, next)) = decode_proto_varint(raw, idx) else { + break; + }; + if next <= idx { + break; + } + idx = next; + } + 1 => { + let Some(next) = idx.checked_add(8) else { + break; + }; + if next > raw.len() { + break; + } + idx = next; + } + 2 => { + let Some((size, next)) = decode_proto_varint(raw, idx) else { + break; + }; + if next <= idx { + break; + } + idx = next; + let Ok(size) = usize::try_from(size) else { + break; + }; + let Some(end) = idx.checked_add(size) else { + break; + }; + if end > raw.len() { + break; + } + fields.push((field_no, &raw[idx..end])); + idx = end; + } + 5 => { + let Some(next) = idx.checked_add(4) else { + break; + }; + if next > raw.len() { + break; + } + idx = next; + } + _ => break, + } + } + fields +} + +fn proto_string_fields(raw: &[u8]) -> Vec<(u64, String)> { + proto_len_fields(raw) + .into_iter() + .filter_map(|(field_no, value)| { + if value.is_empty() || value.len() > 256 { + return None; + } + let text = std::str::from_utf8(value).ok()?.trim().to_string(); + if text.is_empty() || text.chars().any(char::is_control) { + return None; + } + Some((field_no, text)) + }) + .collect() +} + +fn is_strong_username_hint(value: &str) -> bool { + value.starts_with("wxid_") + || value.ends_with("@chatroom") + || value.starts_with("gh_") + || value.contains('@') +} + +fn looks_like_username(value: &str) -> bool { + let value = value.trim(); + if value.is_empty() { + return false; + } + if is_strong_username_hint(value) { + return true; + } + if value.len() < 6 || value.len() > 32 || value.chars().any(char::is_whitespace) { + return false; + } + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + first.is_ascii_alphabetic() && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +fn pick_member_username( + strings: &[(u64, String)], + targets: Option<&HashSet>, +) -> Option { + if let Some(targets) = targets { + return strings + .iter() + .find(|(_, value)| targets.contains(value)) + .map(|(_, value)| value.clone()); + } + + for field_no in [1u64, 4u64] { + if let Some((_, value)) = strings + .iter() + .find(|(f, value)| *f == field_no && looks_like_username(value)) + { + return Some(value.clone()); + } + } + + strings + .iter() + .find(|(_, value)| is_strong_username_hint(value)) + .or_else(|| strings.iter().find(|(_, value)| looks_like_username(value))) + .map(|(_, value)| value.clone()) +} + +fn pick_group_nickname(strings: &[(u64, String)], username: &str) -> Option { + let mut best_score = i64::MIN; + let mut best = String::new(); + + for (idx, (field_no, value)) in strings.iter().enumerate() { + // In current WeChat 4.x ext_buffer member chunks, field 2 is the group + // card/nickname. Field 4 is often another username-like value such as an + // inviter/owner and must not be promoted to a nickname. + if *field_no != 2 { + continue; + } + let value = value.trim(); + if value.is_empty() + || value == username + || is_strong_username_hint(value) + || value.contains('\n') + || value.contains('\r') + || value.len() > 64 + { + continue; + } + + let mut score = 0i64; + if !looks_like_username(value) { + score += 20; + } + score += (32usize.saturating_sub(value.len())) as i64; + score = score * 1000 - idx as i64; + + if score > best_score { + best_score = score; + best = value.to_string(); + } + } + + if best.is_empty() { + None + } else { + Some(best) + } +} + +fn parse_group_nickname_map( + ext_buffer: &[u8], + targets: Option<&HashSet>, +) -> HashMap { + let mut out = HashMap::new(); + if ext_buffer.is_empty() { + return out; + } + + for (_, chunk) in proto_len_fields(ext_buffer) { + let strings = proto_string_fields(chunk); + if strings.is_empty() { + continue; + } + let Some(username) = pick_member_username(&strings, targets) else { + continue; + }; + if out.contains_key(&username) { + continue; + } + if let Some(nickname) = pick_group_nickname(&strings, &username) { + out.insert(username, nickname); + } + } + + out +} + +fn contact_display( + uid: &str, + nick: &str, + remark: &str, + names_map: &HashMap, +) -> String { + if !remark.is_empty() { + remark.to_string() + } else if !nick.is_empty() { + nick.to_string() + } else { + names_map + .get(uid) + .cloned() + .unwrap_or_else(|| uid.to_string()) + } +} + +fn sender_display( + username: &str, + fallback_sender_name: &str, + names: &HashMap, + group_nicknames: &HashMap, +) -> String { + if username.is_empty() { + return String::new(); + } + group_nicknames + .get(username) + .filter(|s| !s.is_empty()) + .cloned() + .or_else(|| names.get(username).cloned()) + .or_else(|| { + if fallback_sender_name.is_empty() { + None + } else { + Some(fallback_sender_name.to_string()) + } + }) + .unwrap_or_else(|| username.to_string()) +} + +fn group_top_senders( + sender_counts: &HashMap, + names: &HashMap, + group_nicknames: &HashMap, + limit: usize, +) -> Vec { + let mut top_senders: Vec = sender_counts + .iter() + .map(|(username, count)| { + let mut row = json!({ + "sender": sender_display(username, "", names, group_nicknames), + "count": count, + }); + add_sender_identity(&mut row, true, username, names, group_nicknames); + row + }) + .collect(); + top_senders.sort_by(|a, b| { + b["count"] + .as_i64() + .unwrap_or(0) + .cmp(&a["count"].as_i64().unwrap_or(0)) + .then_with(|| { + a["sender"] + .as_str() + .unwrap_or("") + .cmp(b["sender"].as_str().unwrap_or("")) + }) + }); + top_senders.truncate(limit); + top_senders +} + +fn sender_username( + real_sender_id: i64, + content: &str, + is_group: bool, + chat_username: &str, + id2u: &HashMap, +) -> String { + let sender_uname = id2u.get(&real_sender_id).cloned().unwrap_or_default(); + if !is_group { + if !sender_uname.is_empty() && sender_uname != chat_username { + return sender_uname; + } + return String::new(); + } + if !sender_uname.is_empty() && sender_uname != chat_username { + return sender_uname; + } + if content.contains(":\n") { + return content.splitn(2, ":\n").next().unwrap_or("").to_string(); + } + String::new() +} + +fn add_sender_identity( + row: &mut Value, + is_group: bool, + username: &str, + names: &HashMap, + group_nicknames: &HashMap, +) { + if !is_group || username.is_empty() { + return; + } + row["sender_username"] = Value::String(username.to_string()); + row["sender_contact_display"] = Value::String( + names.get(username).cloned().unwrap_or_else(|| username.to_string()) + ); + row["sender_group_nickname"] = Value::String( + group_nicknames.get(username).cloned().unwrap_or_default() + ); +} + +fn sender_label( + real_sender_id: i64, + content: &str, + is_group: bool, + chat_username: &str, + id2u: &HashMap, + names: &HashMap, + group_nicknames: &HashMap, +) -> String { + let sender_uname = id2u.get(&real_sender_id).cloned().unwrap_or_default(); + if is_group { + if !sender_uname.is_empty() && sender_uname != chat_username { + return sender_display(&sender_uname, "", names, group_nicknames); + } + if content.contains(":\n") { + let raw = content.splitn(2, ":\n").next().unwrap_or(""); + return sender_display(raw, "", names, group_nicknames); + } + return String::new(); + } + if !sender_uname.is_empty() && sender_uname != chat_username { + return names.get(&sender_uname).cloned().unwrap_or(sender_uname); + } + String::new() +} + +/// 读取消息内容列(兼容 TEXT 和 BLOB 两种存储类型) +/// +/// SQLite 中 message_content 在未压缩时为 TEXT,zstd 压缩后为 BLOB。 +/// rusqlite 的 Vec FromSql 只接受 BLOB,读 TEXT 会静默返回空。 +fn get_content_bytes(row: &rusqlite::Row<'_>, idx: usize) -> Vec { + // 先尝试 BLOB,再 fallback 到 TEXT→bytes + row.get::<_, Vec>(idx) + .or_else(|_| row.get::<_, String>(idx).map(|s| s.into_bytes())) + .unwrap_or_default() +} + +fn decompress_message(data: &[u8], ct: i64) -> String { + if ct == 4 && !data.is_empty() { + // zstd 压缩 + if let Ok(dec) = zstd::decode_all(data) { + return String::from_utf8_lossy(&dec).into_owned(); + } + } + String::from_utf8_lossy(data).into_owned() +} + +fn decompress_or_str(data: &[u8]) -> String { + if data.is_empty() { + return String::new(); + } + // 尝试 zstd 解压 + if let Ok(dec) = zstd::decode_all(data) { + if let Ok(s) = String::from_utf8(dec) { + return s; + } + } + String::from_utf8_lossy(data).into_owned() +} + +fn strip_group_prefix(s: &str) -> String { + if s.contains(":\n") { + s.splitn(2, ":\n").nth(1).unwrap_or(s).to_string() + } else { + s.to_string() + } +} + +/// WeChat `local_type` 低 32 位(高位常是 WCDB/业务 flag)。 +pub fn type_code(t: i64) -> i64 { + (t as u64 & 0xFFFFFFFF) as i64 +} + +/// Agent 稳定英文 slug(与 CLI `--type` 对齐);未知类型为 `type_`。 +pub fn type_id(t: i64) -> String { + match type_code(t) { + 1 => "text".into(), + 3 => "image".into(), + 34 => "voice".into(), + 42 => "card".into(), + 43 => "video".into(), + 47 => "sticker".into(), + 48 => "location".into(), + 49 => "appmsg".into(), // 链接/文件/引用/合并… 见 appmsg_type + 50 => "call".into(), + 10000 => "system".into(), + 10002 => "revoke".into(), + n => format!("type_{}", n), + } +} + +pub fn fmt_type(t: i64) -> String { + let base = type_code(t); + match base { + 1 => "文本".into(), + 3 => "图片".into(), + 34 => "语音".into(), + 42 => "名片".into(), + 43 => "视频".into(), + 47 => "表情".into(), + 48 => "位置".into(), + 49 => "链接/文件".into(), + 50 => "通话".into(), + 10000 => "系统".into(), + 10002 => "撤回".into(), + _ => format!("type={}", base), + } +} + +/// 从 appmsg XML 提取 `` 子类型(57=引用, 6=文件, 5=链接…)。 +pub fn appmsg_type_code(content: &str) -> Option { + let xml = strip_group_prefix(content); + if !xml.contains("().ok()) + .filter(|&n| n > 0) +} + +/// 写入 Agent 友好的类型字段(不覆盖已有 `type` 显示名)。 +pub fn attach_type_fields(msg: &mut Value, local_type: i64, content: &str) { + let code = type_code(local_type); + if let Some(obj) = msg.as_object_mut() { + obj.insert("type_code".into(), json!(code)); + obj.insert("type_id".into(), json!(type_id(local_type))); + if code == 49 { + if let Some(at) = appmsg_type_code(content) { + obj.insert("appmsg_type".into(), json!(at)); + } + } + } +} + +fn fmt_content(local_id: i64, local_type: i64, content: &str, is_group: bool) -> String { + let base = (local_type as u64 & 0xFFFFFFFF) as i64; + match base { + 3 => return format!("[图片] local_id={}", local_id), + 34 => return "[语音]".into(), + 43 => return "[视频]".into(), + 47 => return "[表情]".into(), + 50 => return "[通话]".into(), + 10000 => return parse_sysmsg(content).unwrap_or_else(|| "[系统消息]".into()), + 10002 => return parse_revoke(content).unwrap_or_else(|| "[撤回了一条消息]".into()), + _ => {} + } + + let text = if is_group && content.contains(":\n") { + content.splitn(2, ":\n").nth(1).unwrap_or(content) + } else { + content + }; + + if base == 49 && text.contains("...` +fn parse_revoke(xml: &str) -> Option { + let inner = extract_xml_text(xml, "content")?; + // 有时 content 是 "xxx recalled a message" 英文,有时是中文 + if inner.is_empty() { + return Some("[撤回了一条消息]".into()); + } + // 尝试简化:如果是 XML 格式的撤回内容,直接显示摘要 + Some(format!( + "[撤回] {}", + inner.chars().take(30).collect::() + )) +} + +/// 解析系统消息 XML(群通知等) +fn parse_sysmsg(xml: &str) -> Option { + // 常见格式:... + // 尝试提取 content 标签 + if let Some(s) = extract_xml_text(xml, "content") { + if !s.is_empty() { + return Some(format!("[系统] {}", s.chars().take(50).collect::())); + } + } + // 纯文本系统消息(无 XML) + if !xml.starts_with('<') { + return Some(format!( + "[系统] {}", + xml.chars().take(50).collect::() + )); + } + Some("[系统消息]".into()) +} + +fn parse_appmsg(text: &str) -> Option { + if let Some(parsed) = parse_appmsg_dom(text) { + return Some(parsed); + } + parse_appmsg_legacy(text) +} + +fn parse_appmsg_dom(text: &str) -> Option { + let doc = Document::parse(text).ok()?; + let appmsg = doc.descendants().find(|node| node.has_tag_name("appmsg"))?; + let title = xml_text(xml_child(appmsg, "title")).unwrap_or_default(); + let atype = xml_text(xml_child(appmsg, "type")).unwrap_or_default(); + match atype.as_str() { + "6" => Some(format_file_appmsg(appmsg, &title)), + "19" => Some(format_record_appmsg(appmsg, &title)), + _ => None, + } +} + +fn parse_appmsg_legacy(text: &str) -> Option { + let title = extract_xml_text(text, "title")?; + let atype = extract_xml_text(text, "type").unwrap_or_default(); + match atype.as_str() { + "6" => Some(if !title.is_empty() { + format!("[文件] {}", title) + } else { + "[文件]".into() + }), + "57" => { + let ref_content = quote_refermsg_content(text) + .or_else(|| { + extract_xml_text(text, "content").and_then(|s| quote_content_text(&s, 40)) + }) + .unwrap_or_default(); + let quote = if !title.is_empty() { + format!("[引用] {}", title) + } else { + "[引用]".into() + }; + if !ref_content.is_empty() { + Some(format!("{}\n \u{21b3} {}", quote, ref_content)) + } else { + Some(quote) + } + } + "33" | "36" | "44" => Some(if !title.is_empty() { + format!("[小程序] {}", title) + } else { + "[小程序]".into() + }), + _ => Some(if !title.is_empty() { + format!("[链接] {}", title) + } else { + "[链接/文件]".into() + }), + } +} + +fn format_file_appmsg<'a, 'input>(appmsg: Node<'a, 'input>, title: &str) -> String { + let mut meta = Vec::new(); + if let Some(size) = xml_child(appmsg, "appattach") + .and_then(|attach| xml_text(xml_child(attach, "totallen"))) + .and_then(|value| value.parse::().ok()) + .filter(|size| *size > 0) + { + meta.push(format_byte_size(size)); + } + if let Some(ext) = xml_child(appmsg, "appattach") + .and_then(|attach| xml_text(xml_child(attach, "fileext"))) + .filter(|ext| !ext.is_empty()) + { + meta.push(ext); + } + + let base = if !title.is_empty() { + format!("[文件] {}", title) + } else { + "[文件]".into() + }; + if meta.is_empty() { + base + } else { + format!("{} ({})", base, meta.join(", ")) + } +} + +fn format_record_appmsg<'a, 'input>(appmsg: Node<'a, 'input>, title: &str) -> String { + let items = record_item_lines(appmsg); + let mut header = if !title.is_empty() { + format!("[合并聊天记录] {}", title) + } else { + "[合并聊天记录]".into() + }; + if !items.is_empty() { + header.push_str(&format!(" ({}条)", items.len())); + } + + let mut lines = vec![header]; + if items.is_empty() { + if let Some(desc) = xml_text(xml_child(appmsg, "des")).filter(|desc| !desc.is_empty()) { + lines.push(format!(" {}", collapse_text(&desc, 120))); + } + } else { + for item in items.iter().take(10) { + lines.push(format!(" - {}", item)); + } + if items.len() > 10 { + lines.push(format!(" - ... 还有{}条", items.len() - 10)); + } + } + lines.join("\n") +} + +fn record_item_lines<'a, 'input>(appmsg: Node<'a, 'input>) -> Vec { + let mut lines = record_item_lines_from_node(appmsg); + if !lines.is_empty() { + return lines; + } + + let Some(record_xml) = + xml_text(xml_child(appmsg, "recorditem")).filter(|value| !value.is_empty()) + else { + return Vec::new(); + }; + let unescaped = unescape_html(&record_xml); + for candidate in [&record_xml, &unescaped] { + if let Ok(doc) = Document::parse(candidate) { + lines = record_item_lines_from_node(doc.root_element()); + if !lines.is_empty() { + break; + } + } + } + lines +} + +fn record_item_lines_from_node<'a, 'input>(node: Node<'a, 'input>) -> Vec { + node.descendants() + .filter(|child| child.has_tag_name("dataitem")) + .filter_map(format_record_item) + .collect() +} + +fn format_record_item<'a, 'input>(item: Node<'a, 'input>) -> Option { + let name = first_child_text(item, &["sourcename", "datasrcname", "sourceusername"]); + let desc = first_child_text(item, &["datadesc", "datatitle", "datafmt"]).or_else(|| { + item.attribute("datatype") + .and_then(record_datatype_label) + .map(str::to_string) + })?; + let desc = collapse_text(&desc, 100); + if let Some(name) = name.filter(|value| !value.is_empty()) { + Some(format!("{}: {}", name, desc)) + } else { + Some(desc) + } +} + +fn first_child_text<'a, 'input>(node: Node<'a, 'input>, tags: &[&str]) -> Option { + tags.iter() + .find_map(|tag| xml_text(xml_child(node, tag))) + .filter(|value| !value.is_empty()) +} + +fn record_datatype_label(datatype: &str) -> Option<&'static str> { + match datatype { + "1" => Some("[文本]"), + "2" => Some("[图片]"), + "3" => Some("[语音]"), + "4" => Some("[视频]"), + "6" => Some("[文件]"), + "17" => Some("[链接]"), + _ => None, + } +} + +fn quote_refermsg_content(text: &str) -> Option { + let refer = extract_xml_text(text, "refermsg")?; + let content = extract_xml_text(&refer, "content") + .and_then(|s| quote_content_text(&s, 80)) + .or_else(|| { + extract_xml_text(&refer, "type") + .and_then(|t| quote_refermsg_type_label(&t).map(str::to_string)) + })?; + match extract_xml_text(&refer, "displayname") { + Some(name) if !name.is_empty() => Some(format!("{}: {}", name, content)), + _ => Some(content), + } +} + +fn quote_content_text(raw: &str, max_chars: usize) -> Option { + let unescaped = unescape_html(raw); + if unescaped.contains(" Option<&'static str> { + match t { + "1" => None, + "3" => Some("[图片]"), + "34" => Some("[语音]"), + "43" => Some("[视频]"), + "47" => Some("[表情]"), + "49" => Some("[链接/文件]"), + _ => None, + } +} + +fn collapse_text(text: &str, max_chars: usize) -> String { + let collapsed = text.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() > max_chars { + format!( + "{}...", + collapsed.chars().take(max_chars).collect::() + ) + } else { + collapsed + } +} + +fn format_byte_size(bytes: u64) -> String { + const KB: f64 = 1024.0; + const MB: f64 = KB * 1024.0; + const GB: f64 = MB * 1024.0; + let bytes_f = bytes as f64; + if bytes_f >= GB { + format_decimal_unit(bytes_f / GB, "GB") + } else if bytes_f >= MB { + format_decimal_unit(bytes_f / MB, "MB") + } else if bytes_f >= KB { + format_decimal_unit(bytes_f / KB, "KB") + } else { + format!("{} B", bytes) + } +} + +fn format_decimal_unit(value: f64, unit: &str) -> String { + let mut s = format!("{:.1}", value); + if s.ends_with(".0") { + s.truncate(s.len() - 2); + } + format!("{} {}", s, unit) +} + +fn extract_xml_text(xml: &str, tag: &str) -> Option { + let open = format!("<{}>", tag); + let close = format!("", tag); + let start = xml.find(&open)?; + let content_start = start + open.len(); + let end = xml[content_start..].find(&close)?; + Some(xml[content_start..content_start + end].trim().to_string()) +} + +fn appmsg_url_for_message(local_type: i64, content: &str) -> Option { + if (local_type as u64 & 0xFFFFFFFF) != 49 { + return None; + } + extract_appmsg_url(content) +} + +fn extract_favorite_url(content: &str) -> Option { + let url = extract_xml_text(content, "link").map(|s| unescape_html(strip_xml_cdata(&s)))?; + if url.is_empty() || !(url.starts_with("http://") || url.starts_with("https://")) { + return None; + } + Some(url) +} + +fn strip_xml_cdata(s: &str) -> &str { + s.strip_prefix("")) + .unwrap_or(s) +} + +/// 从 appmsg XML 中提取链接 URL(优先取 ,fallback 到 ) +fn extract_appmsg_url(text: &str) -> Option { + let xml = strip_group_prefix(text); + if !xml.contains(" Option { + let open = format!("<{}", tag); + let start = xml.find(&open)?; + let tag_end = start + xml[start..].find('>')?; + let attr_pat = format!(r#"{}=""#, attr); + let attr_start = start + xml[start..tag_end].find(&attr_pat)? + attr_pat.len(); + let attr_end = attr_start + xml[attr_start..tag_end].find('"')?; + let value = xml[attr_start..attr_end].trim(); + if value.is_empty() { + None + } else { + Some(value.to_string()) + } +} + +fn unescape_html(s: &str) -> String { + s.replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") +} + +#[cfg(test)] +mod type_fields_tests { + use super::{appmsg_type_code, attach_type_fields, type_code, type_id}; + use serde_json::json; + + #[test] + fn type_code_masks_high_bits() { + // high flags set (common WCDB pattern) + let raw = 1i64 | (1i64 << 32); + assert_eq!(type_code(raw), 1); + assert_eq!(type_id(raw), "text"); + } + + #[test] + fn type_id_matches_cli_slugs() { + assert_eq!(type_id(49), "appmsg"); + assert_eq!(type_id(10002), "revoke"); + assert_eq!(type_id(999), "type_999"); + } + + #[test] + fn appmsg_type_from_xml() { + let xml = r#"57hi"#; + assert_eq!(appmsg_type_code(xml), Some(57)); + assert_eq!(appmsg_type_code("plain text"), None); + } + + #[test] + fn attach_type_fields_sets_agent_keys() { + let mut msg = json!({"type": "链接/文件"}); + let xml = r#"wxid_x:\n6"#; + attach_type_fields(&mut msg, 49, xml); + assert_eq!(msg["type_code"], 49); + assert_eq!(msg["type_id"], "appmsg"); + assert_eq!(msg["appmsg_type"], 6); + } +} + +#[cfg(test)] +mod timeline_helpers_tests { + use super::{ + history_can_early_stop, timeline_can_early_stop, timeline_per_chat, timeline_session_cap, + timeline_top_needed_min_ts, + }; + + #[test] + fn history_early_stop_requires_next_shard_colder_than_heap_floor() { + // needed=2, heap full, next shard max still hot → must NOT stop + assert!(!history_can_early_stop(2, 2, Some(10), Some(99))); + // next shard strictly colder → safe + assert!(history_can_early_stop(2, 2, Some(100), Some(99))); + // no more shards → stop + assert!(history_can_early_stop(2, 2, Some(100), None)); + // heap not full → never + assert!(!history_can_early_stop(3, 2, Some(100), Some(1))); + } + + #[test] + fn session_cap_bounds_and_scales_with_needed() { + assert_eq!(timeline_session_cap(1), 8); + assert_eq!(timeline_session_cap(10), 20); + assert_eq!(timeline_session_cap(1000), 48); + assert!(timeline_session_cap(50) <= 48); + assert!(timeline_session_cap(50) >= 8); + } + + #[test] + fn per_chat_bounds() { + assert_eq!(timeline_per_chat(1), 10); + assert_eq!(timeline_per_chat(40), 20); + assert_eq!(timeline_per_chat(1000), 80); + } + + #[test] + fn top_needed_min_ts_requires_full_heap() { + let ts = [100, 90, 80, 70]; + assert_eq!(timeline_top_needed_min_ts(&ts, 3), Some(80)); + assert_eq!(timeline_top_needed_min_ts(&ts, 5), None); + assert_eq!(timeline_top_needed_min_ts(&[], 1), None); + } + + #[test] + fn early_stop_only_when_heap_full_and_remaining_colder() { + assert!(!timeline_can_early_stop(10, 5, Some(100), 50)); + assert!(!timeline_can_early_stop(10, 10, None, 50)); + // remaining session still as hot as heap min → cannot stop + assert!(!timeline_can_early_stop(10, 10, Some(100), 100)); + // remaining strictly colder than heap floor → stop + assert!(timeline_can_early_stop(10, 10, Some(100), 99)); + } +} + +#[cfg(test)] +mod appmsg_tests { + use super::*; + + #[test] + fn parse_forwarded_chat_record_expands_record_items() { + let xml = r#" + + + 群聊的聊天记录 + 张三: 早上好 +李四: 收到 + 19 + <recordinfo><datalist count="2"><dataitem datatype="1"><sourcename>张三</sourcename><sourcetime>1710000000</sourcetime><datadesc>早上好 &amp; coffee</datadesc></dataitem><dataitem datatype="2"><sourcename>李四</sourcename><sourcetime>1710000060</sourcetime><datafmt>图片</datafmt><datadesc>[图片]</datadesc></dataitem></datalist></recordinfo> + + + "#; + + assert_eq!( + parse_appmsg(xml).as_deref(), + Some( + "[合并聊天记录] 群聊的聊天记录 (2条)\n - 张三: 早上好 & coffee\n - 李四: [图片]" + ) + ); + } + + #[test] + fn parse_file_appmsg_includes_attachment_metadata() { + let xml = r#" + + + report.pdf + 6 + + 1536 + pdf + + abcdef123456 + + + "#; + + assert_eq!( + parse_appmsg(xml).as_deref(), + Some("[文件] report.pdf (1.5 KB, pdf)") + ); + } + + #[test] + fn parse_quote_appmsg_reads_refermsg_content() { + let xml = r#" + + + 我也没有用ai啊 + 57 + + + 1 + 不再熬夜 + 昨天用 claude 爬小红书数据来着 + + + + "#; + + assert_eq!( + parse_appmsg(xml).as_deref(), + Some("[引用] 我也没有用ai啊\n \u{21b3} 不再熬夜: 昨天用 claude 爬小红书数据来着") + ); + } + + #[test] + fn query_messages_filters_appmsg_by_base_type() { + let path = temp_db_path("query_messages_filters_appmsg_by_base_type"); + { + let conn = Connection::open(&path).expect("open temp db"); + conn.execute( + "CREATE TABLE Msg_test ( + local_id INTEGER, + local_type INTEGER, + create_time INTEGER, + real_sender_id INTEGER, + message_content TEXT, + WCDB_CT_message_content INTEGER + )", + [], + ) + .expect("create message table"); + conn.execute( + "INSERT INTO Msg_test VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + 1_i64, + ((57_i64) << 32) | 49_i64, + 1775146911_i64, + 0_i64, + r#"我也没有用ai啊57不再熬夜昨天用 claude 爬小红书数据来着"#, + 0_i64 + ], + ) + .expect("insert quote message"); + } + + let rows = query_messages( + &path, + "Msg_test", + "wxid_r605h38n08mv22", + false, + &HashMap::new(), + &HashMap::new(), + None, + None, + Some(49), + 10, + 0, + ) + .expect("query messages"); + + let _ = std::fs::remove_file(&path); + + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0]["content"].as_str(), + Some("[引用] 我也没有用ai啊\n \u{21b3} 不再熬夜: 昨天用 claude 爬小红书数据来着") + ); + } + + #[test] + fn query_messages_includes_stable_group_sender_identity() { + let path = temp_db_path("query_messages_includes_stable_group_sender_identity"); + { + let conn = Connection::open(&path).expect("open temp db"); + conn.execute( + "CREATE TABLE Name2Id ( + user_name TEXT + )", + [], + ) + .expect("create Name2Id table"); + conn.execute( + "INSERT INTO Name2Id(rowid, user_name) VALUES (?1, ?2)", + rusqlite::params![42_i64, "wxid_alice"], + ) + .expect("insert Name2Id row"); + conn.execute( + "CREATE TABLE Msg_test ( + local_id INTEGER, + local_type INTEGER, + create_time INTEGER, + real_sender_id INTEGER, + message_content TEXT, + WCDB_CT_message_content INTEGER + )", + [], + ) + .expect("create message table"); + conn.execute( + "INSERT INTO Msg_test VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + 1_i64, + 1_i64, + 1775146911_i64, + 42_i64, + "hello", + 0_i64 + ], + ) + .expect("insert text message"); + } + + let names = HashMap::from([("wxid_alice".to_string(), "Alice Contact".to_string())]); + let group_nicknames = HashMap::from([("wxid_alice".to_string(), "同名".to_string())]); + let rows = query_messages( + &path, + "Msg_test", + "123@chatroom", + true, + &names, + &group_nicknames, + None, + None, + None, + 10, + 0, + ) + .expect("query messages"); + + let _ = std::fs::remove_file(&path); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["sender"].as_str(), Some("同名")); + assert_eq!(rows[0]["sender_username"].as_str(), Some("wxid_alice")); + assert_eq!(rows[0]["sender_contact_display"].as_str(), Some("Alice Contact")); + assert_eq!(rows[0]["sender_group_nickname"].as_str(), Some("同名")); + } + + #[test] + fn search_in_table_includes_stable_group_sender_identity() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute( + "CREATE TABLE Name2Id ( + user_name TEXT + )", + [], + ) + .expect("create Name2Id table"); + conn.execute( + "INSERT INTO Name2Id(rowid, user_name) VALUES (?1, ?2)", + rusqlite::params![42_i64, "wxid_alice"], + ) + .expect("insert Name2Id row"); + conn.execute( + "CREATE TABLE Msg_test ( + local_id INTEGER, + local_type INTEGER, + create_time INTEGER, + real_sender_id INTEGER, + message_content TEXT, + WCDB_CT_message_content INTEGER + )", + [], + ) + .expect("create message table"); + conn.execute( + "INSERT INTO Msg_test VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![1_i64, 1_i64, 1775146911_i64, 42_i64, "needle", 0_i64], + ) + .expect("insert text message"); + + let names = HashMap::from([("wxid_alice".to_string(), "Alice Contact".to_string())]); + let group_nicknames = HashMap::from([("wxid_alice".to_string(), "同名".to_string())]); + let rows = search_in_table( + &conn, + "Msg_test", + "123@chatroom", + true, + &names, + &group_nicknames, + "needle", + None, + None, + None, + 10, + ) + .expect("search messages"); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["sender"].as_str(), Some("同名")); + assert_eq!(rows[0]["sender_username"].as_str(), Some("wxid_alice")); + assert_eq!(rows[0]["sender_contact_display"].as_str(), Some("Alice Contact")); + assert_eq!(rows[0]["sender_group_nickname"].as_str(), Some("同名")); + } + + /// q_attachments 是异步 + 依赖 DbCache,无法直接 unit-test 整条 pipeline。 + /// 这里锁住 attachment row 复用 `add_sender_identity` 后的最终 JSON 形状: + /// 两个 group nickname 同为 "同名" 的成员,attachment 行可以通过 sender_username 区分。 + #[test] + fn attachment_row_gets_stable_group_sender_identity_via_helper() { + let names: HashMap = HashMap::from([ + ("wxid_alice".to_string(), "Alice Contact".to_string()), + ("wxid_bob".to_string(), "Bob Contact".to_string()), + ]); + let group_nicknames: HashMap = HashMap::from([ + ("wxid_alice".to_string(), "同名".to_string()), + ("wxid_bob".to_string(), "同名".to_string()), + ]); + + let mut alice_row = json!({ + "attachment_id": "abc", + "kind": "image", + "type": "Image", + "local_id": 1, + "timestamp": 1775146911, + "time": "2026-04-30 12:00", + "sender": "同名", + }); + add_sender_identity(&mut alice_row, true, "wxid_alice", &names, &group_nicknames); + assert_eq!(alice_row["sender"].as_str(), Some("同名")); + assert_eq!(alice_row["sender_username"].as_str(), Some("wxid_alice")); + assert_eq!(alice_row["sender_contact_display"].as_str(), Some("Alice Contact")); + assert_eq!(alice_row["sender_group_nickname"].as_str(), Some("同名")); + + let mut bob_row = json!({ + "attachment_id": "def", + "kind": "image", + "type": "Image", + "local_id": 2, + "timestamp": 1775146922, + "time": "2026-04-30 12:00", + "sender": "同名", + }); + add_sender_identity(&mut bob_row, true, "wxid_bob", &names, &group_nicknames); + assert_eq!(bob_row["sender_username"].as_str(), Some("wxid_bob")); + // 同样 sender_group_nickname 都是 "同名",但 sender_username 能区分 + assert_ne!( + alice_row["sender_username"], bob_row["sender_username"], + "sender_username 必须区分两位同名成员" + ); + + // 非群 chat 不该追加 identity 字段(行为对齐 history/search/new-messages) + let mut private_row = json!({"attachment_id": "ghi", "sender": ""}); + add_sender_identity(&mut private_row, false, "wxid_alice", &names, &group_nicknames); + assert!(private_row.get("sender_username").is_none()); + assert!(private_row.get("sender_contact_display").is_none()); + assert!(private_row.get("sender_group_nickname").is_none()); + + // group 但 sender_username 解析为空(非常老的格式、id2u 没命中、content 也没 wxid_xxx:\n 前缀): + // 不要伪造空字段,整段 identity 也不追加 + let mut unknown_row = json!({"attachment_id": "jkl", "sender": ""}); + add_sender_identity(&mut unknown_row, true, "", &names, &group_nicknames); + assert!(unknown_row.get("sender_username").is_none()); + assert!(unknown_row.get("sender_contact_display").is_none()); + assert!(unknown_row.get("sender_group_nickname").is_none()); + } + + #[test] + fn search_in_table_filters_appmsg_by_base_type() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute( + "CREATE TABLE Msg_test ( + local_id INTEGER, + local_type INTEGER, + create_time INTEGER, + real_sender_id INTEGER, + message_content TEXT, + WCDB_CT_message_content INTEGER + )", + [], + ) + .expect("create message table"); + conn.execute( + "INSERT INTO Msg_test VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + 1_i64, + ((57_i64) << 32) | 49_i64, + 1775146911_i64, + 0_i64, + r#"我也没有用ai啊57不再熬夜昨天用 claude 爬小红书数据来着"#, + 0_i64 + ], + ) + .expect("insert quote message"); + + let rows = search_in_table( + &conn, + "Msg_test", + "wxid_r605h38n08mv22", + false, + &HashMap::new(), + &HashMap::new(), + "claude", + None, + None, + Some(49), + 10, + ) + .expect("search messages"); + + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0]["content"].as_str(), + Some("[引用] 我也没有用ai啊\n \u{21b3} 不再熬夜: 昨天用 claude 爬小红书数据来着") + ); + } + + #[test] + fn search_in_table_matches_decompressed_formatted_appmsg_content() { + let conn = Connection::open_in_memory().expect("open in-memory db"); + conn.execute( + "CREATE TABLE Msg_test ( + local_id INTEGER, + local_type INTEGER, + create_time INTEGER, + real_sender_id INTEGER, + message_content BLOB, + WCDB_CT_message_content INTEGER + )", + [], + ) + .expect("create message table"); + let xml = r#"我也没有用ai啊57不再熬夜昨天用 claude 爬小红书数据来着"#; + let compressed = zstd::encode_all(xml.as_bytes(), 0).expect("compress appmsg xml"); + conn.execute( + "INSERT INTO Msg_test VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + 1_i64, + ((57_i64) << 32) | 49_i64, + 1775146911_i64, + 0_i64, + compressed, + 4_i64 + ], + ) + .expect("insert compressed quote message"); + + let rows = search_in_table( + &conn, + "Msg_test", + "wxid_r605h38n08mv22", + false, + &HashMap::new(), + &HashMap::new(), + "claude", + None, + None, + Some(49), + 10, + ) + .expect("search messages"); + + assert_eq!(rows.len(), 1); + assert_eq!( + rows[0]["content"].as_str(), + Some("[引用] 我也没有用ai啊\n \u{21b3} 不再熬夜: 昨天用 claude 爬小红书数据来着") + ); + } + + fn temp_db_path(name: &str) -> std::path::PathBuf { + let unique = format!( + "wx-cli-{}-{}-{}.db", + name, + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock before unix epoch") + .as_nanos() + ); + std::env::temp_dir().join(unique) + } +} + +fn fmt_time(ts: i64, fmt: &str) -> String { + Local + .timestamp_opt(ts, 0) + .single() + .map(|dt| dt.format(fmt).to_string()) + .unwrap_or_else(|| ts.to_string()) +} + +// ─── 新增命令查询函数 ────────────────────────────────────────────────────────── + +/// 查询有未读消息的会话 +/// +/// `filter`:按 chat_type 过滤,None 或空 Vec 等价于 "all"。 +/// 可选值:`private` / `group` / `official` / `folded` / `all`。 +/// 多选支持在 CLI 层用逗号分隔后传入多个元素。 +pub async fn q_unread( + db: &DbCache, + names: &Names, + limit: usize, + filter: Option>, + with_meta: bool, + debug_source: bool, +) -> Result { + let path = db + .get("session/session.db") + .await? + .context("无法解密 session.db")?; + + // 归一化 filter:小写 + 去除别名。返回 None 代表"不过滤"。 + let filter_set: Option> = filter.and_then(|v| { + let mut set = std::collections::HashSet::new(); + for raw in v { + match raw.trim().to_lowercase().as_str() { + "" | "all" => return None, + "private" => { + set.insert("private"); + } + "group" => { + set.insert("group"); + } + "official" | "official_account" => { + set.insert("official_account"); + } + "folded" | "fold" => { + set.insert("folded"); + } + _ => {} // 未知值忽略,避免拼错导致什么都不返回 + } + } + if set.is_empty() { + None + } else { + Some(set) + } + }); + + // 有 filter 时必须全表扫:SQL LIMIT 会把想要的公众号先筛掉。 + // 无 filter 时保留 LIMIT,避免重度用户的大量未读会话拖慢默认路径。 + let has_filter = filter_set.is_some(); + let limit_val = limit; + let rows: Vec<(String, i64, Vec, i64, i64, String, String)> = + tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path)?; + let sql = if has_filter { + "SELECT username, unread_count, summary, last_timestamp, + last_msg_type, last_msg_sender, last_sender_display_name + FROM SessionTable WHERE unread_count > 0 + ORDER BY last_timestamp DESC" + } else { + "SELECT username, unread_count, summary, last_timestamp, + last_msg_type, last_msg_sender, last_sender_display_name + FROM SessionTable WHERE unread_count > 0 + ORDER BY last_timestamp DESC LIMIT ?" + }; + let mut stmt = conn.prepare(sql)?; + let map_row = |row: &rusqlite::Row<'_>| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1).unwrap_or(0), + get_content_bytes(row, 2), + row.get::<_, i64>(3).unwrap_or(0), + row.get::<_, i64>(4).unwrap_or(0), + row.get::<_, String>(5).unwrap_or_default(), + row.get::<_, String>(6).unwrap_or_default(), + )) + }; + let rows = if has_filter { + stmt.query_map([], map_row)? + .collect::>>()? + } else { + stmt.query_map([limit_val as i64], map_row)? + .collect::>>()? + }; + Ok::<_, anyhow::Error>(rows) + }) + .await??; + + let mut results = Vec::new(); + let mut group_nickname_cache: HashMap> = HashMap::new(); + for (username, unread, summary_bytes, ts, msg_type, sender, sender_name) in rows { + let chat_type = chat_type_of(&username, names); + if let Some(ref set) = filter_set { + if !set.contains(chat_type) { + continue; + } + } + if results.len() >= limit { + break; + } + + let display = names.display(&username); + let is_group = chat_type == "group"; + let summary = decompress_or_str(&summary_bytes); + let summary = strip_group_prefix(&summary); + let sender_display = if is_group && !sender.is_empty() { + if !group_nickname_cache.contains_key(&username) { + let nicknames = load_group_nicknames(db, &username) + .await + .unwrap_or_default(); + group_nickname_cache.insert(username.clone(), nicknames); + } + let empty = HashMap::new(); + let group_nicknames = group_nickname_cache.get(&username).unwrap_or(&empty); + sender_display(&sender, &sender_name, &names.map, group_nicknames) + } else { + String::new() + }; + results.push(json!({ + "chat": display, + "username": username, + "is_group": is_group, + "chat_type": chat_type, + "unread": unread, + "last_msg_type": fmt_type(msg_type), + "last_sender": sender_display, + "summary": summary, + "timestamp": ts, + "time": fmt_time(ts, "%m-%d %H:%M"), + })); + } + let total = results.len(); + let latest_ts = results + .first() + .and_then(|v| v.get("timestamp")) + .and_then(|v| v.as_i64()); + let unknown_shards = current_unknown_shards(db, names); + let meta = Meta { + chat_latest_timestamp: latest_ts, + chat_latest_db: latest_ts.map(|_| "session/session.db".to_string()), + session_last_timestamp: None, + shards_scanned: 0, + shards_hit: 0, + unknown_shards: unknown_shards.clone(), + status: derive_status(latest_ts, None, &unknown_shards, false), + per_shard_latest: if with_meta || debug_source { + Some(HashMap::new()) + } else { + None + }, + cache_mode_per_shard: None, + shard_paths: None, + }; + Ok(json!({ "sessions": results, "total": total, "meta": meta })) +} + +/// 查询群成员:优先从 contact.db 的 chatroom_member/chat_room 表获取完整列表, +/// 若表不存在则退化为从消息记录聚合有发言记录的成员 +pub async fn q_members(db: &DbCache, names: &Names, chat: &str) -> Result { + let username = + resolve_username(chat, names).with_context(|| format!("找不到联系人: {}", chat))?; + + if !username.contains("@chatroom") { + anyhow::bail!("'{}' 不是群聊,无法查看群成员", names.display(&username)); + } + + let display = names.display(&username); + let names_map = names.map.clone(); + + // 优先路径:contact.db → chatroom_member + chat_room(完整成员列表) + if let Some(contact_p) = db.get("contact/contact.db").await? { + let uname2 = username.clone(); + let names_map2 = names_map.clone(); + + let members_opt: Option> = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&contact_p)?; + + let has_table: bool = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='chatroom_member'", + [], + |_| Ok(true), + ) + .unwrap_or(false); + + if !has_table { + return Ok::<_, anyhow::Error>(None); + } + + // 从 chat_room 表获取整数 room_id 和群主 + // WeChat 不同版本列名可能不同:username / chat_room_name / name + let (room_id, owner): (i64, String) = [ + "SELECT id, owner FROM chat_room WHERE username = ?", + "SELECT id, owner FROM chat_room WHERE chat_room_name = ?", + "SELECT id, owner FROM chat_room WHERE name = ?", + ] + .iter() + .find_map(|sql| { + conn.query_row(sql, [&uname2], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1).unwrap_or_default(), + )) + }) + .ok() + }) + .unwrap_or((0, String::new())); + + if room_id == 0 { + return Ok::<_, anyhow::Error>(None); + } + + let mut stmt = conn.prepare( + "SELECT c.username, c.nick_name, c.remark + FROM chatroom_member cm + LEFT JOIN contact c ON c.id = cm.member_id + WHERE cm.room_id = ?", + )?; + let raw: Vec<(String, String, String)> = stmt + .query_map([room_id], |row| { + Ok(( + row.get::<_, String>(0).unwrap_or_default(), + row.get::<_, String>(1).unwrap_or_default(), + row.get::<_, String>(2).unwrap_or_default(), + )) + })? + .filter_map(|r| r.ok()) + .filter(|(uid, _, _)| !uid.is_empty()) + .collect(); + + if raw.is_empty() { + return Ok(None); + } + + let target_usernames: HashSet = + raw.iter().map(|(uid, _, _)| uid.clone()).collect(); + let group_nicknames = + load_group_nickname_map_from_conn(&conn, &uname2, Some(&target_usernames)); + + let mut members: Vec = raw + .iter() + .map(|(uid, nick, remark)| { + let contact_display = contact_display(uid, nick, remark, &names_map2); + let group_nickname = group_nicknames.get(uid).cloned().unwrap_or_default(); + let disp = if group_nickname.is_empty() { + contact_display.clone() + } else { + group_nickname.clone() + }; + let is_owner = uid == &owner && !owner.is_empty(); + json!({ + "username": uid, + "display": disp, + "contact_display": contact_display, + "group_nickname": group_nickname, + "is_owner": is_owner, + }) + }) + .collect(); + + // 群主排首位,其余按 display 字典序 + members.sort_by(|a, b| { + let ao = a["is_owner"].as_bool().unwrap_or(false); + let bo = b["is_owner"].as_bool().unwrap_or(false); + if ao != bo { + return bo.cmp(&ao); + } + a["display"] + .as_str() + .unwrap_or("") + .cmp(b["display"].as_str().unwrap_or("")) + }); + + Ok(Some(members)) + }) + .await??; + + if let Some(members) = members_opt { + return Ok(json!({ + "chat": display, + "username": username, + "count": members.len(), + "members": members, + })); + } + } + + // 降级路径:从消息记录中聚合发言过的成员(必须 open_query_conn,path 是加密源) + let (shards, _) = find_msg_shards(db, names, &username, None, None).await?; + if shards.is_empty() { + return Ok(json!({ + "chat": display, + "username": username, + "count": 0, + "members": [], + })); + } + + let mut sender_set: std::collections::HashSet = std::collections::HashSet::new(); + for shard in &shards { + let Some((conn, _)) = db.open_query_conn(&shard.rel_key).await? else { + continue; + }; + let tname = shard.table.clone(); + let uname = username.clone(); + + let senders: Vec = tokio::task::spawn_blocking(move || { + let id2u = load_id2u(&conn); + let mut stmt = conn.prepare(&format!( + "SELECT DISTINCT real_sender_id FROM [{}] WHERE real_sender_id > 0", + tname + ))?; + let ids: Vec = stmt + .query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + let senders: Vec = ids + .iter() + .filter_map(|id| id2u.get(id)) + .filter(|u| *u != &uname) + .cloned() + .collect(); + Ok::<_, anyhow::Error>(senders) + }) + .await??; + + sender_set.extend(senders); + } + + let group_nicknames = load_group_nicknames(db, &username) + .await + .unwrap_or_default(); + let mut members: Vec = sender_set + .iter() + .map(|u| { + let contact_display = names_map.get(u).cloned().unwrap_or_else(|| u.clone()); + let group_nickname = group_nicknames.get(u).cloned().unwrap_or_default(); + let display = if group_nickname.is_empty() { + contact_display.clone() + } else { + group_nickname.clone() + }; + json!({ + "username": u, + "display": display, + "contact_display": contact_display, + "group_nickname": group_nickname, + "is_owner": false, + }) + }) + .collect(); + members.sort_by(|a, b| { + a["display"] + .as_str() + .unwrap_or("") + .cmp(b["display"].as_str().unwrap_or("")) + }); + + Ok(json!({ + "chat": display, + "username": username, + "count": members.len(), + "members": members, + })) +} + +/// 查询新消息:以 session.db 的 last_timestamp 作为 inbox 索引, +/// 只查询 last_timestamp > state[username] 的会话,精确且高效 +pub async fn q_new_messages( + db: &DbCache, + names: &Names, + state: Option>, + limit: usize, + with_meta: bool, + debug_source: bool, +) -> Result { + // 首次运行(state=None)或未见过的会话,用 24h 前作为起点, + // 避免第一次运行时把全量历史消息涌入 + let fallback_ts = chrono::Utc::now().timestamp() - 86400; + + // 1. 从 session.db 读取所有会话的当前 last_timestamp + let session_path = db + .get("session/session.db") + .await? + .context("无法解密 session.db")?; + + let all_sessions: Vec<(String, i64)> = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&session_path)?; + let mut stmt = conn.prepare( + "SELECT username, last_timestamp FROM SessionTable WHERE last_timestamp > 0", + )?; + let rows = stmt + .query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1).unwrap_or(0))) + })? + .collect::>>()?; + Ok::<_, anyhow::Error>(rows) + }) + .await??; + + // 2. 记录 session.db 的当前快照(用于构建 new_state 基础) + let session_ts_map: HashMap = all_sessions + .iter() + .map(|(u, ts)| (u.clone(), *ts)) + .collect(); + + // 3. 找出有新消息的会话 + // 不在 state 中的会话(首次运行或新会话)以 fallback_ts 为基准 + let changed: Vec<(String, i64)> = all_sessions + .into_iter() + .filter(|(uname, ts)| { + let last_known = state + .as_ref() + .and_then(|m| m.get(uname)) + .copied() + .unwrap_or(fallback_ts); + *ts > last_known + }) + .collect(); + + let unknown_shards = current_unknown_shards(db, names); + + if changed.is_empty() { + let meta = meta_for_global_query( + 0, + 0, + unknown_shards, + true, + with_meta, + debug_source, + Some(HashMap::new()), + Some(HashMap::new()), + ); + return Ok(json!({ + "count": 0, + "messages": [], + "new_state": session_ts_map, + "meta": meta, + })); + } + + // 4. 只查询有新消息的会话的消息表 + // per_table_limit 取 limit*5 防止单表截断,最终由全局 truncate 收尾 + let per_table_limit = limit.saturating_mul(5).max(200); + let mut all_msgs: Vec = Vec::new(); + let mut scanned_rel_keys: HashSet = HashSet::new(); + let mut hit_rel_keys: HashSet = HashSet::new(); + let mut cache_modes: HashMap = HashMap::new(); + let mut shard_paths: HashMap = HashMap::new(); + + for (uname, _) in &changed { + let since_ts = state + .as_ref() + .and_then(|m| m.get(uname)) + .copied() + .unwrap_or(fallback_ts); + // 用 since 做分片时间路由,跳过冷库 + let since_for_route = Some(since_ts.saturating_add(1)); + let (shards, _) = find_msg_shards(db, names, uname, since_for_route, None).await?; + if shards.is_empty() { + continue; + } + for shard in &shards { + scanned_rel_keys.insert(shard.rel_key.clone()); + cache_modes.insert(shard.rel_key.clone(), shard.cache_mode.as_str().to_string()); + shard_paths.insert( + shard.rel_key.clone(), + shard.path.to_string_lossy().into_owned(), + ); + } + + let display = names.display(uname); + let chat_type = chat_type_of(uname, names); + let is_group = chat_type == "group"; + let group_nicknames = if is_group { + load_group_nicknames(db, uname).await.unwrap_or_default() + } else { + HashMap::new() + }; + + for shard in &shards { + let rel = shard.rel_key.clone(); + let tname = shard.table.clone(); + let uname2 = uname.clone(); + let display2 = display.clone(); + let names_map = names.map.clone(); + let group_nicknames2 = group_nicknames.clone(); + let tname_for_log = tname.clone(); + let rel_key_for_hit = shard.rel_key.clone(); + + let Some((conn, _)) = db.open_query_conn(&rel).await? else { + eprintln!("[new-messages] open failed for {}", rel); + continue; + }; + + let msgs: Vec = match tokio::task::spawn_blocking(move || { + let id2u = load_id2u(&conn); + + let sql = format!( + "SELECT local_id, local_type, create_time, real_sender_id, + message_content, WCDB_CT_message_content + FROM [{}] WHERE create_time > ? ORDER BY create_time ASC LIMIT ?", + tname + ); + let rows: Vec<_> = conn + .prepare(&sql) + .and_then(|mut stmt| { + stmt.query_map(rusqlite::params![since_ts, per_table_limit as i64], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + get_content_bytes(row, 4), + row.get::<_, i64>(5).unwrap_or(0), + )) + }) + .map(|it| it.filter_map(|r| r.ok()).collect()) + }) + .unwrap_or_default(); + + let mut result = Vec::new(); + for (local_id, local_type, ts, real_sender_id, content_bytes, ct) in rows { + let content = decompress_message(&content_bytes, ct); + let sender_username = sender_username(real_sender_id, &content, is_group, &uname2, &id2u); + let sender = sender_label( + real_sender_id, + &content, + is_group, + &uname2, + &id2u, + &names_map, + &group_nicknames2, + ); + let text = fmt_content(local_id, local_type, &content, is_group); + let url = appmsg_url_for_message(local_type, &content); + let mut msg = json!({ + "chat": display2, + "username": uname2, + "is_group": is_group, + "chat_type": chat_type, + "timestamp": ts, + "time": fmt_time(ts, "%Y-%m-%d %H:%M"), + "sender": sender, + "content": text, + "type": fmt_type(local_type), + }); + attach_type_fields(&mut msg, local_type, &content); + add_sender_identity(&mut msg, is_group, &sender_username, &names_map, &group_nicknames2); + if let Some(u) = url { + msg["url"] = serde_json::Value::String(u); + } + result.push(msg); + } + Ok::<_, anyhow::Error>(result) + }) + .await + { + Ok(Ok(v)) => v, + Ok(Err(e)) => { + eprintln!("[new-messages] skip {}: {}", tname_for_log, e); + continue; + } + Err(e) => { + eprintln!("[new-messages] task error: {}", e); + continue; + } + }; + + if !msgs.is_empty() { + hit_rel_keys.insert(rel_key_for_hit); + } + all_msgs.extend(msgs); + } + } + + all_msgs.sort_by_key(|m| m["timestamp"].as_i64().unwrap_or(0)); + all_msgs.truncate(limit); + + // 5. 重建 new_state,防止全局 limit 截断导致消息永久丢失: + // - 未变化的会话:沿用 session.db 的 last_timestamp(即 session_ts_map) + // - 变化但全被截断(无消息在最终结果中): + // * 后续调用 (state=Some):保留旧 since_ts,下次重试拿这部分消息 + // * 首次调用 (state=None):advance 到 session_ts,避免 since_ts 锁死在 + // fallback_ts 导致后续每次都回扫 24h。窗口会随调用次数 + 时间累积扩大, + // 性能持续衰退。代价:首次 + 被截断会话的老消息看不到,需走 `wx history`。 + // - 变化且有消息返回:advance 到该会话在结果中的最大 timestamp(增量 fetch 标准语义) + let returned_max_ts: HashMap = { + let mut m: HashMap = HashMap::new(); + for msg in &all_msgs { + if let (Some(u), Some(ts)) = (msg["username"].as_str(), msg["timestamp"].as_i64()) { + let e = m.entry(u.to_string()).or_insert(0); + if ts > *e { + *e = ts; + } + } + } + m + }; + let mut new_state = session_ts_map; + for (uname, _) in &changed { + let in_results = returned_max_ts.contains_key(uname); + let prev = state.as_ref().and_then(|m| m.get(uname)).copied(); + let next_ts = match (in_results, prev) { + (true, _) => { + // 有消息返回:advance 到 returned_max;返回的最大 ts 通常 ≤ session_ts, + // 这样下次查 `since > returned_max` 仍能拿到 returned_max..session_ts 的截断尾巴。 + returned_max_ts[uname] + } + (false, Some(prev)) => prev, // 后续 + 截断:保持旧 since + (false, None) => { + // 首次 + 截断:advance 到 session_ts 兜底,避免 since_ts 锁死。 + new_state.get(uname).copied().unwrap_or(fallback_ts) + } + }; + new_state.insert(uname.clone(), next_ts); + } + + let meta = meta_for_global_query( + scanned_rel_keys.len(), + hit_rel_keys.len(), + unknown_shards, + true, + with_meta, + debug_source, + Some(cache_modes), + Some(shard_paths), + ); + + Ok(json!({ + "count": all_msgs.len(), + "messages": all_msgs, + "new_state": new_state, + "meta": meta, + })) +} + +/// 查询收藏内容(favorite/favorite.db 的 fav_db_item 表) +pub async fn q_favorites( + db: &DbCache, + limit: usize, + fav_type: Option, + query: Option, +) -> Result { + let path = db + .get("favorite/favorite.db") + .await? + .context("找不到 favorite.db,请确认微信数据目录")?; + + let rows: Vec = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path)?; + + let mut clauses: Vec<&'static str> = Vec::new(); + let mut params: Vec> = Vec::new(); + + if let Some(t) = fav_type { + clauses.push("type = ?"); + params.push(Box::new(t)); + } + let like_str: Option = query.map(|q| { + let esc = q + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_"); + format!("%{}%", esc) + }); + if let Some(ref s) = like_str { + clauses.push("content LIKE ? ESCAPE '\\'"); + params.push(Box::new(s.clone())); + } + + let where_clause = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + params.push(Box::new(limit as i64)); + + let sql = format!( + "SELECT local_id, type, update_time, content, fromusr, realchatname + FROM fav_db_item {} ORDER BY update_time DESC LIMIT ?", + where_clause + ); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let rows: Vec = stmt + .query_map(params_ref.as_slice(), |row| { + Ok(( + row.get::<_, i64>(0).unwrap_or(0), + row.get::<_, i64>(1).unwrap_or(0), + row.get::<_, i64>(2).unwrap_or(0), + row.get::<_, String>(3).unwrap_or_default(), + row.get::<_, String>(4).unwrap_or_default(), + row.get::<_, String>(5).unwrap_or_default(), + )) + })? + .filter_map(|r| r.ok()) + .map(|(local_id, ftype, ts, content, fromusr, chatname)| { + let type_str = match ftype { + 1 => "文本", + 2 => "图片", + 5 => "文章", + 19 => "名片", + 20 => "视频", + _ => "其他", + }; + // 安全截断(按 Unicode 字符而非字节) + let preview: String = content.chars().take(100).collect(); + let preview = if content.chars().count() > 100 { + format!("{}...", preview) + } else { + preview + }; + // WeChat 部分版本的 update_time 为毫秒,10位以上判定为毫秒后转秒 + let ts_secs = if ts > 9_999_999_999 { ts / 1000 } else { ts }; + let mut item = json!({ + "id": local_id, + "type": type_str, + "type_num": ftype, + "time": fmt_time(ts_secs, "%Y-%m-%d %H:%M"), + "timestamp": ts_secs, + "preview": preview, + "from": fromusr, + "chat": chatname, + }); + if ftype == 5 { + if let Some(url) = extract_favorite_url(&content) { + item["url"] = Value::String(url); + } + } + item + }) + .collect(); + + Ok::<_, anyhow::Error>(rows) + }) + .await??; + + Ok(json!({ + "count": rows.len(), + "items": rows, + })) +} + +/// 聊天统计:消息总数、类型分布、发言排行、24小时分布 +pub async fn q_stats( + db: &DbCache, + names: &Names, + chat: &str, + since: Option, + until: Option, + with_meta: bool, + debug_source: bool, +) -> Result { + let username = + resolve_username(chat, names).with_context(|| format!("找不到联系人: {}", chat))?; + let display = names.display(&username); + let chat_type = chat_type_of(&username, names); + let is_group = chat_type == "group"; + + let (shards, scanned) = find_msg_shards(db, names, &username, None, None).await?; + if shards.is_empty() { + anyhow::bail!("找不到 {} 的消息记录", display); + } + + // 跨所有分片 DB 累计统计 + let mut total: i64 = 0; + let mut type_counts: HashMap = HashMap::new(); + let mut sender_counts: HashMap = HashMap::new(); + let mut hour_counts = [0i64; 24]; + let group_nicknames = if is_group { + load_group_nicknames(db, &username) + .await + .unwrap_or_default() + } else { + HashMap::new() + }; + let mut shard_hits = 0usize; + + for shard in &shards { + let rel = shard.rel_key.clone(); + let tname = shard.table.clone(); + let uname = username.clone(); + let is_group2 = is_group; + + let Some((conn, _)) = db.open_query_conn(&rel).await? else { + continue; + }; + + // 用 SQL GROUP BY 在数据库侧聚合,避免把全量消息内容加载进内存 + let result: (i64, HashMap, HashMap, [i64; 24]) = + tokio::task::spawn_blocking(move || { + let id2u = load_id2u(&conn); + + let mut clauses = Vec::new(); + let mut params: Vec> = Vec::new(); + if let Some(s) = since { + clauses.push("create_time >= ?"); + params.push(Box::new(s)); + } + if let Some(u) = until { + clauses.push("create_time <= ?"); + params.push(Box::new(u)); + } + let where_clause = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + + // 1. 总数 + let count: i64 = conn.query_row( + &format!("SELECT COUNT(*) FROM [{}] {}", tname, where_clause), + params_ref.as_slice(), + |row| row.get(0), + ).unwrap_or(0); + + // 2. 类型分布:SQL GROUP BY,不加载消息内容 + let type_sql = format!( + "SELECT (local_type & 0xFFFFFFFF), COUNT(*) FROM [{}] {} GROUP BY (local_type & 0xFFFFFFFF)", + tname, where_clause + ); + let mut type_c: HashMap = HashMap::new(); + if let Ok(mut stmt) = conn.prepare(&type_sql) { + let _ = stmt.query_map(params_ref.as_slice(), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + }).map(|rows| { + for r in rows.flatten() { + *type_c.entry(fmt_type(r.0)).or_insert(0) += r.1; + } + }); + } + + // 3. 小时分布:只取时间戳,不加载消息内容 + let hour_sql = format!( + "SELECT create_time FROM [{}] {}", + tname, where_clause + ); + let mut hour_c = [0i64; 24]; + if let Ok(mut stmt) = conn.prepare(&hour_sql) { + let _ = stmt.query_map(params_ref.as_slice(), |row| row.get::<_, i64>(0)) + .map(|rows| { + for ts in rows.flatten() { + if let Some(dt) = Local.timestamp_opt(ts, 0).single() { + let h = dt.hour() as usize; + if h < 24 { hour_c[h] += 1; } + } + } + }); + } + + // 4. 发言排行:只取 real_sender_id,不加载消息内容 + // where_clause 可能已含 WHERE,用 AND 追加而非重复写 WHERE + let sender_filter = if where_clause.is_empty() { + "WHERE real_sender_id > 0".to_string() + } else { + format!("{} AND real_sender_id > 0", where_clause) + }; + let sender_sql = format!( + "SELECT real_sender_id, COUNT(*) FROM [{}] {} GROUP BY real_sender_id", + tname, sender_filter + ); + let mut sender_c: HashMap = HashMap::new(); + if is_group2 { + if let Ok(mut stmt) = conn.prepare(&sender_sql) { + let _ = stmt.query_map(params_ref.as_slice(), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + }).map(|rows| { + for (id, cnt) in rows.flatten() { + if let Some(u) = id2u.get(&id) { + if u != &uname { + *sender_c.entry(u.clone()).or_insert(0) += cnt; + } + } + } + }); + } + } + + Ok::<_, anyhow::Error>((count, type_c, sender_c, hour_c)) + }).await??; + + let (count, type_c, sender_c, hour_c) = result; + if count > 0 { + shard_hits += 1; + } + total += count; + for (k, v) in type_c { + *type_counts.entry(k).or_insert(0) += v; + } + for (k, v) in sender_c { + *sender_counts.entry(k).or_insert(0) += v; + } + for i in 0..24 { + hour_counts[i] += hour_c[i]; + } + } + + // 类型分布,按数量降序 + let mut by_type: Vec = type_counts + .iter() + .map(|(t, c)| json!({ "type": t, "count": c })) + .collect(); + by_type.sort_by_key(|v| std::cmp::Reverse(v["count"].as_i64().unwrap_or(0))); + + // 发言排行,Top 10 + let top_senders = group_top_senders(&sender_counts, &names.map, &group_nicknames, 10); + + // 24小时分布 + let by_hour: Vec = hour_counts + .iter() + .enumerate() + .map(|(h, c)| json!({ "hour": h, "count": c })) + .collect(); + let windowed = since.is_some() || until.is_some(); + let unknown_shards = current_unknown_shards(db, names); + let session_ts = session_last_timestamp(db, &username).await; + let meta = meta_for_shards( + scanned, + &shards, + shard_hits, + unknown_shards, + session_ts, + windowed, + with_meta, + debug_source, + ); + + Ok(json!({ + "chat": display, + "username": username, + "is_group": is_group, + "chat_type": chat_type, + "total": total, + "by_type": by_type, + "top_senders": top_senders, + "by_hour": by_hour, + "meta": meta, + })) +} + +/// 查询朋友圈互动通知(点赞 + 评论),对应微信 app 右上角的红点入口。 +/// 空 `content` 是点赞,非空是评论正文。 +pub async fn q_sns_notifications( + db: &DbCache, + names: &Names, + limit: usize, + since: Option, + until: Option, + include_read: bool, +) -> Result { + let path = db.get("sns/sns.db").await?.context("无法解密 sns.db")?; + + let path2 = path.clone(); + type Row = (i64, i64, i64, i64, String, String, String); + let rows: Vec = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path2)?; + let mut clauses: Vec<&str> = Vec::new(); + let mut params: Vec> = Vec::new(); + if !include_read { + clauses.push("is_unread = 1"); + } + if let Some(s) = since { + clauses.push("create_time >= ?"); + params.push(Box::new(s)); + } + if let Some(u) = until { + clauses.push("create_time <= ?"); + params.push(Box::new(u)); + } + let where_clause = if clauses.is_empty() { + String::new() + } else { + format!("WHERE {}", clauses.join(" AND ")) + }; + let sql = format!( + "SELECT local_id, create_time, type, feed_id, from_username, from_nickname, content + FROM SnsMessage_tmp3 {} ORDER BY create_time DESC LIMIT ?", + where_clause + ); + params.push(Box::new(limit as i64)); + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt + .query_map(params_ref.as_slice(), |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2).unwrap_or(0), + row.get::<_, i64>(3).unwrap_or(0), + row.get::<_, String>(4).unwrap_or_default(), + row.get::<_, String>(5).unwrap_or_default(), + row.get::<_, String>(6).unwrap_or_default(), + )) + })? + .collect::>>()?; + Ok::<_, anyhow::Error>(rows) + }) + .await??; + + // 一次性取出涉及的 feed 原帖,避免 N+1 查询 + let feed_ids: Vec = { + let mut v: Vec = rows.iter().map(|r| r.3).collect(); + v.sort_unstable(); + v.dedup(); + v + }; + let path3 = path.clone(); + let feed_ids_clone = feed_ids.clone(); + let feeds: HashMap = tokio::task::spawn_blocking(move || { + if feed_ids_clone.is_empty() { + return Ok::<_, anyhow::Error>(HashMap::new()); + } + let conn = Connection::open(&path3)?; + let placeholders = std::iter::repeat("?") + .take(feed_ids_clone.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT tid, user_name, content FROM SnsTimeLine WHERE tid IN ({})", + placeholders + ); + let params: Vec<&dyn rusqlite::types::ToSql> = feed_ids_clone + .iter() + .map(|id| id as &dyn rusqlite::types::ToSql) + .collect(); + let mut stmt = conn.prepare(&sql)?; + let mut map = HashMap::new(); + let mut rows2 = stmt.query(params.as_slice())?; + while let Some(row) = rows2.next()? { + let tid: i64 = row.get(0)?; + let author: String = row.get::<_, String>(1).unwrap_or_default(); + let content: String = row.get::<_, String>(2).unwrap_or_default(); + let preview = extract_xml_text(&content, "contentDesc") + .map(|s| s.chars().take(60).collect::()) + .unwrap_or_default(); + // 原帖 user_name 偶尔为空(转发帖),再从 XML 兜一下 + let author = if author.is_empty() { + extract_xml_text(&content, "username").unwrap_or_default() + } else { + author + }; + map.insert(tid, (author, preview)); + } + Ok(map) + }) + .await??; + + let mut out = Vec::with_capacity(rows.len()); + for (_local_id, ct, _typ, fid, from_u, from_nick, content) in rows { + let kind = if content.trim().is_empty() { + "like" + } else { + "comment" + }; + let display = if !from_nick.is_empty() { + from_nick.clone() + } else { + names.display(&from_u) + }; + let (feed_author_u, feed_preview) = feeds.get(&fid).cloned().unwrap_or_default(); + let feed_author_display = if feed_author_u.is_empty() { + String::new() + } else { + names.display(&feed_author_u) + }; + out.push(json!({ + "type": kind, + "time": fmt_time(ct, "%m-%d %H:%M"), + "timestamp": ct, + "from_username": from_u, + "from_nickname": display, + "content": content, + "feed_id": fid, + "feed_author_username": feed_author_u, + "feed_author": feed_author_display, + "feed_preview": feed_preview, + })); + } + let total = out.len(); + Ok(json!({ "notifications": out, "total": total })) +} + +// 朋友圈扫描的硬上限:单次查询最多解析这么多行 SnsTimeLine, +// 防止用户传超大 limit 或者底层数据异常时把 daemon 卡住。 +// 当前账号 ~10k+ 帖子,5w 上限留足缓冲。 +const SNS_MAX_LIMIT: usize = 10_000; +const SNS_MAX_SCAN: usize = 50_000; + +/// 转义 SQL LIKE 模式中的元字符。配合 `ESCAPE '\\'` 使用。 +/// 反斜杠必须最先转义,否则后续替换出的 `\%` / `\_` 会被再次吞掉。 +fn escape_like_pattern(s: &str) -> String { + s.replace('\\', r"\\") + .replace('%', r"\%") + .replace('_', r"\_") +} + +fn xml_child<'a, 'input>(node: Node<'a, 'input>, tag: &str) -> Option> { + node.children() + .find(|child| child.is_element() && child.has_tag_name(tag)) +} + +fn xml_text<'a, 'input>(node: Option>) -> Option { + node.and_then(|n| n.text()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn xml_attr<'a, 'input>(node: Option>, attr: &str) -> Option { + node.and_then(|n| n.attribute(attr)) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn insert_media_string(out: &mut serde_json::Map, key: &str, value: Option) { + if let Some(value) = value { + out.insert(key.to_string(), Value::String(value)); + } +} + +fn insert_media_i64(out: &mut serde_json::Map, key: &str, value: Option) { + if let Some(value) = value { + out.insert(key.to_string(), Value::from(value)); + } +} + +/// 从已经定位到的 `` 节点里抽 `/` 数组。 +/// 字段名与 artifacts 仓库 `wechat_sns_dump.py::_parse_media` 对齐, +/// 便于跨实现 diff。缺失字段直接省略(不输出 null),供下游代理图片 / 离线渲染。 +fn parse_media_from_timeline(timeline: Node) -> Vec { + let Some(media_list) = + xml_child(timeline, "ContentObject").and_then(|node| xml_child(node, "mediaList")) + else { + return Vec::new(); + }; + + media_list + .children() + .filter(|node| node.is_element() && node.has_tag_name("media")) + .map(|media| { + let url_el = xml_child(media, "url"); + let thumb_el = xml_child(media, "thumb"); + let size_el = xml_child(media, "size"); + let mut out = serde_json::Map::new(); + + insert_media_string(&mut out, "type", xml_text(xml_child(media, "type"))); + insert_media_string(&mut out, "sub_type", xml_text(xml_child(media, "sub_type"))); + insert_media_string(&mut out, "url", xml_text(url_el)); + insert_media_string(&mut out, "thumb", xml_text(thumb_el)); + insert_media_string(&mut out, "md5", xml_attr(url_el, "md5")); + insert_media_string(&mut out, "url_key", xml_attr(url_el, "key")); + insert_media_string(&mut out, "url_token", xml_attr(url_el, "token")); + insert_media_string(&mut out, "url_enc_idx", xml_attr(url_el, "enc_idx")); + insert_media_string(&mut out, "thumb_key", xml_attr(thumb_el, "key")); + insert_media_string(&mut out, "thumb_token", xml_attr(thumb_el, "token")); + insert_media_string(&mut out, "thumb_enc_idx", xml_attr(thumb_el, "enc_idx")); + insert_media_i64( + &mut out, + "width", + xml_attr(size_el, "width").and_then(|v| v.parse::().ok()), + ); + insert_media_i64( + &mut out, + "height", + xml_attr(size_el, "height").and_then(|v| v.parse::().ok()), + ); + insert_media_i64( + &mut out, + "total_size", + xml_attr(size_el, "totalSize").and_then(|v| v.parse::().ok()), + ); + insert_media_string( + &mut out, + "video_md5", + xml_text(xml_child(media, "videomd5")), + ); + insert_media_i64( + &mut out, + "video_duration", + xml_text(xml_child(media, "videoDuration")).and_then(|v| v.parse::().ok()), + ); + + Value::Object(out) + }) + .collect() +} + +/// 从 `SnsTimeLine.content` 整段 XML 抽 media[]。仅供单测使用 —— 生产路径走 +/// `parse_post_xml`,那边已经把整份 doc parse 一次直接复用 timeline 节点。 +#[cfg(test)] +fn parse_post_media(xml: &str) -> Vec { + let Ok(doc) = Document::parse(xml) else { + return Vec::new(); + }; + let Some(timeline) = doc.descendants().find(|n| n.has_tag_name("TimelineObject")) else { + return Vec::new(); + }; + parse_media_from_timeline(timeline) +} + +/// SnsTimeLine 行解析产物。不含 display name(依赖 Names,需要出 spawn_blocking 再补)。 +struct ParsedPost { + tid: i64, + create_time: i64, + author_username: String, + content: String, + media: Vec, + location: String, +} + +fn parse_post_xml_fallback(tid: i64, user_name_column: &str, content: &str) -> ParsedPost { + let create_time = extract_xml_text(content, "createTime") + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let text = extract_xml_text(content, "contentDesc") + .map(|s| unescape_html(&s)) + .unwrap_or_default(); + let author_username = if user_name_column.is_empty() { + extract_xml_text(content, "username") + .map(|s| unescape_html(&s)) + .unwrap_or_default() + } else { + user_name_column.to_string() + }; + let location = extract_xml_attr(content, "location", "poiName") + .map(|s| unescape_html(&s)) + .unwrap_or_default(); + + ParsedPost { + tid, + create_time, + author_username, + content: text, + media: Vec::new(), + location, + } +} + +/// 纯 XML 解析,无 Names 依赖,可以在 spawn_blocking 里跑。 +/// user_name_column 为空时从 TimelineObject/ 兜底(转发帖)。 +/// +/// 单 roxmltree DOM 解析一次出全部字段(createTime / contentDesc / username / media / location), +/// 取代旧版 regex + DOM 双解析。XML entity 解码(`<` / `&` 等)由 roxmltree 自动处理, +/// 旧版 `extract_xml_text` 是字符串扫描不解码 —— 因此 `content` / `location` / `username` 字段 +/// 现在会输出解码后的文本,对下游是更正确的语义。 +/// 如果 XML 已损坏到无法 DOM parse,或缺少 `TimelineObject`,则退回轻量 string +/// fallback,尽量保住 createTime / contentDesc / username / location,避免一条帖子 +/// 因为局部坏 XML 被整体打成零值,影响排序 / 搜索 / 作者过滤语义。 +fn parse_post_xml(tid: i64, user_name_column: &str, content: &str) -> ParsedPost { + let Ok(doc) = Document::parse(content) else { + return parse_post_xml_fallback(tid, user_name_column, content); + }; + let Some(timeline) = doc.descendants().find(|n| n.has_tag_name("TimelineObject")) else { + return parse_post_xml_fallback(tid, user_name_column, content); + }; + + let create_time = xml_text(xml_child(timeline, "createTime")) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + let text = xml_text(xml_child(timeline, "contentDesc")).unwrap_or_default(); + let author_username = if user_name_column.is_empty() { + xml_text(xml_child(timeline, "username")).unwrap_or_default() + } else { + user_name_column.to_string() + }; + let media = parse_media_from_timeline(timeline); + let location = xml_child(timeline, "location") + .and_then(|n| n.attribute("poiName")) + .map(str::to_string) + .unwrap_or_default(); + + ParsedPost { + tid, + create_time, + author_username, + content: text, + media, + location, + } +} + +fn post_to_value(p: ParsedPost, names: &Names) -> Value { + let author = if p.author_username.is_empty() { + String::new() + } else { + names.display(&p.author_username) + }; + json!({ + "tid": p.tid, + "timestamp": p.create_time, + "time": fmt_time(p.create_time, "%Y-%m-%d %H:%M"), + "author_username": p.author_username, + "author": author, + "content": p.content, + "media_count": p.media.len() as i64, + "media": p.media, + "location": p.location, + }) +} + +/// 查询朋友圈时间线:按时间/作者筛选。用于浏览自己或好友的朋友圈。 +pub async fn q_sns_feed( + db: &DbCache, + names: &Names, + limit: usize, + since: Option, + until: Option, + user: Option<&str>, +) -> Result { + let path = db.get("sns/sns.db").await?.context("无法解密 sns.db")?; + + let limit = limit.min(SNS_MAX_LIMIT); + let user_uname = match user { + Some(q) => { + Some(resolve_username(q, names).with_context(|| format!("找不到联系人: {}", q))?) + } + None => None, + }; + + // user 过滤不在 SQL 层做:SnsTimeLine.user_name 列对部分(转发)帖子是空, + // 真正作者只在 XML 里。SQL 层 `user_name = ?` 会把这部分提前漏掉, + // 让 parse_post_xml 的 fallback 失效。所以扫全表 → parse → 用 ParsedPost.author_username 过滤。 + // (createTime 也不是列,本来就要扫全表 parse XML 才能正确按时间排序。) + let path2 = path.clone(); + let parsed: Vec = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path2)?; + let sql = "SELECT tid, user_name, content FROM SnsTimeLine ORDER BY tid DESC"; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([], |row| Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1).unwrap_or_default(), + row.get::<_, String>(2).unwrap_or_default(), + )))?; + + let mut scanned = 0usize; + let mut out: Vec = Vec::new(); + for row in rows { + scanned += 1; + if scanned > SNS_MAX_SCAN { + eprintln!( + "[sns_feed] scan 超过硬上限 {},结果可能不完整。建议加 --user / --since 缩小范围。", + SNS_MAX_SCAN + ); + break; + } + let (tid, uname, content) = row?; + let p = parse_post_xml(tid, &uname, &content); + if let Some(u) = user_uname.as_ref() { if &p.author_username != u { continue; } } + if let Some(s) = since { if p.create_time < s { continue; } } + if let Some(u) = until { if p.create_time > u { continue; } } + out.push(p); + } + // tid DESC 不严格等于 createTime DESC(不同账号 tid 生成算法不同), + // 所以要先收齐全部匹配的、按 create_time 排序,再 truncate —— 否则会丢帖。 + out.sort_by_key(|p| std::cmp::Reverse(p.create_time)); + out.truncate(limit); + Ok::<_, anyhow::Error>(out) + }).await??; + + let posts: Vec = parsed + .into_iter() + .map(|p| post_to_value(p, names)) + .collect(); + let total = posts.len(); + Ok(json!({ "posts": posts, "total": total })) +} + +/// 搜索朋友圈全文:在 contentDesc(正文)里匹配 keyword,可叠加时间 / 作者过滤。 +pub async fn q_sns_search( + db: &DbCache, + names: &Names, + keyword: &str, + limit: usize, + since: Option, + until: Option, + user: Option<&str>, +) -> Result { + if keyword.trim().is_empty() { + anyhow::bail!("搜索关键词不能为空"); + } + let path = db.get("sns/sns.db").await?.context("无法解密 sns.db")?; + + let limit = limit.min(SNS_MAX_LIMIT); + let user_uname = match user { + Some(q) => { + Some(resolve_username(q, names).with_context(|| format!("找不到联系人: {}", q))?) + } + None => None, + }; + + // SQL LIKE 在 content 上粗筛 keyword(这步省掉绝大多数行的 XML parse 开销)。 + // user 不在 SQL 层过滤,原因同 q_sns_feed:SnsTimeLine.user_name 列对部分(转发) + // 帖子为空,真实作者只在 XML 里。 + let like_pattern = format!("%{}%", escape_like_pattern(keyword)); + let keyword_owned = keyword.to_string(); + + let path2 = path.clone(); + let parsed: Vec = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&path2)?; + let sql = "SELECT tid, user_name, content FROM SnsTimeLine \ + WHERE content LIKE ? ESCAPE '\\' ORDER BY tid DESC"; + let mut stmt = conn.prepare(sql)?; + let rows = stmt.query_map([&like_pattern], |row| Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1).unwrap_or_default(), + row.get::<_, String>(2).unwrap_or_default(), + )))?; + + let needle = keyword_owned.to_lowercase(); + let mut scanned = 0usize; + let mut out: Vec = Vec::new(); + for row in rows { + scanned += 1; + if scanned > SNS_MAX_SCAN { + eprintln!( + "[sns_search] scan 超过硬上限 {},结果可能不完整。建议缩小 keyword 或加 --user / --since。", + SNS_MAX_SCAN + ); + break; + } + let (tid, uname, content) = row?; + let desc = extract_xml_text(&content, "contentDesc").unwrap_or_default(); + if !desc.to_lowercase().contains(&needle) { continue; } + + let p = parse_post_xml(tid, &uname, &content); + if let Some(u) = user_uname.as_ref() { if &p.author_username != u { continue; } } + if let Some(s) = since { if p.create_time < s { continue; } } + if let Some(u) = until { if p.create_time > u { continue; } } + out.push(p); + } + out.sort_by_key(|p| std::cmp::Reverse(p.create_time)); + out.truncate(limit); + Ok::<_, anyhow::Error>(out) + }).await??; + + let posts: Vec = parsed + .into_iter() + .map(|p| post_to_value(p, names)) + .collect(); + let total = posts.len(); + Ok(json!({ "keyword": keyword, "posts": posts, "total": total })) +} + +// ─── 公众号文章查询 ─────────────────────────────────────────────────────────── + +/// 一条公众号文章的解析产物 +#[derive(Debug)] +struct BizArticle { + /// 接收该推送的时间戳(即消息的 create_time) + recv_time: i64, + /// 公众号 username + account_username: String, + /// 文章标题 + title: String, + /// 文章链接 + url: String, + /// 摘要 + digest: String, + /// 封面图 + cover: String, + /// 文章发布时间(pub_time,单位秒) + pub_time: i64, +} + +/// 从 biz_message 表的单条 XML 解析出全部 article items +fn parse_biz_xml_items(recv_time: i64, account_username: &str, xml: &str) -> Vec { + let mut items = Vec::new(); + let mut search_from = 0; + loop { + let Some(item_start) = xml[search_from..].find("") else { + break; + }; + let abs_start = search_from + item_start; + let Some(item_end) = xml[abs_start..].find("") else { + break; + }; + let abs_end = abs_start + item_end + 7; + let item_xml = &xml[abs_start..abs_end]; + + let title = extract_cdata(item_xml, "title").unwrap_or_default(); + let url = extract_cdata(item_xml, "url").unwrap_or_default(); + // Skip items with no URL or empty title (e.g. payment entries) + if url.is_empty() || title.is_empty() { + search_from = abs_end; + continue; + } + let digest = extract_cdata(item_xml, "digest").unwrap_or_default(); + let cover = extract_cdata(item_xml, "cover").unwrap_or_default(); + let pub_time = extract_xml_text(item_xml, "pub_time") + .and_then(|s| s.parse::().ok()) + .unwrap_or(recv_time); + + items.push(BizArticle { + recv_time, + account_username: account_username.to_string(), + title, + url, + digest, + cover, + pub_time, + }); + search_from = abs_end; + } + items +} + +/// 提取 CDATA 或普通文本内容: `` 或 `...` +/// +/// 注意: 内容匹配到 `` 之前的内容。CDATA 块中的 "]]"已在 "]]\x3e" 之前, +/// 所以 inner 为 `` 或 `" 被 close tag 吸掉) +fn extract_cdata(xml: &str, tag: &str) -> Option { + let open = format!("<{}>", tag); + let close = format!("", tag); + let start = xml.find(&open)? + open.len(); + let end = xml[start..].find(&close)?; + let inner = xml[start..start + end].trim(); + if inner.starts_with("` → strip 9-char `` suffix + let body = &inner[9..]; + // Strip `]]>` (normal) or `]]` (edge case) + let cdata_end = b"]]>"; + let cdata_end2 = b"]]"; + let content: &str = if body.as_bytes().ends_with(cdata_end) { + &body[..body.len() - 3] + } else if body.as_bytes().ends_with(cdata_end2) { + &body[..body.len() - 2] + } else { + body + }; + let content = content.trim(); + if content.is_empty() { + None + } else { + Some(content.to_string()) + } + } else if inner.is_empty() { + None + } else { + Some(unescape_html(inner)) + } +} + +/// 查询公众号文章推送(biz_message_*.db 分片) +/// +/// 每条消息可能包含多篇文章(多图文推送)。返回所有文章展开就的平底列表。 +pub async fn q_biz_articles( + db: &DbCache, + names: &Names, + limit: usize, + account: Option, + since: Option, + until: Option, + unread: bool, +) -> Result { + let mut biz_paths = Vec::new(); + for rel_key in &names.biz_msg_db_keys { + if let Some(path) = db.get(rel_key).await? { + biz_paths.push(path); + } + } + if biz_paths.is_empty() { + return Err(anyhow::anyhow!( + "无法解密任何 biz_message_*.db,请确认 all_keys.json 包含对应密钥" + )); + } + + // 开启 --unread:从 session.db 拿“公众号 + unread_count>0”的 username 子集, + // 作为合集过滤(与 --account 取交集),后续结果按 account_username 去重取顶 1 篇。 + let unread_usernames: Option> = if unread { + let session_path = db + .get("session/session.db") + .await? + .context("无法解密 session.db")?; + let session_path2 = session_path.clone(); + let unread_rows: Vec = tokio::task::spawn_blocking(move || { + let conn = Connection::open(&session_path2)?; + let mut stmt = + conn.prepare("SELECT username FROM SessionTable WHERE unread_count > 0")?; + let rows: Vec = stmt + .query_map([], |row| row.get::<_, String>(0))? + .filter_map(|r| r.ok()) + .collect(); + Ok::<_, anyhow::Error>(rows) + }) + .await??; + // 仅保留公众号类型的未读会话 + let set: std::collections::HashSet = unread_rows + .into_iter() + .filter(|u| chat_type_of(u, names) == "official_account") + .collect(); + if set.is_empty() { + // 没有未读公众号 → 直接空返回,避免打 biz 表扫描 + return Ok(json!({ "count": 0, "articles": [] })); + } + Some(set) + } else { + None + }; + + // 1. 从全部 biz shard 的 Name2Id 表收集 username,再推导 md5 -> username + let biz_paths2 = biz_paths.clone(); + let biz_usernames: HashSet = tokio::task::spawn_blocking(move || { + let mut usernames = HashSet::new(); + for biz_path in biz_paths2 { + let conn = Connection::open(&biz_path)?; + let mut stmt = conn.prepare( + "SELECT DISTINCT user_name FROM Name2Id \ + WHERE user_name IS NOT NULL AND user_name != ''", + )?; + let rows: Vec = stmt + .query_map([], |row| row.get::<_, String>(0))? + .filter_map(|r| r.ok()) + .collect(); + usernames.extend(rows); + } + Ok::<_, anyhow::Error>(usernames) + }) + .await??; + + // 构建 md5(username) -> username 映射 + let md5_to_uname: HashMap = biz_usernames + .iter() + .map(|u| (format!("{:x}", md5::compute(u.as_bytes())), u.clone())) + .collect(); + + // 2. 如果 指定了 --account,找到匹配的 username 列表 + let account_low = account.as_deref().map(|s| s.to_lowercase()); + let mut target_usernames: Option> = account_low.as_ref().map(|low| { + biz_usernames + .iter() + .filter(|u| { + let display = names.display(u); + display.to_lowercase().contains(low.as_str()) + || u.to_lowercase().contains(low.as_str()) + }) + .cloned() + .collect() + }); + + // --unread 与 --account 取交集(进一步缩小范围) + if let Some(ref unread_set) = unread_usernames { + target_usernames = Some(match target_usernames.take() { + Some(acc_list) => acc_list + .into_iter() + .filter(|u| unread_set.contains(u)) + .collect(), + None => unread_set.iter().cloned().collect(), + }); + // 交集为空 → 提前返回 + if target_usernames + .as_ref() + .map(|v| v.is_empty()) + .unwrap_or(false) + { + return Ok(json!({ "count": 0, "articles": [] })); + } + } + + // 3. 进行数据库查询 + let biz_paths3 = biz_paths; + let since2 = since; + let until2 = until; + let target_hashes: Option> = target_usernames.as_ref().map(|unames| { + unames + .iter() + .map(|u| format!("{:x}", md5::compute(u.as_bytes()))) + .collect() + }); + + let rows: Vec<(String, i64, i64, Vec, i64)> = tokio::task::spawn_blocking(move || { + let re = regex::Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap(); + let mut all_rows: Vec<(String, i64, i64, Vec, i64)> = Vec::new(); + + for biz_path in biz_paths3 { + let conn = Connection::open(&biz_path)?; + let mut stmt = conn.prepare( + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'", + )?; + let table_names: Vec = stmt + .query_map([], |row| row.get(0))? + .filter_map(|r| r.ok()) + .collect(); + + for tname in &table_names { + if !re.is_match(tname) { + continue; + } + let hash = &tname[4..]; + + // account 过滤 + if let Some(ref hashes) = target_hashes { + if !hashes.iter().any(|h| h == hash) { + continue; + } + } + + let username = md5_to_uname.get(hash).cloned().unwrap_or_default(); + + // 构建过滤条件 + let mut clauses: Vec = Vec::new(); + let mut params: Vec> = Vec::new(); + // local_type & 0xFFFFFFFF = 49 是 appmsg(公众号文章) + clauses.push("(local_type & 4294967295) = 49".to_string()); + if let Some(s) = since2 { + clauses.push("create_time >= ?".to_string()); + params.push(Box::new(s)); + } + if let Some(u) = until2 { + clauses.push("create_time <= ?".to_string()); + params.push(Box::new(u)); + } + let where_clause = format!("WHERE {}", clauses.join(" AND ")); + + let sql = format!( + "SELECT create_time, WCDB_CT_message_content, message_content \ + FROM [{}] {} ORDER BY create_time DESC", + tname, where_clause + ); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + if let Ok(mut inner_stmt) = conn.prepare(&sql) { + let msg_rows: Vec<_> = inner_stmt + .query_map(params_ref.as_slice(), |row| { + Ok(( + username.clone(), + row.get::<_, i64>(0)?, + row.get::<_, i64>(1).unwrap_or(0), + get_content_bytes(row, 2), + 0i64, + )) + }) + .map(|it| it.filter_map(|r| r.ok()).collect()) + .unwrap_or_default(); + all_rows.extend(msg_rows); + } + } + } + Ok::<_, anyhow::Error>(all_rows) + }) + .await??; + + // 4. 解压并解析 XML + let mut articles: Vec = Vec::new(); + for (username, recv_time, ct, content_bytes, _) in rows { + let content = decompress_message(&content_bytes, ct); + if content.is_empty() { + continue; + } + let items = parse_biz_xml_items(recv_time, &username, &content); + articles.extend(items); + } + + // 5. 按 pub_time DESC 排序 + articles.sort_by_key(|a| std::cmp::Reverse(a.pub_time)); + + // --unread 语义 A:每个公众号只保留最新 1 篇(已按 pub_time 排序,取首条即可) + if unread { + let mut seen = std::collections::HashSet::::new(); + articles.retain(|a| seen.insert(a.account_username.clone())); + } + + articles.truncate(limit); + + let results: Vec = articles + .into_iter() + .map(|a| { + let account_display = names.display(&a.account_username); + json!({ + "time": fmt_time(a.pub_time, "%Y-%m-%d %H:%M"), + "timestamp": a.pub_time, + "recv_time": a.recv_time, + "recv_time_str": fmt_time(a.recv_time, "%Y-%m-%d %H:%M"), + "account": account_display, + "account_username": a.account_username, + "title": a.title, + "url": a.url, + "digest": a.digest, + "cover_url": a.cover, + }) + }) + .collect(); + + Ok(json!({ "count": results.len(), "articles": results })) +} + +// ─── 附件(当前先支持图片)查询与提取 ───────────────────────────────── +// +// 设计要点: +// - `q_attachments` 只走 `Msg_` 表,按 `local_type & 0xFFFFFFFF IN (...)` 过滤 +// 出附件消息行,再编出 `attachment_id`。**不**去翻 `message_resource.db`,因为列出动作 +// 要可枚举几千条;resource lookup 留到 `q_extract` 才做。 +// - `q_extract` 走完整链:`AttachmentId` → `message_resource.db` 查 md5 → +// `/msg/attach/...` 找 .dat → 按 magic 分发到 v1/v2 decoder → 写盘。 +// - V2 image AES key 通过 `image_key::default_provider()` 拿(codex 后续填实现)。 +// 缺 key 时 V2 解码会返回明确错误,CLI 直接抛给用户。 + +/// 列出某会话内的附件消息(当前仅 image)。返回每条的 `attachment_id`, +/// 后续传给 `Extract` 才真正读 message_resource.db + 解密 .dat。 +pub async fn q_attachments( + db: &DbCache, + names: &Names, + chat: &str, + kinds: Option>, + limit: usize, + offset: usize, + since: Option, + until: Option, + with_meta: bool, + debug_source: bool, +) -> Result { + use crate::attachment::{AttachmentId, AttachmentKind}; + + let username = + resolve_username(chat, names).with_context(|| format!("找不到联系人: {}", chat))?; + let display = names.display(&username); + let chat_type = chat_type_of(&username, names); + let is_group = chat_type == "group"; + + // 解析 kinds → 低 32 bit local_type 集合 + let kind_filters: Vec<(AttachmentKind, i64)> = parse_attachment_kinds(kinds.as_deref())?; + if kind_filters.is_empty() { + anyhow::bail!("kinds 为空 — 当前至少传一种 image"); + } + let lo32_types: Vec = kind_filters.iter().map(|(_, t)| *t).collect(); + // local_type → AttachmentKind 反查(mask 完后定 kind) + let type_to_kind: HashMap = + kind_filters.iter().map(|(k, t)| (*t, *k)).collect(); + + let (shards, scanned) = find_msg_shards(db, names, &username, None, None).await?; + if shards.is_empty() { + anyhow::bail!("找不到 {} 的消息记录", display); + } + + // 群聊需要 sender 显示名 + let group_nicknames = if is_group { + load_group_nicknames(db, &username) + .await + .unwrap_or_default() + } else { + HashMap::new() + }; + + let mut all_rows: Vec<(i64, i64, i64, i64, String, String, i64, i64)> = Vec::new(); + let mut shard_hits = 0usize; + // 元组:(local_id, local_type_lo32, create_time, real_sender_id, sender_label, + // sender_username, ts_for_sort, db_idx) + // sender_username 是稳定 wxid,用来让 sender_contact_display / sender_group_nickname + // 落在 attachment row 上(消除"两个同名成员的图分不清谁发的"歧义)。 + for (db_idx, shard) in shards.iter().enumerate() { + let rel = shard.rel_key.clone(); + let tname = shard.table.clone(); + let uname = username.clone(); + let is_group2 = is_group; + let names_map = names.map.clone(); + let group_nicknames2 = group_nicknames.clone(); + let lo32_types2 = lo32_types.clone(); + let since2 = since; + let until2 = until; + // per-DB 软上限避免巨群全量加载 + let per_db_cap = (offset + limit).max(limit) * 2; + let db_idx2 = db_idx as i64; + + let Some((conn, _)) = db.open_query_conn(&rel).await? else { + continue; + }; + + let rows: Vec<(i64, i64, i64, i64, String, String, i64, i64)> = + tokio::task::spawn_blocking(move || { + let id2u = load_id2u(&conn); + + // local_type 在 DB 里可能带高位 flag,过滤要 mask 低 32 bit + let placeholders = lo32_types2 + .iter() + .map(|_| "?") + .collect::>() + .join(","); + let mut clauses: Vec = + vec![format!("(local_type & 4294967295) IN ({})", placeholders)]; + let mut params: Vec> = lo32_types2 + .iter() + .map(|t| Box::new(*t) as Box) + .collect(); + if let Some(s) = since2 { + clauses.push("create_time >= ?".into()); + params.push(Box::new(s)); + } + if let Some(u) = until2 { + clauses.push("create_time <= ?".into()); + params.push(Box::new(u)); + } + let where_clause = format!("WHERE {}", clauses.join(" AND ")); + + let sql = format!( + "SELECT local_id, local_type, create_time, real_sender_id, + message_content, WCDB_CT_message_content + FROM [{}] {} ORDER BY create_time DESC LIMIT ?", + tname, where_clause + ); + params.push(Box::new(per_db_cap as i64)); + + let params_ref: Vec<&dyn rusqlite::types::ToSql> = + params.iter().map(|p| p.as_ref()).collect(); + let mut stmt = conn.prepare(&sql)?; + let rows: Vec<(i64, i64, i64, i64, String, String, i64, i64)> = stmt + .query_map(params_ref.as_slice(), |row| { + let local_id: i64 = row.get(0)?; + let raw_type: i64 = row.get(1)?; + let lo32 = (raw_type as u64 & 0xFFFFFFFF) as i64; + let ts: i64 = row.get(2)?; + let real_sender_id: i64 = row.get(3)?; + let content_bytes = get_content_bytes(row, 4); + let ct: i64 = row.get::<_, i64>(5).unwrap_or(0); + let content = decompress_message(&content_bytes, ct); + let (sender, sender_uname) = if is_group2 { + ( + sender_label( + real_sender_id, + &content, + true, + &uname, + &id2u, + &names_map, + &group_nicknames2, + ), + sender_username( + real_sender_id, + &content, + true, + &uname, + &id2u, + ), + ) + } else { + (String::new(), String::new()) + }; + Ok((local_id, lo32, ts, real_sender_id, sender, sender_uname, ts, db_idx2)) + })? + .filter_map(|r| r.ok()) + .collect(); + Ok::<_, anyhow::Error>(rows) + }) + .await??; + if !rows.is_empty() { + shard_hits += 1; + } + all_rows.extend(rows); + } + + // 全局按 ts DESC 排序后分页(ts_for_sort 在 tuple index 6) + all_rows.sort_by_key(|r| std::cmp::Reverse(r.6)); + let paged: Vec<_> = all_rows.into_iter().skip(offset).take(limit).collect(); + + // 翻成 JSON + let mut results: Vec = Vec::with_capacity(paged.len()); + for (local_id, lo32, ts, _real_sender_id, sender, sender_uname, _ts2, _db_idx) in paged { + let kind = type_to_kind + .get(&lo32) + .copied() + .unwrap_or(AttachmentKind::Image); // 理论不会 fallthrough + let id = AttachmentId { + v: 1, + chat: username.clone(), + local_id, + create_time: ts, + kind, + db: None, + }; + let id_str = id.encode()?; + + let mut row = json!({ + "attachment_id": id_str, + "kind": kind.as_str(), + "type": fmt_type(lo32), + "local_id": local_id, + "timestamp": ts, + "time": fmt_time(ts, "%Y-%m-%d %H:%M"), + }); + if is_group && !sender.is_empty() { + row["sender"] = Value::String(sender); + } + add_sender_identity(&mut row, is_group, &sender_uname, &names.map, &group_nicknames); + results.push(row); + } + let unknown_shards = current_unknown_shards(db, names); + let session_ts = session_last_timestamp(db, &username).await; + let meta = meta_for_shards( + scanned, + &shards, + shard_hits, + unknown_shards, + session_ts, + true, + with_meta, + debug_source, + ); + + Ok(json!({ + "chat": display, + "username": username, + "is_group": is_group, + "chat_type": chat_type, + "count": results.len(), + "attachments": results, + "meta": meta, + })) +} + +/// 解码 attachment_id → 查 message_resource.db → 找本地 .dat → 解密 → 写盘。 +pub async fn q_extract( + db: &DbCache, + _names: &Names, + attachment_id: &str, + output: &str, + overwrite: bool, +) -> Result { + use crate::attachment::{ + attachment_id::AttachmentId, + decoder::{self, V2KeyMaterial}, + image_key, resolver, + }; + + let id = AttachmentId::decode(attachment_id) + .context("解析 attachment_id 失败(不是合法 base64url(json)?)")?; + + let output_path = std::path::PathBuf::from(output); + if output_path.exists() && !overwrite { + anyhow::bail!( + "目标已存在:{}(加 --overwrite 覆盖)", + output_path.display() + ); + } + if let Some(parent) = output_path.parent() { + if !parent.as_os_str().is_empty() { + tokio::fs::create_dir_all(parent) + .await + .with_context(|| format!("创建输出目录失败:{}", parent.display()))?; + } + } + + // 1) 拿 message_resource.db + let resource_path = db + .get("message/message_resource.db") + .await? + .context("无法解密 message_resource.db(请确认 all_keys.json 包含该 DB 的密钥)")?; + + // 2) 推 wxchat_base = db_dir.parent(),再拼 attach_root + let wxchat_base = db + .db_dir() + .parent() + .ok_or_else(|| anyhow::anyhow!("db_dir 没有 parent,无法推断 xwechat_files 根目录"))? + .to_path_buf(); + let attach_root = resolver::attach_root_for(&wxchat_base); + + // 3) blocking pool 跑 resolver + 读盘 + 解码 + let id_for_task = id.clone(); + let resource_path2 = resource_path.clone(); + let attach_root2 = attach_root.clone(); + let wxchat_base2 = wxchat_base.clone(); + let output_path2 = output_path.clone(); + + let report: Value = tokio::task::spawn_blocking(move || -> Result { + let resolved = resolver::resolve_blocking(&id_for_task, &resource_path2, &attach_root2)?; + + let dat_bytes = std::fs::read(&resolved.dat_path) + .with_context(|| format!("读取 .dat 失败:{}", resolved.dat_path.display()))?; + + // V2 image key — 平台相关。`ImageKeyMaterial` 同时给 aes_key + xor_key。 + // xor_key 不能硬编码 0x88:实测 macOS 真实账号上是 `uin & 0xff` 派生的(0xa2 等), + // 所以这里桥接时必须把 provider 的 xor_key 透传给 V2KeyMaterial。 + // 缺 key 时让 decoder 自己抛带诊断的错。 + let provider = image_key::default_provider(); + let key_material = if let Some(p) = provider.as_ref() { + // 从 wxchat_base 末段拿 wxid + let wxid = wxchat_base2 + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string(); + if wxid.is_empty() { + None + } else { + match p.get_key(&wxid) { + Ok(km) => Some(km), + Err(e) => { + eprintln!( + "[extract] image key 提取失败 (wxid={}): {} — V2 文件将无法解码", + wxid, e + ); + None + } + } + } + } else { + None + }; + let v2_key = match key_material.as_ref() { + Some(km) => V2KeyMaterial { + aes_key: Some(&km.aes_key), + xor_key: km.xor_key, + }, + None => V2KeyMaterial::default(), + }; + + let decoded = decoder::dispatch(&dat_bytes, v2_key)?; + + // 写盘 + std::fs::write(&output_path2, &decoded.data) + .with_context(|| format!("写出文件失败:{}", output_path2.display()))?; + + // 注意:不要在这里塞 `ok: true`。dispatch 会用 Response::ok(v) 包一层, + // Response 的 `data: Value` 字段是 #[serde(flatten)] 写出的,本 payload + // 的 `ok` 会和 Response 自带的 `ok` 在线上拼成两个同名 key,CLI 反序列化时 + // serde_json 直接报 "duplicate field",业务请求看上去像 daemon 解析失败。 + Ok(json!({ + "kind": id_for_task.kind.as_str(), + "md5": resolved.md5, + "dat_path": resolved.dat_path.display().to_string(), + "dat_size": resolved.size, + "output": output_path2.display().to_string(), + "output_size": decoded.data.len(), + "format": decoded.format, + "decoder": decoded.decoder, + })) + }) + .await??; + + Ok(report) +} + +/// 解析 `kinds` 参数到 `(AttachmentKind, lo32_local_type)` 列表。 +/// 当前只支持 image;命令名保留成 `attachments` 是为了后续扩到其他附件类型时不 break CLI。 +fn parse_attachment_kinds( + kinds: Option<&[String]>, +) -> Result> { + use crate::attachment::AttachmentKind; + let raw = kinds.unwrap_or(&[]); + if raw.is_empty() { + return Ok(vec![(AttachmentKind::Image, 3)]); + } + let mut out: Vec<(AttachmentKind, i64)> = Vec::with_capacity(raw.len()); + let mut seen = HashSet::<&'static str>::new(); + for k in raw { + let (kind, t): (AttachmentKind, i64) = match k.to_ascii_lowercase().as_str() { + "image" | "img" => (AttachmentKind::Image, 3), + "voice" | "audio" | "video" | "file" => { + anyhow::bail!( + "当前只支持 image 提取;video/file/voice 的资源路径与 decoder 还没接通" + ) + } + other => anyhow::bail!("未知附件类型:{}(当前仅支持 image)", other), + }; + if seen.insert(kind.as_str()) { + out.push((kind, t)); + } + } + Ok(out) +} + +#[cfg(test)] +mod biz_tests { + use super::*; + + #[test] + fn extract_cdata_normal() { + let xml = "<![CDATA[TencentResearch]]>"; + assert_eq!(extract_cdata(xml, "title"), Some("TencentResearch".into())); + } + + #[test] + fn extract_cdata_empty() { + let xml = ""; + assert_eq!(extract_cdata(xml, "cover"), None); + } + + #[test] + fn extract_cdata_url() { + let xml = ""; + let result = extract_cdata(xml, "url"); + assert!(result.is_some()); + let url = result.unwrap(); + assert!(url.starts_with("http://mp.weixin.qq.com")); + assert!(!url.contains("CDATA")); + } + + #[test] + fn extract_cdata_no_cdata_wrapper() { + let xml = "1700000000"; + assert_eq!(extract_cdata(xml, "pub_time"), Some("1700000000".into())); + } + + #[test] + fn parse_biz_xml_items_single_article() { + let xml = r#" + <![CDATA[Test Article Title]]> + + + + 1700000000 + "#; + + let items = parse_biz_xml_items(1699999999, "gh_test123", xml); + assert_eq!(items.len(), 1); + assert_eq!(items[0].title, "Test Article Title"); + assert_eq!(items[0].url, "http://mp.weixin.qq.com/s?test=1"); + assert_eq!(items[0].digest, "Test Digest"); + assert_eq!(items[0].pub_time, 1700000000); + assert_eq!(items[0].account_username, "gh_test123"); + } + + #[test] + fn parse_biz_xml_items_skips_no_url() { + let xml = r#" + <![CDATA[Has Title No URL]]> + + 1700000001 + "#; + let items = parse_biz_xml_items(1700000001, "gh_test", xml); + assert_eq!(items.len(), 0); + } + + #[test] + fn parse_biz_xml_items_multi_article() { + let xml = r#" + + <![CDATA[Article 1]]> + + 1700000010 + + + <![CDATA[Article 2]]> + + 1700000020 + + "#; + let items = parse_biz_xml_items(1700000000, "gh_multi", xml); + assert_eq!(items.len(), 2); + assert_eq!(items[0].title, "Article 1"); + assert_eq!(items[1].title, "Article 2"); + } + + #[test] + fn parse_biz_xml_items_pub_time_fallback() { + // When pub_time is missing, should fall back to recv_time + let xml = r#" + <![CDATA[No PubTime]]> + + "#; + let items = parse_biz_xml_items(1700000099, "gh_fallback", xml); + assert_eq!(items.len(), 1); + assert_eq!(items[0].pub_time, 1700000099); // falls back to recv_time + } +} + +#[cfg(test)] +mod group_nickname_tests { + use super::*; + + fn varint(mut value: u64) -> Vec { + let mut out = Vec::new(); + loop { + let mut byte = (value & 0x7f) as u8; + value >>= 7; + if value != 0 { + byte |= 0x80; + } + out.push(byte); + if value == 0 { + return out; + } + } + } + + fn len_field(field_no: u64, bytes: &[u8]) -> Vec { + let mut out = varint((field_no << 3) | 2); + out.extend(varint(bytes.len() as u64)); + out.extend(bytes); + out + } + + fn string_field(field_no: u64, value: &str) -> Vec { + len_field(field_no, value.as_bytes()) + } + + fn member_chunk(username: &str, group_nickname: &str) -> Vec { + let mut member = Vec::new(); + member.extend(string_field(1, username)); + member.extend(string_field(2, group_nickname)); + len_field(1, &member) + } + + #[test] + fn parses_group_nickname_member_chunks() { + let mut ext_buffer = Vec::new(); + ext_buffer.extend(member_chunk("wxid_alice", "Alice In Group")); + ext_buffer.extend(member_chunk("bob_123456", "Bob Card")); + + let nicknames = parse_group_nickname_map(&ext_buffer, None); + + assert_eq!( + nicknames.get("wxid_alice").map(String::as_str), + Some("Alice In Group") + ); + assert_eq!( + nicknames.get("bob_123456").map(String::as_str), + Some("Bob Card") + ); + } + + #[test] + fn target_filter_anchors_member_username_choice() { + let mut member = Vec::new(); + member.extend(string_field(3, "candidate_name")); + member.extend(string_field(4, "wxid_target")); + member.extend(string_field(2, "Target Card")); + let ext_buffer = len_field(1, &member); + let targets = HashSet::from(["wxid_target".to_string()]); + + let nicknames = parse_group_nickname_map(&ext_buffer, Some(&targets)); + + assert_eq!( + nicknames.get("wxid_target").map(String::as_str), + Some("Target Card") + ); + assert!(!nicknames.contains_key("candidate_name")); + } + + #[test] + fn ignores_non_card_string_fields_as_group_nicknames() { + let mut ext_buffer = Vec::new(); + + let mut member_without_card = Vec::new(); + member_without_card.extend(string_field(1, "wxid_alice")); + member_without_card.extend(string_field(4, "owner_or_inviter")); + ext_buffer.extend(len_field(1, &member_without_card)); + + let mut member_with_card = Vec::new(); + member_with_card.extend(string_field(1, "wxid_bob")); + member_with_card.extend(string_field(2, "Bob In Group")); + member_with_card.extend(string_field(4, "owner_or_inviter")); + ext_buffer.extend(len_field(1, &member_with_card)); + + let nicknames = parse_group_nickname_map(&ext_buffer, None); + + assert!(!nicknames.contains_key("wxid_alice")); + assert_eq!( + nicknames.get("wxid_bob").map(String::as_str), + Some("Bob In Group") + ); + } + + #[test] + fn group_top_senders_keeps_duplicate_display_names_separate() { + let sender_counts = + HashMap::from([("wxid_alice".to_string(), 7), ("wxid_bob".to_string(), 3)]); + let names = HashMap::from([ + ("wxid_alice".to_string(), "Alice Contact".to_string()), + ("wxid_bob".to_string(), "Bob Contact".to_string()), + ]); + let group_nicknames = HashMap::from([ + ("wxid_alice".to_string(), "同名".to_string()), + ("wxid_bob".to_string(), "同名".to_string()), + ]); + + let top = group_top_senders(&sender_counts, &names, &group_nicknames, 10); + + assert_eq!(top.len(), 2); + assert_eq!(top[0]["sender"].as_str(), Some("同名")); + assert_eq!(top[0]["sender_username"].as_str(), Some("wxid_alice")); + assert_eq!(top[0]["sender_contact_display"].as_str(), Some("Alice Contact")); + assert_eq!(top[0]["sender_group_nickname"].as_str(), Some("同名")); + assert_eq!(top[0]["count"].as_i64(), Some(7)); + assert_eq!(top[1]["sender"].as_str(), Some("同名")); + assert_eq!(top[1]["sender_username"].as_str(), Some("wxid_bob")); + assert_eq!(top[1]["sender_contact_display"].as_str(), Some("Bob Contact")); + assert_eq!(top[1]["sender_group_nickname"].as_str(), Some("同名")); + assert_eq!(top[1]["count"].as_i64(), Some(3)); + } +} + +#[cfg(test)] +mod sns_tests { + use super::*; + + fn make_post_xml( + create_time: &str, + desc: &str, + username_tag: Option<&str>, + media: usize, + location: Option<&str>, + ) -> String { + let username = username_tag + .map(|u| format!("{}", u)) + .unwrap_or_default(); + let media_tags = "2".repeat(media); + let content_object = if media > 0 { + format!( + "{}", + media_tags + ) + } else { + String::new() + }; + let loc = location + .map(|p| format!(r#""#, p)) + .unwrap_or_default(); + format!( + "{}{}{}{}{}", + username, create_time, desc, content_object, loc + ) + } + + #[test] + fn parse_uses_user_name_column_when_present() { + let xml = make_post_xml("1700000000", "hello", Some("wxid_xml"), 0, None); + let p = parse_post_xml(1, "wxid_column", &xml); + assert_eq!(p.author_username, "wxid_column"); + assert_eq!(p.create_time, 1700000000); + assert_eq!(p.content, "hello"); + assert_eq!(p.media.len(), 0); + assert_eq!(p.location, ""); + } + + #[test] + fn parse_falls_back_to_xml_username_when_column_empty() { + let xml = make_post_xml("1700000001", "world", Some("wxid_xml_only"), 0, None); + let p = parse_post_xml(2, "", &xml); + assert_eq!(p.author_username, "wxid_xml_only"); + } + + #[test] + fn parse_handles_missing_create_time() { + let xml = "x"; + let p = parse_post_xml(3, "wxid", xml); + assert_eq!(p.create_time, 0); + assert_eq!(p.content, "x"); + } + + #[test] + fn parse_counts_media_and_extracts_location() { + let xml = make_post_xml("1700000002", "post", None, 3, Some("Wuxi")); + let p = parse_post_xml(4, "wxid", &xml); + assert_eq!(p.media.len(), 3); + assert_eq!(p.location, "Wuxi"); + } + + #[test] + fn parse_when_both_column_and_xml_username_empty_returns_empty_author() { + let xml = "1700000003orphan"; + let p = parse_post_xml(5, "", xml); + assert_eq!(p.author_username, ""); + } + + #[test] + fn parse_decodes_xml_entities_in_content() { + // 单 DOM 解析的副作用:roxmltree 自动把 < / & / " 等还原成原字符; + // 旧版 extract_xml_text 字符串扫描不解码,会把 "<world>" 原样输出。 + // 新版语义对下游更正确(拿到的就是用户真实内容),把这个行为锁进测试。 + let xml = "Hello <world> & friends"; + let p = parse_post_xml(6, "wxid", xml); + assert_eq!(p.content, "Hello & friends"); + } + + #[test] + fn parse_malformed_xml_falls_back_to_string_fields_when_column_present() { + let xml = "1700000007A & B", + "5", + "https://mp.weixin.qq.com/s?__biz=MzI4&mid=2247&idx=1", + "" + ); + assert_eq!( + extract_appmsg_url(xml).as_deref(), + Some("https://mp.weixin.qq.com/s?__biz=MzI4&mid=2247&idx=1") + ); + } + + #[test] + fn extract_appmsg_url_strips_group_prefix_and_cdata() { + let xml = concat!( + "wxid_sender:\n", + "", + "5", + "", + "" + ); + assert_eq!( + extract_appmsg_url(xml).as_deref(), + Some("https://example.com/x?a=1&b=2") + ); + } + + #[test] + fn extract_appmsg_url_falls_back_to_url1() { + let xml = concat!( + "", + "5", + "https://example.com/fallback", + "" + ); + assert_eq!( + extract_appmsg_url(xml).as_deref(), + Some("https://example.com/fallback") + ); + } + + #[test] + fn extract_appmsg_url_ignores_non_http_values() { + let xml = concat!( + "", + "5", + "weixin://bizmsgmenu?msgmenucontent=foo", + "" + ); + assert_eq!(extract_appmsg_url(xml), None); + } + + #[test] + fn extract_appmsg_url_ignores_refermsg() { + let xml = concat!( + "", + "57", + "https://example.com/nested", + "" + ); + assert_eq!(extract_appmsg_url(xml), None); + } + + #[test] + fn extract_favorite_url_reads_link_tag() { + let xml = concat!( + "", + "5", + "", + "" + ); + assert_eq!( + extract_favorite_url(xml).as_deref(), + Some("https://mp.weixin.qq.com/s?__biz=foo&mid=1") + ); + } + + #[test] + fn extract_favorite_url_ignores_non_http_values() { + let xml = concat!( + "", + "5", + "weixin://favorites/item/1", + "" + ); + assert_eq!(extract_favorite_url(xml), None); + } + + fn media_object(value: &Value) -> &serde_json::Map { + value.as_object().expect("media entry should be an object") + } + + #[test] + fn single_image_media() { + let xml = r#" + + + + + + 2 + https://szmmsns.qpic.cn/<redacted>/image.jpg + https://szmmsns.qpic.cn/<redacted>/thumb.jpg + + + + + + + "#; + + let media = parse_post_media(xml); + assert_eq!(media.len(), 1); + + let item = media_object(&media[0]); + assert_eq!(item.get("type").and_then(Value::as_str), Some("2")); + assert_eq!( + item.get("url").and_then(Value::as_str), + Some("https://szmmsns.qpic.cn//image.jpg") + ); + assert_eq!( + item.get("thumb").and_then(Value::as_str), + Some("https://szmmsns.qpic.cn//thumb.jpg") + ); + assert_eq!(item.get("url_enc_idx").and_then(Value::as_str), Some("1")); + assert_eq!( + item.get("url_key").and_then(Value::as_str), + Some("placeholder-key") + ); + assert_eq!( + item.get("url_token").and_then(Value::as_str), + Some("placeholder-token") + ); + assert_eq!( + item.get("md5").and_then(Value::as_str), + Some("placeholder-md5") + ); + assert_eq!(item.get("width").and_then(Value::as_i64), Some(1440)); + assert_eq!(item.get("height").and_then(Value::as_i64), Some(1080)); + assert_eq!(item.get("total_size").and_then(Value::as_i64), Some(123456)); + } + + #[test] + fn three_images_media() { + let xml = r#" + + + + + + 2 + 10 + https://szmmsns.qpic.cn/<redacted>/image-1.jpg + https://szmmsns.qpic.cn/<redacted>/thumb-1.jpg + + + + 2 + 11 + https://szmmsns.qpic.cn/<redacted>/image-2.jpg + https://szmmsns.qpic.cn/<redacted>/thumb-2.jpg + + + + 6 + https://szmmsns.qpic.cn/<redacted>/image-3.jpg + https://szmmsns.qpic.cn/<redacted>/thumb-3.jpg + + + + + + + "#; + + let media = parse_post_media(xml); + assert_eq!(media.len(), 3); + + let first = media_object(&media[0]); + assert_eq!(first.get("sub_type").and_then(Value::as_str), Some("10")); + assert_eq!( + first.get("url_key").and_then(Value::as_str), + Some("placeholder-key-1") + ); + + let second = media_object(&media[1]); + assert_eq!(second.get("sub_type").and_then(Value::as_str), Some("11")); + assert_eq!(second.get("width").and_then(Value::as_i64), Some(300)); + + let third = media_object(&media[2]); + assert_eq!(third.get("type").and_then(Value::as_str), Some("6")); + assert_eq!( + third.get("thumb_key").and_then(Value::as_str), + Some("placeholder-thumb-key-3") + ); + } + + #[test] + fn video_media() { + let xml = r#" + + + + + + 15 + https://szmmsns.qpic.cn/<redacted>/video.mp4 + https://szmmsns.qpic.cn/<redacted>/video-thumb.jpg + + <placeholder-video-md5> + 37 + + + + + + "#; + + let media = parse_post_media(xml); + assert_eq!(media.len(), 1); + + let item = media_object(&media[0]); + assert_eq!( + item.get("video_md5").and_then(Value::as_str), + Some("") + ); + assert_eq!(item.get("video_duration").and_then(Value::as_i64), Some(37)); + assert!(!item.contains_key("total_size")); + } + + #[test] + fn text_only_post() { + let without_media_list = r#" + + + + 1 + + + + "#; + let empty_media_list = r#" + + + + + + + + "#; + + assert!(parse_post_media(without_media_list).is_empty()); + assert!(parse_post_media(empty_media_list).is_empty()); + } + + #[test] + fn malformed_xml() { + let xml = r#" + + + + + + 2 + + + + + "#; + + assert!(parse_post_media(xml).is_empty()); + } + + #[test] + fn size_without_total_size_omits_total_size_key() { + let xml = r#" + + + + + + 2 + + + + + + + "#; + + let media = parse_post_media(xml); + assert_eq!(media.len(), 1); + let item = media_object(&media[0]); + assert_eq!(item.get("width").and_then(Value::as_i64), Some(640)); + assert_eq!(item.get("height").and_then(Value::as_i64), Some(480)); + assert!(!item.contains_key("total_size")); + } +} diff --git a/src/daemon/server.rs b/src/daemon/server.rs new file mode 100644 index 0000000..22d0cb9 --- /dev/null +++ b/src/daemon/server.rs @@ -0,0 +1,450 @@ +use anyhow::Result; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +use super::cache::DbCache; +use super::query::Names; +use crate::ipc::{Request, Response}; + +/// 启动 IPC server(Unix socket / Windows named pipe) +pub async fn serve(db: Arc, names: Arc>>) -> Result<()> { + #[cfg(unix)] + serve_unix(db, names).await?; + #[cfg(windows)] + serve_windows(db, names).await?; + Ok(()) +} + +#[cfg(unix)] +async fn serve_unix(db: Arc, names: Arc>>) -> Result<()> { + use tokio::net::UnixListener; + let sock_path = crate::config::sock_path(); + + // 删除旧 socket 文件 + if sock_path.exists() { + let _ = tokio::fs::remove_file(&sock_path).await; + } + + let listener = UnixListener::bind(&sock_path)?; + // 设置权限 0600 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&sock_path, std::fs::Permissions::from_mode(0o600))?; + } + + eprintln!("[server] 监听 {}", sock_path.display()); + + loop { + let (stream, _) = listener.accept().await?; + let db2 = Arc::clone(&db); + let names2 = Arc::clone(&names); + + tokio::spawn(async move { + if let Err(e) = handle_connection_unix(stream, db2, names2).await { + eprintln!("[server] 连接处理错误: {}", e); + } + }); + } +} + +#[cfg(unix)] +async fn handle_connection_unix( + stream: tokio::net::UnixStream, + db: Arc, + names: Arc>>, +) -> Result<()> { + let (reader, mut writer) = stream.into_split(); + let mut lines = BufReader::new(reader).lines(); + + let line = match lines.next_line().await? { + Some(l) => l, + None => return Ok(()), + }; + + // 解析请求 + let req: Request = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + let resp = Response::err(format!("JSON 解析错误: {}", e)); + writer.write_all(resp.to_json_line()?.as_bytes()).await?; + return Ok(()); + } + }; + + let resp = dispatch(req, &db, &names).await; + writer.write_all(resp.to_json_line()?.as_bytes()).await?; + Ok(()) +} + +#[cfg(windows)] +async fn serve_windows( + db: Arc, + names: Arc>>, +) -> Result<()> { + use interprocess::local_socket::{tokio::prelude::*, GenericNamespaced, ListenerOptions}; + + // interprocess 的 GenericNamespaced 在 Windows 上会自动拼接 `\\.\pipe\` 前缀, + // 这里必须传相对名;client 端用 `\\.\pipe\wx-cli-daemon` 直接打开可以对上 + let name = "wx-cli-daemon".to_ns_name::()?; + let opts = ListenerOptions::new().name(name); + let listener = opts.create_tokio()?; + + eprintln!("[server] 监听 \\\\.\\pipe\\wx-cli-daemon"); + + loop { + let conn = listener.accept().await?; + let db2 = Arc::clone(&db); + let names2 = Arc::clone(&names); + + tokio::spawn(async move { + if let Err(e) = handle_connection_windows(conn, db2, names2).await { + eprintln!("[server] 连接处理错误: {}", e); + } + }); + } +} + +#[cfg(windows)] +async fn handle_connection_windows( + conn: interprocess::local_socket::tokio::Stream, + db: Arc, + names: Arc>>, +) -> Result<()> { + let (reader, mut writer) = tokio::io::split(conn); + let mut lines = BufReader::new(reader).lines(); + + let line = match lines.next_line().await? { + Some(l) => l, + None => return Ok(()), + }; + + let req: Request = match serde_json::from_str(&line) { + Ok(r) => r, + Err(e) => { + let resp = Response::err(format!("JSON 解析错误: {}", e)); + writer.write_all(resp.to_json_line()?.as_bytes()).await?; + return Ok(()); + } + }; + + let resp = dispatch(req, &db, &names).await; + writer.write_all(resp.to_json_line()?.as_bytes()).await?; + Ok(()) +} + +async fn dispatch(req: Request, db: &DbCache, names: &tokio::sync::RwLock>) -> Response { + use super::query; + use crate::ipc::Request::*; + + // 取 guard → O(1) clone Arc → 立即 drop 锁。后续 await 期间不持有锁, + // 多个并发 IPC 请求可以真正并行。Names 本身不可变(由 daemon 启动时 + // 一次性构建),共享 Arc 即可。 + let names_arc: Arc = { + let guard = names.read().await; + Arc::clone(&*guard) + }; + + match req { + Ping => Response::ok(serde_json::json!({ "pong": true })), + Sessions { + limit, + with_meta, + debug_source, + } => match query::q_sessions(db, &names_arc, limit, with_meta, debug_source).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + }, + History { + chat, + limit, + offset, + since, + until, + after_ts, + before_ts, + msg_type, + with_meta, + debug_source, + } => { + match query::q_history( + db, + &names_arc, + &chat, + limit, + offset, + since, + until, + after_ts, + before_ts, + msg_type, + with_meta, + debug_source, + ) + .await + { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + Search { + keyword, + chats, + limit, + since, + until, + msg_type, + with_meta, + debug_source, + } => { + match query::q_search( + db, + &names_arc, + &keyword, + chats, + limit, + since, + until, + msg_type, + with_meta, + debug_source, + ) + .await + { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + Contacts { query, limit } => { + match query::q_contacts(&names_arc, query.as_deref(), limit).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + Unread { + limit, + filter, + with_meta, + debug_source, + } => match query::q_unread(db, &names_arc, limit, filter, with_meta, debug_source).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + }, + Members { chat } => match query::q_members(db, &names_arc, &chat).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + }, + NewMessages { + state, + limit, + with_meta, + debug_source, + } => { + match query::q_new_messages(db, &names_arc, state, limit, with_meta, debug_source).await + { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + Favorites { + limit, + fav_type, + query, + } => match query::q_favorites(db, limit, fav_type, query).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + }, + Stats { + chat, + since, + until, + with_meta, + debug_source, + } => { + match query::q_stats(db, &names_arc, &chat, since, until, with_meta, debug_source).await + { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + SnsNotifications { + limit, + since, + until, + include_read, + } => { + match query::q_sns_notifications(db, &names_arc, limit, since, until, include_read) + .await + { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + SnsFeed { + limit, + since, + until, + user, + } => match query::q_sns_feed(db, &names_arc, limit, since, until, user.as_deref()).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + }, + SnsSearch { + keyword, + limit, + since, + until, + user, + } => { + match query::q_sns_search( + db, + &names_arc, + &keyword, + limit, + since, + until, + user.as_deref(), + ) + .await + { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + Timeline { + limit, + offset, + since, + until, + after_ts, + msg_type, + with_meta, + debug_source, + } => { + match query::q_timeline( + db, + &names_arc, + limit, + offset, + since, + until, + after_ts, + msg_type, + with_meta, + debug_source, + ) + .await + { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + ReloadConfig => match reload_config(db, names).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + }, + BizArticles { + limit, + account, + since, + until, + unread, + } => { + match query::q_biz_articles(db, &names_arc, limit, account, since, until, unread).await + { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + Attachments { + chat, + kinds, + limit, + offset, + since, + until, + with_meta, + debug_source, + } => { + match query::q_attachments( + db, + &names_arc, + &chat, + kinds, + limit, + offset, + since, + until, + with_meta, + debug_source, + ) + .await + { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + } + } + Extract { + attachment_id, + output, + overwrite, + } => match query::q_extract(db, &names_arc, &attachment_id, &output, overwrite).await { + Ok(v) => Response::ok(v), + Err(e) => Response::err(e.to_string()), + }, + } +} + +/// 重新加载 all_keys.json,并刷新 Names(msg_db_keys / contact 显示名)。 +async fn reload_config( + db: &DbCache, + names: &tokio::sync::RwLock>, +) -> Result { + use super::{collect_db_keys, extract_keys, is_biz_msg_db_key, is_msg_db_key}; + use crate::config; + + let cfg = config::load_config()?; + let keys_content = tokio::fs::read_to_string(&cfg.keys_file) + .await + .map_err(|e| anyhow::anyhow!("读取密钥文件 {:?} 失败: {}", cfg.keys_file, e))?; + let keys_raw: serde_json::Value = serde_json::from_str(&keys_content)?; + let all_keys = extract_keys(&keys_raw); + let key_count = all_keys.len(); + db.replace_keys(all_keys.clone()).await; + + let msg_db_keys = collect_db_keys(&all_keys, is_msg_db_key); + let biz_msg_db_keys = collect_db_keys(&all_keys, is_biz_msg_db_key); + + let names_raw = super::query::load_names(db).await.unwrap_or_else(|e| { + eprintln!("[server] reload: 加载联系人失败: {}", e); + Names { + map: Default::default(), + md5_to_uname: Default::default(), + msg_db_keys: Vec::new(), + biz_msg_db_keys: Vec::new(), + verify_flags: Default::default(), + } + }); + let mut new_names = names_raw; + new_names.msg_db_keys = msg_db_keys.clone(); + new_names.biz_msg_db_keys = biz_msg_db_keys; + let contact_count = new_names.map.len(); + { + let mut guard = names.write().await; + *guard = Arc::new(new_names); + } + + eprintln!( + "[server] ReloadConfig: keys={} msg_shards={} contacts={}", + key_count, + msg_db_keys.len(), + contact_count + ); + + Ok(serde_json::json!({ + "reloaded": true, + "keys": key_count, + "msg_shards": msg_db_keys.len(), + "contacts": contact_count, + })) +} diff --git a/src/daemon/shard_meta.rs b/src/daemon/shard_meta.rs new file mode 100644 index 0000000..627e91f --- /dev/null +++ b/src/daemon/shard_meta.rs @@ -0,0 +1,237 @@ +//! 消息分片时间元数据:避免 history/search 对每个 message_N.db 全量解密。 +//! +//! 持久化到 `~/.wx-cli/shard-meta.json`。 +//! **按 talker 表(Msg_)分别记录 min/max**,避免用 A 会话的时间窗错误跳过仍含 B 会话消息的分片。 + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::config; + +const META_FILE: &str = "shard-meta.json"; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TableBound { + #[serde(default)] + pub min_ts: i64, + #[serde(default)] + pub max_ts: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ShardMetaEntry { + /// 旧版字段:分片级 min/max。仅作兼容读取;路由改用 `tables`。 + #[serde(default)] + pub min_ts: i64, + #[serde(default)] + pub max_ts: i64, + /// `Timestamp` 表读到的分片起点(若有) + #[serde(default)] + pub shard_start: Option, + /// 加密源文件 mtime(纳秒),用于判断是否过期 + #[serde(default)] + pub source_mtime_ns: u64, + /// 是否确认某 talker 表不存在(可选加速,key 为 table name) + #[serde(default)] + pub missing_tables: Vec, + /// 按表记录的时间窗:`Msg_` -> bound + #[serde(default)] + pub tables: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ShardMetaFile { + /// rel_key -> meta,如 `message/message_0.db` + #[serde(default)] + pub shards: HashMap, + #[serde(default)] + pub written_at_ns: u128, +} + +pub fn meta_path() -> PathBuf { + config::cli_dir().join(META_FILE) +} + +pub fn load() -> ShardMetaFile { + let path = meta_path(); + let Ok(text) = std::fs::read_to_string(&path) else { + return ShardMetaFile::default(); + }; + serde_json::from_str(&text).unwrap_or_default() +} + +pub fn save(meta: &ShardMetaFile) { + let path = meta_path(); + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let mut out = meta.clone(); + out.written_at_ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + if let Ok(json) = serde_json::to_string_pretty(&out) { + let tmp = path.with_extension("json.tmp"); + if std::fs::write(&tmp, json).is_ok() { + let _ = std::fs::rename(tmp, path); + } + } +} + +pub fn source_mtime_ns(path: &Path) -> u64 { + std::fs::metadata(path) + .and_then(|m| m.modified()) + .map(|t| { + t.duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0) + }) + .unwrap_or(0) +} + +/// 判断分片是否可能与 [since, until] 时间窗重叠。 +/// +/// 必须传入 `table`(`Msg_`)。只用该表的 bound;未知表则保守返回 true。 +/// meta 缺失或源文件 mtime 更新时返回 true。 +pub fn may_overlap( + entry: Option<&ShardMetaEntry>, + source_mtime: u64, + table: &str, + since: Option, + until: Option, +) -> bool { + let Some(e) = entry else { + return true; + }; + // 源文件已更新 → meta 可能过期 + if e.source_mtime_ns > 0 && source_mtime > e.source_mtime_ns { + return true; + } + // 已知缺失 + if e.missing_tables.iter().any(|t| t == table) { + return false; + } + + let (lo, hi) = if let Some(tb) = e.tables.get(table) { + let lo = if tb.min_ts > 0 { + tb.min_ts + } else { + e.shard_start.unwrap_or(0) + }; + let hi = tb.max_ts.max(lo); + (lo, hi) + } else { + // 无该表记录:不因其它 talker 的旧分片级 min/max 跳过 + return true; + }; + + if hi <= 0 && lo <= 0 { + return true; + } + let start = since.unwrap_or(i64::MIN); + let end = until.unwrap_or(i64::MAX); + lo <= end && hi >= start +} + +/// 记录一次打开后的 **per-table** 时间范围。 +pub fn record_open( + meta: &mut ShardMetaFile, + rel_key: &str, + source_path: &Path, + table: &str, + min_ts: Option, + max_ts: Option, + shard_start: Option, +) { + let e = meta.shards.entry(rel_key.to_string()).or_default(); + e.source_mtime_ns = source_mtime_ns(source_path); + if shard_start.is_some() { + e.shard_start = shard_start; + } + // 从 missing 里移除(表实际存在) + e.missing_tables.retain(|t| t != table); + + let tb = e.tables.entry(table.to_string()).or_default(); + if let Some(v) = min_ts { + if v > 0 && (tb.min_ts <= 0 || v < tb.min_ts) { + tb.min_ts = v; + } + } + if let Some(v) = max_ts { + if v > tb.max_ts { + tb.max_ts = v; + } + } + + // 兼容字段:维护分片级 expanded union(仅观测,不用于跨 talker 路由) + if let Some(v) = min_ts { + if v > 0 && (e.min_ts <= 0 || v < e.min_ts) { + e.min_ts = v; + } + } + if let Some(v) = max_ts { + if v > e.max_ts { + e.max_ts = v; + } + } +} + +/// 加密源文件 mtime 降序(新写的分片优先,通常即热分片)。 +pub fn sort_rel_keys_by_mtime(db_dir: &Path, keys: &[String]) -> Vec { + let mut items: Vec<(u64, String)> = keys + .iter() + .map(|k| { + let p = db_dir.join(k.replace('/', std::path::MAIN_SEPARATOR_STR)); + (source_mtime_ns(&p), k.clone()) + }) + .collect(); + items.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + items.into_iter().map(|(_, k)| k).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_bounds_do_not_poison_other_talkers() { + let mut meta = ShardMetaFile::default(); + let path = PathBuf::from("/tmp/nonexistent-for-meta-test"); + record_open( + &mut meta, + "message/message_0.db", + &path, + "Msg_aaa", + Some(1_700_000_000), + Some(1_700_000_100), + None, + ); + let e = meta.shards.get("message/message_0.db"); + // other table unknown → must open + assert!(may_overlap( + e, + 0, + "Msg_bbb", + Some(1_600_000_000), + Some(1_600_000_100) + )); + // known table outside window → skip + assert!(!may_overlap( + e, + 0, + "Msg_aaa", + Some(1_600_000_000), + Some(1_600_000_100) + )); + // known table inside window → open + assert!(may_overlap( + e, + 0, + "Msg_aaa", + Some(1_700_000_000), + Some(1_700_000_050) + )); + } +} diff --git a/src/ipc.rs b/src/ipc.rs new file mode 100644 index 0000000..0540ff4 --- /dev/null +++ b/src/ipc.rs @@ -0,0 +1,259 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +/// CLI 向 daemon 发送的请求(换行符分隔 JSON,与 Python 版兼容) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "snake_case")] +pub enum Request { + Ping, + Sessions { + #[serde(default = "default_limit_20")] + limit: usize, + #[serde(default, skip_serializing_if = "is_false")] + with_meta: bool, + #[serde(default, skip_serializing_if = "is_false")] + debug_source: bool, + }, + History { + chat: String, + #[serde(default = "default_limit_50")] + limit: usize, + #[serde(default)] + offset: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + /// 游标:create_time < after_ts(向更旧翻页,传上一页最旧一条的 timestamp) + #[serde(default, skip_serializing_if = "Option::is_none")] + after_ts: Option, + /// 游标:create_time > before_ts(向更新翻页) + #[serde(default, skip_serializing_if = "Option::is_none")] + before_ts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + msg_type: Option, + #[serde(default, skip_serializing_if = "is_false")] + with_meta: bool, + #[serde(default, skip_serializing_if = "is_false")] + debug_source: bool, + }, + Search { + keyword: String, + #[serde(skip_serializing_if = "Option::is_none")] + chats: Option>, + #[serde(default = "default_limit_20")] + limit: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + #[serde(skip_serializing_if = "Option::is_none")] + msg_type: Option, + #[serde(default, skip_serializing_if = "is_false")] + with_meta: bool, + #[serde(default, skip_serializing_if = "is_false")] + debug_source: bool, + }, + Contacts { + #[serde(skip_serializing_if = "Option::is_none")] + query: Option, + #[serde(default = "default_limit_50")] + limit: usize, + }, + Unread { + #[serde(default = "default_limit_20")] + limit: usize, + /// 按会话类型过滤:private / group / official / folded / all,支持多选 + #[serde(default, skip_serializing_if = "Option::is_none")] + filter: Option>, + #[serde(default, skip_serializing_if = "is_false")] + with_meta: bool, + #[serde(default, skip_serializing_if = "is_false")] + debug_source: bool, + }, + Members { + chat: String, + }, + NewMessages { + /// 上次检查时各会话的 last_timestamp 快照(username -> ts) + /// None 表示首次运行,会返回 new_state 供下次使用 + #[serde(skip_serializing_if = "Option::is_none")] + state: Option>, + #[serde(default = "default_limit_200")] + limit: usize, + #[serde(default, skip_serializing_if = "is_false")] + with_meta: bool, + #[serde(default, skip_serializing_if = "is_false")] + debug_source: bool, + }, + Stats { + chat: String, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + #[serde(default, skip_serializing_if = "is_false")] + with_meta: bool, + #[serde(default, skip_serializing_if = "is_false")] + debug_source: bool, + }, + Favorites { + #[serde(default = "default_limit_50")] + limit: usize, + /// 类型过滤:1=文本,2=图片,5=文章,19=名片,20=视频 + #[serde(skip_serializing_if = "Option::is_none")] + fav_type: Option, + /// 内容关键词搜索 + #[serde(skip_serializing_if = "Option::is_none")] + query: Option, + }, + /// 朋友圈互动通知(点赞 + 评论) + SnsNotifications { + #[serde(default = "default_limit_50")] + limit: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + /// 包含已读通知(默认仅未读) + #[serde(default)] + include_read: bool, + }, + /// 朋友圈时间线(按时间 / 作者筛选帖子) + SnsFeed { + #[serde(default = "default_limit_20")] + limit: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + /// 作者昵称 / 备注名 / 微信 username,模糊匹配 + #[serde(skip_serializing_if = "Option::is_none")] + user: Option, + }, + /// 查询公众号文章推送(biz_message_*.db 分片) + BizArticles { + #[serde(default = "default_limit_50")] + limit: usize, + /// 公众号名称过滤(模糊匹配 display name,None = 全部) + #[serde(skip_serializing_if = "Option::is_none")] + account: Option, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + /// 只看有未读消息的公众号,每个公众号取最新 1 篇 + #[serde(default)] + unread: bool, + }, + /// 朋友圈全文搜索(匹配 contentDesc) + SnsSearch { + keyword: String, + #[serde(default = "default_limit_20")] + limit: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + #[serde(skip_serializing_if = "Option::is_none")] + user: Option, + }, + /// 重新加载配置和密钥(key extract / key set 后 daemon 不会自动重读时用) + ReloadConfig, + /// 列出某个会话里的图片附件 + /// 输出每条带 `attachment_id`(不透明 base64url 句柄),传给 `Extract` 时取回本体 + Attachments { + chat: String, + /// 类型过滤:当前仅支持 image + #[serde(default, skip_serializing_if = "Option::is_none")] + kinds: Option>, + #[serde(default = "default_limit_50")] + limit: usize, + #[serde(default)] + offset: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + #[serde(default, skip_serializing_if = "is_false")] + with_meta: bool, + #[serde(default, skip_serializing_if = "is_false")] + debug_source: bool, + }, + /// 提取(解密)单个附件的本体到指定路径 + Extract { + /// `Attachments` 返回的不透明 ID + attachment_id: String, + /// 写入的绝对路径(daemon 直接写盘,不经 socket 传 binary) + output: String, + /// 已存在时是否覆盖 + #[serde(default)] + overwrite: bool, + }, + /// 跨会话时间线(按时间合并多 chat 消息) + Timeline { + #[serde(default = "default_limit_50")] + limit: usize, + #[serde(default)] + offset: usize, + #[serde(skip_serializing_if = "Option::is_none")] + since: Option, + #[serde(skip_serializing_if = "Option::is_none")] + until: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + after_ts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + msg_type: Option, + #[serde(default, skip_serializing_if = "is_false")] + with_meta: bool, + #[serde(default, skip_serializing_if = "is_false")] + debug_source: bool, + }, +} + +/// daemon 的响应 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Response { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(flatten)] + pub data: Value, +} + +impl Response { + pub fn ok(data: Value) -> Self { + Self { + ok: true, + error: None, + data, + } + } + + pub fn err(msg: impl Into) -> Self { + Self { + ok: false, + error: Some(msg.into()), + data: Value::Null, + } + } + + pub fn to_json_line(&self) -> anyhow::Result { + let s = serde_json::to_string(self)?; + Ok(s + "\n") + } +} + +fn default_limit_20() -> usize { + 20 +} +fn default_limit_50() -> usize { + 50 +} +fn default_limit_200() -> usize { + 200 +} +fn is_false(v: &bool) -> bool { + !*v +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..e6385fa --- /dev/null +++ b/src/main.rs @@ -0,0 +1,15 @@ +mod config; +mod ipc; +mod crypto; +mod scanner; +mod daemon; +mod cli; +mod attachment; + +fn main() { + if std::env::var("WX_DAEMON_MODE").is_ok() { + daemon::run(); + } else { + cli::run(); + } +} diff --git a/src/scanner/linux.rs b/src/scanner/linux.rs new file mode 100644 index 0000000..ddeff07 --- /dev/null +++ b/src/scanner/linux.rs @@ -0,0 +1,137 @@ +/// Linux WeChat 进程内存密钥扫描器 +/// +/// 通过 /proc//maps 枚举内存区域, +/// 通过 /proc//mem 读取内存内容, +/// 搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥 +use anyhow::{bail, Context, Result}; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; + +use super::{ + collect_db_salts, match_raw_keys, scan_key_patterns, KeyEntry, MAX_PATTERN_BYTES, +}; + +const CHUNK_SIZE: usize = 2 * 1024 * 1024; + +/// 查找 WeChat 进程 PID +fn find_wechat_pid() -> Option { + let proc_dir = std::fs::read_dir("/proc").ok()?; + for entry in proc_dir.flatten() { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + // 只处理数字目录(PID) + if !name_str.chars().all(|c| c.is_ascii_digit()) { + continue; + } + let comm_path = format!("/proc/{}/comm", name_str); + if let Ok(comm) = std::fs::read_to_string(&comm_path) { + let comm = comm.trim().to_lowercase(); + if comm == "wechat" || comm == "weixin" { + if let Ok(pid) = name_str.parse::() { + return Some(pid); + } + } + } + } + None +} + +/// 解析 /proc//maps 文件,返回可读的内存区域 (start, end) +fn parse_maps(pid: u32) -> Result> { + let maps_path = format!("/proc/{}/maps", pid); + let content = + std::fs::read_to_string(&maps_path).with_context(|| format!("读取 {} 失败", maps_path))?; + + let mut regions = Vec::new(); + for line in content.lines() { + // 格式: start-end perms offset dev inode pathname + let parts: Vec<&str> = line.splitn(2, ' ').collect(); + if parts.len() < 2 { + continue; + } + let perms = parts[1].trim_start(); + // 只选取 r 和 w 权限的区域 + if !perms.starts_with("rw") { + continue; + } + let addr_parts: Vec<&str> = parts[0].splitn(2, '-').collect(); + if addr_parts.len() != 2 { + continue; + } + if let (Ok(start), Ok(end)) = ( + u64::from_str_radix(addr_parts[0], 16), + u64::from_str_radix(addr_parts[1], 16), + ) { + regions.push((start, end)); + } + } + Ok(regions) +} + +pub fn scan_keys(db_dir: &Path) -> Result> { + let pid = find_wechat_pid().context("找不到 WeChat 进程,请确认 WeChat 正在运行")?; + eprintln!("WeChat PID: {}", pid); + + let db_salts = collect_db_salts(db_dir); + eprintln!("找到 {} 个加密数据库", db_salts.len()); + + eprintln!("扫描进程内存..."); + let regions = parse_maps(pid)?; + eprintln!("找到 {} 个可读写内存区域", regions.len()); + + let mem_path = format!("/proc/{}/mem", pid); + let mut mem_file = std::fs::File::open(&mem_path) + .with_context(|| format!("打开 {} 失败,请以 root 权限运行", mem_path))?; + + let mut raw_keys: Vec<(String, String)> = Vec::new(); + for (start, end) in ®ions { + scan_region(&mut mem_file, *start, *end, &mut raw_keys); + } + eprintln!("找到 {} 个候选密钥", raw_keys.len()); + + let entries = match_raw_keys(db_dir, &raw_keys, &db_salts); + + eprintln!( + "匹配到 {}/{} 个数据库密钥(来自 {} 个候选 key)", + entries.len(), + db_salts.len(), + raw_keys.len() + ); + Ok(entries) +} + +fn scan_region(mem: &mut std::fs::File, start: u64, end: u64, results: &mut Vec<(String, String)>) { + let total_len = (end - start) as usize; + let overlap = MAX_PATTERN_BYTES; + let mut offset = 0usize; + + loop { + if offset >= total_len { + break; + } + let chunk_size = std::cmp::min(CHUNK_SIZE, total_len - offset); + let addr = start + offset as u64; + + if mem.seek(SeekFrom::Start(addr)).is_err() { + break; + } + let mut buf = vec![0u8; chunk_size]; + match mem.read(&mut buf) { + Ok(n) if n > 0 => { + buf.truncate(n); + search_pattern(&buf, results); + } + _ => {} + } + + if chunk_size > overlap { + offset += chunk_size - overlap; + } else { + offset += chunk_size; + } + } +} + +fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) { + scan_key_patterns(buf, results); +} diff --git a/src/scanner/macos.rs b/src/scanner/macos.rs new file mode 100644 index 0000000..4b3ba53 --- /dev/null +++ b/src/scanner/macos.rs @@ -0,0 +1,869 @@ +/// macOS WeChat 进程内存密钥扫描器 +/// +/// 两阶段提取(**都不依赖关闭 SIP**;SIP 保护的是系统组件,不是读微信内存的开关): +/// 1. **内存扫描**(需要 `task_for_pid`:通常 = 本机 GUI Terminal + sudo + 开发者工具 TCC) +/// - 搜索 `x'<64hex_key><32hex_salt>'` 传统 WCDB 缓存格式 +/// - 按每个 DB 的 16-byte salt 在堆中找相邻 32-byte raw key +/// 2. **LLDB hook**(补齐冷分片密钥:用户滚动/打开会话时触发 DB 打开) +/// - attach 到 WeChat,hook CommonCrypto `CCCryptorCreate` 等 +/// - 捕获 32-byte AES key,用 SQLCipher 4 HMAC 与磁盘 DB 匹配 +/// - Hardened Runtime 官方包建议 sudo;部分官网包本身 ad-hoc,用户态 LLDB 即可 +/// +/// 官方 Hardened Runtime 包在本机 GUI Terminal + sudo 下通常可 `task_for_pid`; +/// 部分官网包本身就是 ad-hoc,无需也不应再重签。 +use anyhow::{bail, Context, Result}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use super::{ + collect_db_salts, collect_salt_adjacent_keys, decode_salt_hex, match_key_hexes, unique_key_hexes, + scan_key_patterns, KeyEntry, MAX_PATTERN_BYTES, +}; + +// Mach 相关常量 +const KERN_SUCCESS: i32 = 0; +const VM_PROT_READ: i32 = 1; +const VM_PROT_WRITE: i32 = 2; +const VM_REGION_BASIC_INFO_64: i32 = 9; +const CHUNK_SIZE: usize = 2 * 1024 * 1024; // 2MB + +// vm_region_basic_info_64 结构体 +#[repr(C)] +struct VmRegionBasicInfo64 { + protection: i32, + max_protection: i32, + inheritance: u32, + shared: u32, + reserved: u32, + _offset: u64, + behavior: i32, + user_wired_count: u16, +} + +// Mach FFI 声明 +#[allow(non_camel_case_types)] +type kern_return_t = i32; +#[allow(non_camel_case_types)] +type mach_port_t = u32; +#[allow(non_camel_case_types)] +type mach_vm_address_t = u64; +#[allow(non_camel_case_types)] +type mach_vm_size_t = u64; +#[allow(non_camel_case_types)] +type mach_msg_type_number_t = u32; +#[allow(non_camel_case_types)] +type vm_offset_t = usize; +#[allow(non_camel_case_types, dead_code)] +type vm_prot_t = i32; + +#[derive(Clone, Copy)] +enum SignatureKind { + AdHoc, + HardenedRuntime, + Unknown, +} + +extern "C" { + fn mach_task_self() -> mach_port_t; + fn task_for_pid(host: mach_port_t, pid: libc::pid_t, task: *mut mach_port_t) -> kern_return_t; + fn mach_vm_region( + task: mach_port_t, + address: *mut mach_vm_address_t, + size: *mut mach_vm_size_t, + flavor: i32, + info: *mut VmRegionBasicInfo64, + info_count: *mut mach_msg_type_number_t, + obj_name: *mut mach_port_t, + ) -> kern_return_t; + fn mach_vm_read( + task: mach_port_t, + addr: mach_vm_address_t, + size: mach_vm_size_t, + data: *mut vm_offset_t, + data_cnt: *mut mach_msg_type_number_t, + ) -> kern_return_t; + fn mach_vm_deallocate( + task: mach_port_t, + addr: mach_vm_address_t, + size: mach_vm_size_t, + ) -> kern_return_t; +} + +/// 查找 WeChat 进程的 PID +fn find_wechat_pid() -> Option { + // 使用 pgrep -x WeChat 查找(与 C 版本一致) + let output = std::process::Command::new("pgrep") + .args(["-x", "WeChat"]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let s = String::from_utf8_lossy(&output.stdout); + s.trim().parse().ok() +} + +/// 默认 LLDB hook 等待秒数(给用户时间在微信里滚动历史、打开会话)。 +pub const DEFAULT_HOOK_SECONDS: u64 = 45; + +#[allow(dead_code)] +pub fn scan_keys(db_dir: &Path) -> Result> { + scan_keys_with_options(db_dir, DEFAULT_HOOK_SECONDS, true, &[]) +} + +/// `hook_seconds == 0` 时跳过 LLDB hook;`auto_hook` 为 false 时即使匹配不全也不 hook。 +/// `known` 为已有仍有效的密钥,用于判断是否还需要 hook 补齐。 +pub fn scan_keys_with_options( + db_dir: &Path, + hook_seconds: u64, + auto_hook: bool, + known: &[KeyEntry], +) -> Result> { + let is_root = unsafe { libc::geteuid() } == 0; + + // 1. 查找 WeChat PID + let pid = find_wechat_pid().context("找不到 WeChat 进程,请确认 WeChat 正在运行")?; + eprintln!("WeChat PID: {}", pid); + let signature = detect_signature(pid); + match signature { + SignatureKind::AdHoc => eprintln!("WeChat 签名: ad-hoc(无需再次签名;用户态 LLDB 通常可附加)"), + SignatureKind::HardenedRuntime => { + eprintln!("WeChat 签名: 官方 Hardened Runtime(内存扫描需要 sudo;hook 也建议 sudo)") + } + SignatureKind::Unknown => eprintln!("WeChat 签名: 未能识别(继续尝试)"), + } + + eprintln!("扫描数据库文件..."); + let db_salts = collect_db_salts(db_dir); + eprintln!("找到 {} 个加密数据库", db_salts.len()); + if db_salts.is_empty() { + bail!("数据目录中没有加密的 .db 文件: {}", db_dir.display()); + } + + let salt_bytes: Vec<[u8; 16]> = db_salts + .iter() + .filter_map(|(s, _)| decode_salt_hex(s)) + .collect(); + + let mut raw_keys: Vec<(String, String)> = Vec::new(); + let mut extra_keys: Vec = Vec::new(); + let mut seen_extra: HashSet = HashSet::new(); + + // ── Phase 1: 进程内存扫描(需要 task_for_pid) ───────────────────── + if is_root { + let task = obtain_task_port(pid, signature)?; + eprintln!("Got task port: {}", task); + eprintln!("扫描进程内存寻找密钥(x'hex' + salt 邻接)..."); + let scanned = scan_memory(task, &salt_bytes, &mut raw_keys, &mut extra_keys, &mut seen_extra)?; + eprintln!( + "内存扫描完成:x'hex' 候选 {} 个,salt 邻接候选 {} 个(读取约 {:.1} MB)", + raw_keys.len(), + extra_keys.len(), + scanned as f64 / (1024.0 * 1024.0) + ); + } else { + eprintln!( + "当前非 root:跳过 Mach 内存扫描。若 WeChat 为 ad-hoc 签名,将仅依赖 LLDB hook。" + ); + if !matches!(signature, SignatureKind::AdHoc) && hook_seconds == 0 { + bail!( + "读取 WeChat 进程内存需要 root 权限,请从本机 Terminal 运行:\n\ + {}\n\ + 若使用官网 ad-hoc 包,也可不加 sudo:\n\ + wx key extract --hook-seconds 60", + crate::config::RECOMMENDED_KEY_EXTRACT + ); + } + } + + // 合并候选并匹配 + let mut all_key_hexes = unique_key_hexes(&raw_keys); + for k in &extra_keys { + if !all_key_hexes.iter().any(|x| x == k) { + all_key_hexes.push(k.clone()); + } + } + + let mut entries = if raw_keys.is_empty() && all_key_hexes.is_empty() { + Vec::new() + } else { + match_key_hexes(db_dir, &all_key_hexes, &raw_keys, &db_salts) + }; + + // 把已有有效密钥并入,避免已配齐时还去 hook + if !known.is_empty() { + let mut by_name: std::collections::BTreeMap = known + .iter() + .cloned() + .map(|e| (e.db_name.clone(), e)) + .collect(); + for e in entries { + by_name.insert(e.db_name.clone(), e); + } + entries = by_name.into_values().collect(); + } + + eprintln!( + "内存阶段匹配到 {}/{} 个数据库密钥(候选 key {} 个,含已有密钥)", + entries.len(), + db_salts.len(), + all_key_hexes.len() + ); + + // ── Phase 2: LLDB CommonCrypto hook 补齐冷分片 ─────────────────── + let covered: HashSet<&str> = entries.iter().map(|e| e.db_name.as_str()).collect(); + let still_missing: Vec<&(String, String)> = db_salts + .iter() + .filter(|(_, name)| !covered.contains(name.as_str())) + .collect(); + if auto_hook && hook_seconds > 0 && !still_missing.is_empty() { + eprintln!( + "仍有 {} 个数据库未匹配密钥,启动 LLDB hook {}s…\n\ + 请在此期间切换到微信:滚动聊天列表、打开几个会话/历史记录,\n\ + 以触发冷分片 DB 的解密(message_N.db 等)。", + still_missing.len(), + hook_seconds + ); + match hook_keys_via_lldb( + pid, + hook_seconds, + is_root || matches!(signature, SignatureKind::AdHoc), + ) { + Ok(hooked) => { + eprintln!("LLDB hook 捕获到 {} 个 32-byte key", hooked.len()); + // 只对仍缺的 DB 做匹配,加快速度 + let missing_salts: Vec<(String, String)> = still_missing + .iter() + .map(|(s, n)| ((*s).clone(), (*n).clone())) + .collect(); + let hooked_entries = match_key_hexes(db_dir, &hooked, &[], &missing_salts); + let mut by_name: std::collections::BTreeMap = entries + .into_iter() + .map(|e| (e.db_name.clone(), e)) + .collect(); + for e in hooked_entries { + by_name.insert(e.db_name.clone(), e); + } + entries = by_name.into_values().collect(); + } + Err(e) => { + eprintln!("LLDB hook 未成功: {:#}", e); + eprintln!( + "提示: 确认已安装 Xcode CLT(xcode-select --install),\n\ + Hardened Runtime 包请使用 sudo;ad-hoc 包可直接用户态 lldb。" + ); + } + } + } + + eprintln!( + "最终匹配到 {}/{} 个数据库密钥", + entries.len(), + db_salts.len() + ); + + // 兼容旧返回路径:若完全失败且非 root,给出清晰错误 + if entries.is_empty() && !is_root && !matches!(signature, SignatureKind::AdHoc) { + bail!( + "未能提取任何密钥。请从本机 Terminal 运行:\n\ + {}\n\ + 等待期间在微信中打开/滚动相关聊天以触发冷分片加载。\n\ + SIP 无需关闭;不要预先 ad-hoc 重签官方包。", + crate::config::RECOMMENDED_KEY_EXTRACT_HINT + ); + } + + Ok(entries) +} + +fn obtain_task_port(pid: libc::pid_t, signature: SignatureKind) -> Result { + // SAFETY: task_for_pid 是标准 Mach API,参数合法 + let mut task: mach_port_t = 0; + let kr = unsafe { task_for_pid(mach_task_self(), pid, &mut task) }; + if kr == KERN_SUCCESS { + return Ok(task); + } + let advice = match signature { + SignatureKind::AdHoc => { + "当前 WeChat 已是 ad-hoc,重复签名没有帮助。请确认命令来自本机 GUI \ + Terminal,并在系统提示时允许「开发者工具」权限。" + } + SignatureKind::HardenedRuntime => { + "当前 WeChat 是官方 Hardened Runtime 签名。请从本机 GUI Terminal \ + 重试,并在「隐私与安全性 → 开发者工具」中允许该 Terminal。只有 SSH \ + 等无 GUI 场景仍被拒绝时,才考虑有副作用的 ad-hoc 重签。" + } + SignatureKind::Unknown => { + "请从本机 GUI Terminal 重试,并检查「隐私与安全性 → 开发者工具」权限。" + } + }; + bail!( + "task_for_pid 失败 (kr={})。\n{}\n\ + SIP 无需关闭;wx-cli 不会自动修改 WeChat.app。", + kr, + advice + ) +} + +fn detect_signature(pid: libc::pid_t) -> SignatureKind { + let process = std::process::Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "comm="]) + .output(); + let Ok(process) = process else { + return SignatureKind::Unknown; + }; + let executable = String::from_utf8_lossy(&process.stdout); + let executable = executable.trim(); + if executable.is_empty() { + return SignatureKind::Unknown; + } + + let output = std::process::Command::new("codesign") + .args(["-dvv", executable]) + .output(); + let Ok(output) = output else { + return SignatureKind::Unknown; + }; + let details = String::from_utf8_lossy(&output.stderr); + parse_signature_details(&details) +} + +fn parse_signature_details(details: &str) -> SignatureKind { + if details.contains("Signature=adhoc") || details.contains("(adhoc)") { + SignatureKind::AdHoc + } else if details.contains("(runtime)") || details.contains("flags=0x10000") { + SignatureKind::HardenedRuntime + } else { + SignatureKind::Unknown + } +} + +/// 扫描进程内存。 +/// +/// - `raw_keys`: `x''` 字符串模式 +/// - `extra_keys`: salt 邻接的 binary 32-byte key +/// - 返回值:成功读取的总字节数 +fn scan_memory( + task: mach_port_t, + salts: &[[u8; 16]], + raw_keys: &mut Vec<(String, String)>, + extra_keys: &mut Vec, + seen_extra: &mut HashSet, +) -> Result { + let mut addr: mach_vm_address_t = 0; + let mut bytes_read: u64 = 0; + + // VM_REGION_BASIC_INFO_COUNT_64 = 9(来自 ,固定值,不能用 sizeof 计算) + let info_count_expected: mach_msg_type_number_t = 9; + + loop { + let mut size: mach_vm_size_t = 0; + let mut info = VmRegionBasicInfo64 { + protection: 0, + max_protection: 0, + inheritance: 0, + shared: 0, + reserved: 0, + _offset: 0, + behavior: 0, + user_wired_count: 0, + }; + let mut info_count: mach_msg_type_number_t = info_count_expected; + let mut obj_name: mach_port_t = 0; + + // SAFETY: mach_vm_region 枚举虚拟内存区域,所有参数合法 + let kr = unsafe { + mach_vm_region( + task, + &mut addr, + &mut size, + VM_REGION_BASIC_INFO_64, + &mut info, + &mut info_count, + &mut obj_name, + ) + }; + + if kr != KERN_SUCCESS { + break; + } + if size == 0 { + addr = addr.saturating_add(1); + continue; + } + + // 堆上密钥:优先 RW;也扫只读区域中较小的块(部分缓存) + let readable = (info.protection & VM_PROT_READ) != 0; + let writable = (info.protection & VM_PROT_WRITE) != 0; + let scan_it = readable + && (writable || size <= 64 * 1024 * 1024) + && size > 0 + && size < 512 * 1024 * 1024; + if scan_it { + bytes_read += scan_region(task, addr, size, salts, raw_keys, extra_keys, seen_extra); + } + + addr = addr.saturating_add(size); + } + + Ok(bytes_read) +} + +/// 扫描单个内存区域,按 CHUNK_SIZE 分块读取 +fn scan_region( + task: mach_port_t, + addr: mach_vm_address_t, + size: mach_vm_size_t, + salts: &[[u8; 16]], + raw_keys: &mut Vec<(String, String)>, + extra_keys: &mut Vec, + seen_extra: &mut HashSet, +) -> u64 { + let end = addr + size; + let mut ca = addr; + let mut bytes_read: u64 = 0; + + while ca < end { + let cs = std::cmp::min(end - ca, CHUNK_SIZE as u64); + + let mut data: vm_offset_t = 0; + let mut dc: mach_msg_type_number_t = 0; + + // SAFETY: mach_vm_read 读取目标进程内存到内核缓冲区, + // 返回的 data 指针指向通过 vm_allocate 分配的内存, + // 必须用 mach_vm_deallocate 释放 + let kr = unsafe { mach_vm_read(task, ca, cs, &mut data, &mut dc) }; + + if kr == KERN_SUCCESS { + // SAFETY: data 是 mach_vm_read 返回的有效指针,dc 是字节数 + let buf: &[u8] = unsafe { std::slice::from_raw_parts(data as *const u8, dc as usize) }; + + search_pattern(buf, raw_keys); + collect_salt_adjacent_keys(buf, salts, extra_keys, seen_extra); + bytes_read += dc as u64; + + // SAFETY: 释放 mach_vm_read 分配的内核内存 + unsafe { + mach_vm_deallocate(mach_task_self(), data as u64, dc as u64); + } + } + + // 保留最大 pattern 长度以处理跨块边界 + let overlap = MAX_PATTERN_BYTES; + if cs as usize > overlap { + ca += cs - overlap as u64; + } else { + ca += cs; + } + } + bytes_read +} + +/// 通过 LLDB 在用户/root 态 hook CommonCrypto,捕获 AES-256 key。 +/// +/// 微信 4.x(尤其是 Tencent 官网 ad-hoc 包)在打开加密 DB 时会调用 +/// `CCCryptorCreate` / `CCCryptorCreateWithMode`;此时 keyLength==32。 +/// +/// 脚本在 `seconds` 后自动 `process detach`,避免强杀 lldb 把微信留在 SIGSTOP。 +fn hook_keys_via_lldb(pid: libc::pid_t, seconds: u64, allow_user: bool) -> Result> { + let lldb = find_lldb() + .context("找不到 lldb。请安装 Xcode Command Line Tools:xcode-select --install")?; + + let tmp_dir = std::env::temp_dir().join(format!("wx-cli-hook-{}", std::process::id())); + std::fs::create_dir_all(&tmp_dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&tmp_dir, std::fs::Permissions::from_mode(0o700)); + } + let script_path = tmp_dir.join("wx_hook.py"); + let keys_path = tmp_dir.join("keys.txt"); + let done_path = tmp_dir.join("done"); + let _ = std::fs::remove_file(&keys_path); + let _ = std::fs::remove_file(&done_path); + // 预先创建空 keys 文件并收紧权限,避免 lldb 以默认 umask 写出 world-readable 密钥 + { + let _ = std::fs::File::create(&keys_path); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&keys_path, std::fs::Permissions::from_mode(0o600)); + } + } + std::fs::write( + &script_path, + lldb_hook_script( + keys_path.to_string_lossy().as_ref(), + done_path.to_string_lossy().as_ref(), + seconds, + ), + )?; + + if !allow_user && unsafe { libc::geteuid() } != 0 { + bail!("LLDB hook 需要 root 或 ad-hoc 签名的 WeChat"); + } + + let mut cmd = Command::new(&lldb); + cmd.args([ + "-p", + &pid.to_string(), + "--batch", + "-o", + &format!("command script import {}", script_path.display()), + "-o", + "process continue", + ]); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::null()); + cmd.stderr(Stdio::piped()); + + eprintln!("LLDB: {} -p {} (等待 {}s,到期自动 detach)", lldb.display(), pid, seconds); + let mut child = cmd.spawn().context("启动 lldb 失败")?; + + // 多等几秒给 detach/quit 收尾 + let wait_budget = Duration::from_secs(seconds + 15); + let started = std::time::Instant::now(); + loop { + if done_path.exists() { + // 给 quit 一点时间 + std::thread::sleep(Duration::from_millis(500)); + let _ = child.try_wait(); + break; + } + if let Some(status) = child.try_wait()? { + if !status.success() && !keys_path.exists() { + let mut stderr = String::new(); + if let Some(mut s) = child.stderr.take() { + use std::io::Read; + let _ = s.read_to_string(&mut stderr); + } + let _ = std::fs::remove_dir_all(&tmp_dir); + bail!( + "lldb 退出异常: {} {}", + status, + stderr.chars().take(400).collect::() + ); + } + break; + } + if started.elapsed() > wait_budget { + eprintln!("LLDB 超时,尝试终止…"); + let _ = child.kill(); + let _ = child.wait(); + break; + } + std::thread::sleep(Duration::from_millis(250)); + } + // 确保子进程回收 + let _ = child.wait(); + + let content = std::fs::read_to_string(&keys_path).unwrap_or_default(); + let mut keys = Vec::new(); + let mut seen = HashSet::new(); + for line in content.lines() { + let line = line.trim().to_lowercase(); + if line.len() == 64 + && line.chars().all(|c| c.is_ascii_hexdigit()) + && seen.insert(line.clone()) + { + keys.push(line); + } + } + let _ = std::fs::remove_dir_all(&tmp_dir); + Ok(keys) +} + +fn find_lldb() -> Option { + let candidates = [ + "lldb", + "/usr/bin/lldb", + "/Library/Developer/CommandLineTools/usr/bin/lldb", + "/Applications/Xcode.app/Contents/Developer/usr/bin/lldb", + "/opt/homebrew/opt/llvm/bin/lldb", + ]; + for c in candidates { + let p = PathBuf::from(c); + if c == "lldb" { + if Command::new("which") + .arg("lldb") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + { + return Some(PathBuf::from("lldb")); + } + } else if p.exists() { + return Some(p); + } + } + None +} + +fn lldb_hook_script(keys_path: &str, done_path: &str, seconds: u64) -> String { + // arm64 ABI: + // CCCryptorCreate(op, alg, options, key, keyLength, iv, cryptorRef) + // x3=key, x4=keyLength + // CCCryptorCreateWithMode(op, mode, alg, padding, iv, key, keyLength, ...) + // x5=key, x6=keyLength + format!( + r#"# auto-generated by wx-cli +import lldb +import threading +import time + +OUT = r"{keys_path}" +DONE = r"{done_path}" +SECONDS = {seconds} +captured = set() +_debugger = None + +def _save(hx): + try: + with open(OUT, "a") as f: + f.write(hx + "\n") + except Exception: + pass + +def _try_read(process, key_ptr, key_len): + if key_len != 32 or not key_ptr: + return + err = lldb.SBError() + data = process.ReadMemory(key_ptr, 32, err) + if not err.Success() or not data or len(data) != 32: + return + hx = bytes(data).hex() + if hx in captured: + return + captured.add(hx) + print("[wx-cli hook] key " + hx, flush=True) + _save(hx) + +def on_cc(frame, bp_loc, _dict): + try: + process = frame.GetThread().GetProcess() + arch = process.GetTarget().GetTriple() + if "arm64" in arch or "aarch64" in arch: + x3 = frame.FindRegister("x3").GetValueAsUnsigned() + x4 = frame.FindRegister("x4").GetValueAsUnsigned() + x5 = frame.FindRegister("x5").GetValueAsUnsigned() + x6 = frame.FindRegister("x6").GetValueAsUnsigned() + _try_read(process, x3, x4) + _try_read(process, x5, x6) + else: + rcx = frame.FindRegister("rcx").GetValueAsUnsigned() + r8 = frame.FindRegister("r8").GetValueAsUnsigned() + r9 = frame.FindRegister("r9").GetValueAsUnsigned() + _try_read(process, rcx, r8) + _try_read(process, r9, 32) + except Exception as e: + print("[wx-cli hook] err " + str(e), flush=True) + return False + +def _finish(): + time.sleep(SECONDS) + try: + if _debugger is not None: + _debugger.HandleCommand("process detach") + _debugger.HandleCommand("quit") + except Exception as e: + print("[wx-cli hook] detach err " + str(e), flush=True) + try: + open(DONE, "w").write("ok\n") + except Exception: + pass + +def __lldb_init_module(debugger, _internal_dict): + global _debugger + _debugger = debugger + target = debugger.GetSelectedTarget() + names = [ + "CCCryptorCreate", + "CCCrypt", + "CCCryptorCreateWithMode", + "CCCryptorCreateFromData", + ] + for name in names: + bp = target.BreakpointCreateByName(name) + n = bp.GetNumLocations() + if n == 0: + print("[wx-cli hook] skip " + name, flush=True) + continue + bp.SetScriptCallbackFunction(__name__ + ".on_cc") + bp.SetAutoContinue(True) + print("[wx-cli hook] " + name + " locs=" + str(n), flush=True) + open(OUT, "w").close() + print("[wx-cli hook] ready for %ds → %s" % (SECONDS, OUT), flush=True) + t = threading.Thread(target=_finish, daemon=True) + t.start() +"# + ) +} + +pub(crate) fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) { + scan_key_patterns(buf, results); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 构造一条合法的 x'' 模式字节串 + fn make_pattern(key: &[u8; 64], salt: &[u8; 32]) -> Vec { + let mut v = vec![b'x', b'\'']; + v.extend_from_slice(key); + v.extend_from_slice(salt); + v.push(b'\''); + v + } + + #[test] + fn test_search_pattern_basic() { + let key = [b'a'; 64]; + let salt = [b'b'; 32]; + let buf = make_pattern(&key, &salt); + let mut results = Vec::new(); + search_pattern(&buf, &mut results); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, "a".repeat(64)); + assert_eq!(results[0].1, "b".repeat(32)); + } + + #[test] + fn test_search_pattern_uppercase_lowercased() { + // 大写十六进制字符应被统一转为小写 + let key = [b'A'; 64]; + let salt = [b'B'; 32]; + let buf = make_pattern(&key, &salt); + let mut results = Vec::new(); + search_pattern(&buf, &mut results); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, "a".repeat(64)); + assert_eq!(results[0].1, "b".repeat(32)); + } + + #[test] + fn test_search_pattern_not_all_hex() { + // 96 个十六进制字符中有一个非法字符 → 不匹配 + let mut buf = vec![b'x', b'\'']; + buf.extend_from_slice(&[b'a'; 95]); + buf.push(b'g'); // 'g' 不是合法十六进制字符 + buf.push(b'\''); + let mut results = Vec::new(); + search_pattern(&buf, &mut results); + assert!(results.is_empty()); + } + + #[test] + fn test_search_pattern_wrong_closing_quote() { + // 结尾引号错误 → 不匹配 + let mut buf = vec![b'x', b'\'']; + buf.extend_from_slice(&[b'a'; 96]); + buf.push(b'"'); // 应为 b'\'' + let mut results = Vec::new(); + search_pattern(&buf, &mut results); + assert!(results.is_empty()); + } + + #[test] + fn test_search_pattern_dedup() { + // 相同模式出现两次 → 只保留一条 + let key = [b'1'; 64]; + let salt = [b'2'; 32]; + let pattern = make_pattern(&key, &salt); + let mut buf = pattern.clone(); + buf.extend_from_slice(&pattern); + let mut results = Vec::new(); + search_pattern(&buf, &mut results); + assert_eq!(results.len(), 1); + } + + #[test] + fn test_search_pattern_multiple_distinct() { + // 两个不同的合法模式 → 各自独立捕获 + let key1 = [b'a'; 64]; + let salt1 = [b'b'; 32]; + let key2 = [b'c'; 64]; + let salt2 = [b'd'; 32]; + let mut buf = make_pattern(&key1, &salt1); + buf.extend_from_slice(&make_pattern(&key2, &salt2)); + let mut results = Vec::new(); + search_pattern(&buf, &mut results); + assert_eq!(results.len(), 2); + let keys: Vec<&str> = results.iter().map(|(k, _)| k.as_str()).collect(); + assert!(keys.contains(&"a".repeat(64).as_str())); + assert!(keys.contains(&"c".repeat(64).as_str())); + } + + #[test] + fn test_search_pattern_embedded_in_garbage() { + // 模式夹在垃圾字节中间,仍应找到 + let mut buf = vec![0xFFu8; 50]; + let key = [b'e'; 64]; + let salt = [b'f'; 32]; + buf.extend_from_slice(&make_pattern(&key, &salt)); + buf.extend_from_slice(&[0x00u8; 50]); + let mut results = Vec::new(); + search_pattern(&buf, &mut results); + assert_eq!(results.len(), 1); + } + + #[test] + fn test_search_pattern_too_short() { + // 缓冲区太小,无法容纳完整模式 + let buf = [b'x', b'\'', b'a', b'b']; + let mut results = Vec::new(); + search_pattern(&buf, &mut results); + assert!(results.is_empty()); + } + + #[test] + fn test_search_pattern_empty_buf() { + let mut results = Vec::new(); + search_pattern(&[], &mut results); + assert!(results.is_empty()); + } + + #[test] + fn test_search_pattern_real_hex_mix() { + // 合法的混合大小写十六进制(0-9, a-f, A-F) + let mut key = [b'0'; 64]; + for (i, c) in b"0123456789abcdefABCDEF0123456789abcdef0123456789abcdef01234567" + .iter() + .enumerate() + { + if i < 64 { + key[i] = *c; + } + } + let salt = [b'9'; 32]; + let buf = make_pattern(&key, &salt); + let mut results = Vec::new(); + search_pattern(&buf, &mut results); + assert_eq!(results.len(), 1); + // 结果应全小写 + assert!(results[0] + .0 + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())); + } + + #[test] + fn test_parse_signature_details() { + assert!(matches!( + parse_signature_details("flags=0x2(adhoc) Signature=adhoc"), + SignatureKind::AdHoc + )); + assert!(matches!( + parse_signature_details("flags=0x10000(runtime) Signature size=9174"), + SignatureKind::HardenedRuntime + )); + assert!(matches!( + parse_signature_details("Identifier=com.tencent.xinWeChat"), + SignatureKind::Unknown + )); + } +} diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs new file mode 100644 index 0000000..54f0781 --- /dev/null +++ b/src/scanner/mod.rs @@ -0,0 +1,772 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +/// 扫描到的一条密钥记录 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KeyEntry { + /// 相对路径,如 "message/message_0.db" + pub db_name: String, + /// 32字节 AES 密钥(hex) + pub enc_key: String, + /// 16字节 salt(hex,来自数据库文件头) + pub salt: String, +} + +/// 从进程内存中扫描所有 SQLCipher 密钥 +/// +/// 需要以 root/Administrator 权限运行(macOS ad-hoc 包的 LLDB hook 路径除外) +#[allow(dead_code)] // 跨平台/外部入口保留;CLI 走 scan_keys_with_options +pub fn scan_keys(db_dir: &Path) -> Result> { + scan_keys_with_options(db_dir, ScanOptions::default()) +} + +/// 密钥扫描选项(目前主要影响 macOS LLDB hook) +#[derive(Debug, Clone)] +pub struct ScanOptions<'a> { + /// LLDB hook 等待秒数;0 表示禁用 hook + pub hook_seconds: u64, + /// 内存扫描未配齐时是否自动进入 hook + pub auto_hook: bool, + /// 已有仍有效的密钥(避免已配齐时仍进入 hook) + pub known: &'a [KeyEntry], +} + +impl Default for ScanOptions<'static> { + fn default() -> Self { + Self { + #[cfg(target_os = "macos")] + hook_seconds: macos::DEFAULT_HOOK_SECONDS, + #[cfg(not(target_os = "macos"))] + hook_seconds: 0, + auto_hook: true, + known: &[], + } + } +} + +pub fn scan_keys_with_options(db_dir: &Path, opts: ScanOptions<'_>) -> Result> { + #[cfg(target_os = "macos")] + return macos::scan_keys_with_options(db_dir, opts.hook_seconds, opts.auto_hook, opts.known); + #[cfg(target_os = "linux")] + { + let _ = opts; + return linux::scan_keys(db_dir); + } + #[cfg(target_os = "windows")] + { + let _ = opts; + return windows::scan_keys(db_dir); + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + let _ = opts; + anyhow::bail!("当前平台不支持自动密钥扫描") + } +} + +/// 读取 DB 文件前 16 字节作为 salt(hex),如果是明文 SQLite 则返回 None +pub fn read_db_salt(path: &Path) -> Option { + let mut buf = [0u8; 16]; + let mut f = std::fs::File::open(path).ok()?; + use std::io::Read; + f.read_exact(&mut buf).ok()?; + // 明文 SQLite:头部是 "SQLite format 3" + if &buf[..15] == b"SQLite format 3" { + return None; + } + Some(hex::encode(&buf)) +} + +/// 遍历 db_dir,收集所有 .db 文件的 salt -> 相对路径 映射 +pub fn collect_db_salts(db_dir: &Path) -> Vec<(String, String)> { + let mut result = Vec::new(); + collect_recursive(db_dir, db_dir, &mut result); + result +} + +/// 将内存中的候选 key 映射到实际数据库文件。 +/// +/// 旧版 WCDB 会把 `raw key + file salt` 连续保存在内存字符串里,因此 salt +/// 相同的候选优先尝试;最终仍以数据库第一页的 HMAC/解密结果为准。新版构建即使 +/// 不再保留 key/salt 邻接关系,也能从其余候选中找到对应 key。 +#[allow(dead_code)] // Linux/Windows 扫描器使用;macOS 走 match_key_hexes +pub(crate) fn match_raw_keys( + db_dir: &Path, + raw_keys: &[(String, String)], + db_salts: &[(String, String)], +) -> Vec { + let pure_keys: Vec = unique_key_hexes(raw_keys); + match_key_hexes(db_dir, &pure_keys, raw_keys, db_salts) +} + +/// 用一组候选 32-byte key(hex)去匹配数据库。 +/// +/// `raw_keys` 可选:若提供,会优先尝试 salt 与 DB 相同的候选。 +pub(crate) fn match_key_hexes( + db_dir: &Path, + key_hexes: &[String], + raw_keys: &[(String, String)], + db_salts: &[(String, String)], +) -> Vec { + let mut entries = Vec::new(); + + for (db_salt, db_name) in db_salts { + let db_path = db_dir.join(db_name); + let mut seen_keys = std::collections::HashSet::new(); + + // 1) salt 配对优先(x'key+salt' 模式的传统路径) + let salt_matched = raw_keys + .iter() + .filter(|(_, candidate_salt)| candidate_salt == db_salt) + .map(|(k, _)| k.as_str()); + // 2) 其余候选(含 hook / salt-adjacent 提取到的纯 key) + let rest = key_hexes.iter().map(|k| k.as_str()); + + for key_hex in salt_matched.chain(rest) { + if !seen_keys.insert(key_hex.to_string()) { + continue; + } + let Some(key) = decode_key_hex(key_hex) else { + continue; + }; + if crate::crypto::validate_raw_key_for_db(&db_path, &key) { + entries.push(KeyEntry { + db_name: db_name.clone(), + enc_key: key_hex.to_string(), + salt: db_salt.clone(), + }); + break; + } + } + } + + entries +} + +/// 从 `(key_hex, salt_hex)` 列表提取去重后的 key。 +pub(crate) fn unique_key_hexes(raw_keys: &[(String, String)]) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for (k, _) in raw_keys { + if k.len() == 64 && seen.insert(k.clone()) { + out.push(k.clone()); + } + } + out +} + +/// 合并两批 KeyEntry:同名 DB 以 `primary` 优先,缺失的用 `fallback` 补齐。 +pub fn merge_key_entries(primary: &[KeyEntry], fallback: &[KeyEntry]) -> Vec { + let mut map = std::collections::BTreeMap::new(); + for e in fallback { + map.insert(e.db_name.clone(), e.clone()); + } + for e in primary { + map.insert(e.db_name.clone(), e.clone()); + } + map.into_values().collect() +} + +/// 磁盘上存在但尚未拿到密钥的加密 DB(含文件大小,按严重度排序)。 +#[derive(Debug, Clone)] +pub struct MissingDb { + /// 相对 `db_dir` 的路径(正斜杠) + pub rel: String, + /// 文件字节数;无法 stat 时为 0 + pub size: u64, +} + +/// 列出磁盘上存在但尚未拿到密钥的加密 `.db` 相对路径。 +pub fn missing_encrypted_dbs(db_dir: &Path, known: &[KeyEntry]) -> Vec { + list_missing_encrypted_dbs(db_dir, known) + .into_iter() + .map(|m| m.rel) + .collect() +} + +/// 列出缺失密钥的加密 DB,附大小;**聊天分片优先**,其次按 size 降序。 +pub fn list_missing_encrypted_dbs(db_dir: &Path, known: &[KeyEntry]) -> Vec { + let known_names: std::collections::HashSet<&str> = + known.iter().map(|e| e.db_name.as_str()).collect(); + let mut out: Vec = collect_db_salts(db_dir) + .into_iter() + .map(|(_, name)| name) + .filter(|name| !known_names.contains(name.as_str())) + .map(|rel| { + let size = std::fs::metadata(db_dir.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR))) + .map(|m| m.len()) + .unwrap_or(0); + MissingDb { rel, size } + }) + .collect(); + out.sort_by(|a, b| { + missing_db_rank(&a.rel) + .cmp(&missing_db_rank(&b.rel)) + .then_with(|| b.size.cmp(&a.size)) + .then_with(|| a.rel.cmp(&b.rel)) + }); + out +} + +/// `message/message_.db` 聊天历史分片(影响 history/sessions 完整性)。 +pub fn is_chat_message_shard(rel: &str) -> bool { + let n = rel.replace('\\', "/"); + let Some(name) = n.strip_prefix("message/") else { + return false; + }; + let Some(rest) = name.strip_prefix("message_") else { + return false; + }; + let Some(num) = rest.strip_suffix(".db") else { + return false; + }; + !num.is_empty() && num.chars().all(|c| c.is_ascii_digit()) +} + +/// 缺失时是否应判定为「健康检查失败」(聊天分片 / 核心库)。 +/// `migrate/*` 等旁路库缺失不阻断日常查询。 +pub fn is_critical_missing_db(rel: &str) -> bool { + let n = rel.replace('\\', "/"); + if is_chat_message_shard(&n) { + return true; + } + matches!( + n.as_str(), + "session/session.db" + | "contact/contact.db" + | "message/message_fts.db" + | "message/media_0.db" + ) || n.starts_with("message/biz_message_") +} + +/// 排序键:数字越小越优先展示 / 处理。 +pub fn missing_db_rank(rel: &str) -> u8 { + let n = rel.replace('\\', "/"); + if is_chat_message_shard(&n) { + 0 + } else if n.starts_with("message/") || n.starts_with("session/") || n.starts_with("contact/") { + 1 + } else if n.starts_with("migrate/") { + 9 + } else { + 5 + } +} + +/// 人类可读的体积(B / KB / MB)。 +pub fn format_db_size(bytes: u64) -> String { + if bytes >= 1024 * 1024 { + format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0)) + } else if bytes >= 1024 { + format!("{:.1}KB", bytes as f64 / 1024.0) + } else { + format!("{bytes}B") + } +} + +pub(crate) fn decode_key_hex(value: &str) -> Option<[u8; 32]> { + if value.len() != 64 { + return None; + } + let mut key = [0u8; 32]; + for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() { + let pair = std::str::from_utf8(chunk).ok()?; + key[index] = u8::from_str_radix(pair, 16).ok()?; + } + Some(key) +} + +/// 公开给 init 等模块复用的 hex → 32-byte key 解码。 +pub fn decode_key_hex_pub(value: &str) -> Option<[u8; 32]> { + decode_key_hex(value) +} + +/// 把 16 字节 salt 的 hex 解码为原始字节。 +pub(crate) fn decode_salt_hex(value: &str) -> Option<[u8; 16]> { + if value.len() != 32 { + return None; + } + let mut salt = [0u8; 16]; + for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() { + let pair = std::str::from_utf8(chunk).ok()?; + salt[index] = u8::from_str_radix(pair, 16).ok()?; + } + Some(salt) +} + +/// Windows `PAGE_*` base protect(不含 modifier)。与 WinNT.h 一致,便于跨平台单测。 +/// 非 Windows 构建里仅测试引用;生产路径在 `scanner/windows.rs`。 +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const WIN_PAGE_READWRITE: u32 = 0x04; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const WIN_PAGE_WRITECOPY: u32 = 0x08; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const WIN_PAGE_EXECUTE_READWRITE: u32 = 0x40; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const WIN_PAGE_EXECUTE_WRITECOPY: u32 = 0x80; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const WIN_PAGE_GUARD: u32 = 0x100; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const WIN_PAGE_NOCACHE: u32 = 0x200; +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +const WIN_PAGE_WRITECOMBINE: u32 = 0x400; + +/// 判断 Windows 页面保护是否可读可写(剥离 GUARD/NOCACHE/WRITECOMBINE 后比 base)。 +/// +/// 从 old-main #54 捞回:仅匹配 `PAGE_READWRITE` 会漏掉 WRITECOPY / +/// EXECUTE_READWRITE 等同样含密钥的堆页。 +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) fn is_writable_readable_page(protect: u32) -> bool { + let base = protect & !(WIN_PAGE_GUARD | WIN_PAGE_NOCACHE | WIN_PAGE_WRITECOMBINE); + matches!( + base, + x if x == WIN_PAGE_READWRITE + || x == WIN_PAGE_WRITECOPY + || x == WIN_PAGE_EXECUTE_READWRITE + || x == WIN_PAGE_EXECUTE_WRITECOPY + ) +} + +/// `x'<64hex_key><32hex_salt>'` 模式的最大字节长度(含前后引号)。 +pub(crate) const MAX_PATTERN_BYTES: usize = 99; // x' + 96 hex + ' +const HEX_PATTERN_LEN: usize = 96; // 64(key) + 32(salt) + +/// 在缓冲区中搜索 WCDB 缓存的 `x''` 字符串模式。 +pub(crate) fn scan_key_patterns(buf: &[u8], results: &mut Vec<(String, String)>) { + let total = MAX_PATTERN_BYTES; + if buf.len() < total { + return; + } + + let mut i = 0; + while i + total <= buf.len() { + if buf[i] != b'x' || buf[i + 1] != b'\'' { + i += 1; + continue; + } + + let hex_start = i + 2; + let all_hex = buf[hex_start..hex_start + HEX_PATTERN_LEN] + .iter() + .all(|&c| c.is_ascii_hexdigit()); + if !all_hex { + i += 1; + continue; + } + if buf[hex_start + HEX_PATTERN_LEN] != b'\'' { + i += 1; + continue; + } + + let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64]).to_lowercase(); + let salt_hex = + String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96]).to_lowercase(); + let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex); + if !is_dup { + results.push((key_hex, salt_hex)); + } + i += total; + } +} + +/// 在缓冲区中寻找与已知 DB salt 相邻的 32 字节 binary key。 +/// +/// 微信 4.x 每个 DB 使用独立 AES-256 key;密钥材料常以「key || salt」或 +/// 「salt || key」形式出现在堆上(不一定是 `x''` 字符串)。 +pub(crate) fn collect_salt_adjacent_keys( + buf: &[u8], + salts: &[[u8; 16]], + out_keys: &mut Vec, + seen: &mut std::collections::HashSet, +) { + if salts.is_empty() || buf.len() < 48 { + return; + } + // 关键偏移:key 紧邻 salt,以及常见的 8/16 字节对齐填充 + const BEFORE: [usize; 3] = [32, 40, 48]; + const AFTER: [usize; 3] = [0, 8, 16]; + + for salt in salts { + let mut start = 0usize; + while start + 16 <= buf.len() { + // 简单 memmem:找 salt + if let Some(rel) = find_slice(&buf[start..], salt) { + let i = start + rel; + for off in BEFORE { + if i >= off { + push_raw_key(&buf[i - off..i - off + 32], out_keys, seen); + } + } + for off in AFTER { + let kstart = i + 16 + off; + if kstart + 32 <= buf.len() { + push_raw_key(&buf[kstart..kstart + 32], out_keys, seen); + } + } + start = i + 1; + } else { + break; + } + } + } +} + +fn find_slice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn push_raw_key( + key: &[u8], + out_keys: &mut Vec, + seen: &mut std::collections::HashSet, +) { + if key.len() != 32 { + return; + } + // 过滤明显不是密钥的全零 / 低熵块 + if key.iter().all(|&b| b == 0) || key.iter().all(|&b| b == key[0]) { + return; + } + let hex = key.iter().map(|b| format!("{:02x}", b)).collect::(); + if seen.insert(hex.clone()) { + out_keys.push(hex); + } +} + +fn collect_recursive(base: &Path, dir: &Path, out: &mut Vec<(String, String)>) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_recursive(base, &path, out); + } else if path.extension().map(|e| e == "db").unwrap_or(false) { + if let Some(salt) = read_db_salt(&path) { + if let Ok(rel) = path.strip_prefix(base) { + let rel_str = rel.to_string_lossy().replace('\\', "/"); + out.push((salt, rel_str)); + } + } + } + } +} + +// hex encoding helper (avoid adding hex crate by implementing inline) +mod hex { + pub fn encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{:02x}", b)).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + /// 创建一个进程唯一的临时目录(测试用),返回路径;测试结束后调用方负责删除 + fn make_temp_dir(label: &str) -> std::path::PathBuf { + let mut p = std::env::temp_dir(); + // 用 label + thread id 保证同进程内并发测试不冲突 + p.push(format!( + "wx-cli-test-{}-{:?}", + label, + std::thread::current().id() + )); + fs::create_dir_all(&p).unwrap(); + p + } + + // ── read_db_salt ────────────────────────────────────────────────────────── + + #[test] + fn test_read_db_salt_plaintext_sqlite() { + let dir = make_temp_dir("salt-plain"); + let path = dir.join("plain.db"); + // 明文 SQLite 头:前 15 字节是 "SQLite format 3" + let mut content = b"SQLite format 3\x00".to_vec(); + content.extend_from_slice(&[0u8; 100]); + fs::write(&path, &content).unwrap(); + + assert!(read_db_salt(&path).is_none(), "明文 SQLite 应返回 None"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_read_db_salt_encrypted() { + let dir = make_temp_dir("salt-enc"); + let path = dir.join("enc.db"); + // 非 SQLite 头 → 视为加密数据库,取前 16 字节作为 salt + let header: [u8; 16] = [ + 0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, + 0x0b, 0x0c, + ]; + fs::write(&path, &header).unwrap(); + + let salt = read_db_salt(&path).expect("加密 DB 应返回 Some"); + assert_eq!(salt, "deadbeef0102030405060708090a0b0c"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_read_db_salt_too_short() { + let dir = make_temp_dir("salt-short"); + let path = dir.join("short.db"); + fs::write(&path, b"tooshort").unwrap(); // < 16 bytes + + assert!(read_db_salt(&path).is_none(), "文件太短应返回 None"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_read_db_salt_nonexistent() { + assert!(read_db_salt(Path::new("/nonexistent/surely/not/here.db")).is_none()); + } + + #[test] + fn test_read_db_salt_exactly_16_bytes() { + let dir = make_temp_dir("salt-16"); + let path = dir.join("exact.db"); + let header = [0xabu8; 16]; + fs::write(&path, &header).unwrap(); + + let salt = read_db_salt(&path).unwrap(); + // 0xab × 16 → "ab" × 16 = 32 chars + assert_eq!(salt, "ab".repeat(16)); + fs::remove_dir_all(&dir).ok(); + } + + // ── collect_db_salts ────────────────────────────────────────────────────── + + #[test] + fn test_collect_db_salts_empty_dir() { + let dir = make_temp_dir("collect-empty"); + let salts = collect_db_salts(&dir); + assert!(salts.is_empty()); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_collect_db_salts_skips_plaintext_sqlite() { + let dir = make_temp_dir("collect-plain"); + let mut content = b"SQLite format 3\x00".to_vec(); + content.extend_from_slice(&[0u8; 100]); + fs::write(dir.join("plain.db"), &content).unwrap(); + + assert!(collect_db_salts(&dir).is_empty(), "明文 SQLite 应被跳过"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_collect_db_salts_finds_encrypted() { + let dir = make_temp_dir("collect-enc"); + let header = [0x11u8; 16]; + fs::write(dir.join("msg.db"), &header).unwrap(); + + let salts = collect_db_salts(&dir); + assert_eq!(salts.len(), 1); + assert_eq!(salts[0].0, "11".repeat(16)); // 0x11 × 16 → "11" × 16 + assert_eq!(salts[0].1, "msg.db"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_collect_db_salts_recursive() { + let dir = make_temp_dir("collect-rec"); + let subdir = dir.join("sub"); + fs::create_dir_all(&subdir).unwrap(); + + let header = [0xaau8; 16]; + fs::write(dir.join("root.db"), &header).unwrap(); + fs::write(subdir.join("nested.db"), &header).unwrap(); + fs::write(dir.join("ignored.txt"), b"text file").unwrap(); + + let salts = collect_db_salts(&dir); + assert_eq!(salts.len(), 2, "应递归找到 2 个加密 .db"); + + let names: Vec<&str> = salts.iter().map(|(_, n)| n.as_str()).collect(); + assert!(names.contains(&"root.db")); + assert!(names.contains(&"sub/nested.db")); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_collect_db_salts_ignores_non_db_extensions() { + let dir = make_temp_dir("collect-ext"); + let header = [0xbbu8; 16]; + fs::write(dir.join("data.txt"), &header).unwrap(); + fs::write(dir.join("data.json"), &header).unwrap(); + fs::write(dir.join("data.sqlite"), &header).unwrap(); + + assert!(collect_db_salts(&dir).is_empty(), "非 .db 文件应被忽略"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_collect_db_salts_multiple_files_unique_salts() { + let dir = make_temp_dir("collect-multi"); + fs::write(dir.join("a.db"), &[0x11u8; 16]).unwrap(); + fs::write(dir.join("b.db"), &[0x22u8; 16]).unwrap(); + fs::write(dir.join("c.db"), &[0x33u8; 16]).unwrap(); + + let salts = collect_db_salts(&dir); + assert_eq!(salts.len(), 3); + + let salt_vals: std::collections::HashSet<&str> = + salts.iter().map(|(s, _)| s.as_str()).collect(); + assert!(salt_vals.contains("11".repeat(16).as_str())); + assert!(salt_vals.contains("22".repeat(16).as_str())); + assert!(salt_vals.contains("33".repeat(16).as_str())); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn test_decode_key_hex() { + let key = decode_key_hex(&"ab".repeat(32)).unwrap(); + assert_eq!(key, [0xabu8; 32]); + assert!(decode_key_hex("not-a-key").is_none()); + assert!(decode_key_hex(&"gg".repeat(32)).is_none()); + } + + #[test] + fn writable_readable_page_accepts_common_rw_bases() { + assert!(is_writable_readable_page(WIN_PAGE_READWRITE)); + assert!(is_writable_readable_page(WIN_PAGE_WRITECOPY)); + assert!(is_writable_readable_page(WIN_PAGE_EXECUTE_READWRITE)); + assert!(is_writable_readable_page(WIN_PAGE_EXECUTE_WRITECOPY)); + // modifier bits must not hide a writable base + assert!(is_writable_readable_page(WIN_PAGE_READWRITE | WIN_PAGE_GUARD)); + assert!(is_writable_readable_page( + WIN_PAGE_WRITECOPY | WIN_PAGE_NOCACHE | WIN_PAGE_WRITECOMBINE + )); + } + + #[test] + fn writable_readable_page_rejects_readonly_and_execute_only() { + const PAGE_READONLY: u32 = 0x02; + const PAGE_EXECUTE: u32 = 0x10; + const PAGE_EXECUTE_READ: u32 = 0x20; + assert!(!is_writable_readable_page(PAGE_READONLY)); + assert!(!is_writable_readable_page(PAGE_EXECUTE)); + assert!(!is_writable_readable_page(PAGE_EXECUTE_READ)); + assert!(!is_writable_readable_page(0)); + } + + #[test] + fn chat_message_shard_classifier() { + assert!(is_chat_message_shard("message/message_0.db")); + assert!(is_chat_message_shard("message/message_12.db")); + assert!(is_chat_message_shard(r"message\message_1.db")); + assert!(!is_chat_message_shard("message/message_fts.db")); + assert!(!is_chat_message_shard("message/message_resource.db")); + assert!(!is_chat_message_shard("message/biz_message_0.db")); + assert!(!is_chat_message_shard("migrate/unspportmsg.db")); + assert!(!is_chat_message_shard("session/session.db")); + } + + #[test] + fn critical_missing_vs_optional() { + assert!(is_critical_missing_db("message/message_1.db")); + assert!(is_critical_missing_db("session/session.db")); + assert!(is_critical_missing_db("message/message_fts.db")); + assert!(!is_critical_missing_db("migrate/unspportmsg.db")); + assert!(!is_critical_missing_db("solitaire/solitaire.db")); + } + + #[test] + fn missing_db_rank_orders_chat_first() { + assert!(missing_db_rank("message/message_2.db") < missing_db_rank("message/media_0.db")); + assert!(missing_db_rank("message/media_0.db") < missing_db_rank("migrate/x.db")); + } + + #[test] + fn format_db_size_human() { + assert_eq!(format_db_size(500), "500B"); + assert_eq!(format_db_size(2048), "2.0KB"); + assert_eq!(format_db_size(2 * 1024 * 1024), "2.0MB"); + } + + #[test] + fn list_missing_sorted_by_rank_and_size() { + let dir = make_temp_dir("missing-sort"); + let msg = dir.join("message"); + fs::create_dir_all(&msg).unwrap(); + let mig = dir.join("migrate"); + fs::create_dir_all(&mig).unwrap(); + // encrypted headers (non-SQLite magic) + let big = [0xAAu8; 16]; + let small = [0xBBu8; 16]; + let mid = [0xCCu8; 16]; + fs::write(msg.join("message_1.db"), { + let mut v = big.to_vec(); + v.extend(vec![1u8; 1000]); + v + }) + .unwrap(); + fs::write(msg.join("message_2.db"), { + let mut v = mid.to_vec(); + v.extend(vec![1u8; 100]); + v + }) + .unwrap(); + fs::write(mig.join("unspportmsg.db"), { + let mut v = small.to_vec(); + v.extend(vec![1u8; 50]); + v + }) + .unwrap(); + + let known = vec![KeyEntry { + db_name: "message/message_2.db".into(), + enc_key: "00".repeat(32), + salt: String::new(), + }]; + let miss = list_missing_encrypted_dbs(&dir, &known); + assert_eq!(miss.len(), 2); + assert_eq!(miss[0].rel, "message/message_1.db"); + assert_eq!(miss[1].rel, "migrate/unspportmsg.db"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn scan_key_patterns_finds_xhex() { + let key = "aa".repeat(32); + let salt = "bb".repeat(16); + let mut buf = b"noise".to_vec(); + buf.extend(format!("x'{key}{salt}'").into_bytes()); + buf.extend(b"tail"); + let mut results = Vec::new(); + scan_key_patterns(&buf, &mut results); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, key); + assert_eq!(results[0].1, salt); + } + + #[test] + fn salt_adjacent_picks_key_before_salt() { + let salt = [0x11u8; 16]; + // non-uniform key (all-same bytes are filtered as low-entropy) + let mut key = [0u8; 32]; + for (i, b) in key.iter_mut().enumerate() { + *b = (i as u8).wrapping_mul(7).wrapping_add(3); + } + let mut buf = key.to_vec(); + buf.extend_from_slice(&salt); + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + collect_salt_adjacent_keys(&buf, &[salt], &mut out, &mut seen); + let expect = key.iter().map(|b| format!("{:02x}", b)).collect::(); + assert!(out.iter().any(|h| h == &expect), "got {out:?}"); + } +} diff --git a/src/scanner/windows.rs b/src/scanner/windows.rs new file mode 100644 index 0000000..9118da9 --- /dev/null +++ b/src/scanner/windows.rs @@ -0,0 +1,169 @@ +/// Windows WeChat 进程内存密钥扫描器 +/// +/// 使用 Windows API: +/// - CreateToolhelp32Snapshot + Process32Next: 枚举进程找 Weixin.exe +/// - OpenProcess: 获取进程句柄(需要 PROCESS_VM_READ | PROCESS_QUERY_INFORMATION) +/// - VirtualQueryEx: 枚举内存区域 +/// - ReadProcessMemory: 读取内存内容 +use anyhow::{Context, Result}; +use std::path::Path; +use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory; +use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32First, Process32Next, PROCESSENTRY32, TH32CS_SNAPPROCESS, +}; +use windows::Win32::System::Memory::{ + VirtualQueryEx, MEMORY_BASIC_INFORMATION, MEM_COMMIT, +}; +use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ}; + +use super::{ + collect_db_salts, is_writable_readable_page, match_raw_keys, scan_key_patterns, KeyEntry, + MAX_PATTERN_BYTES, +}; + +const CHUNK_SIZE: usize = 2 * 1024 * 1024; + +/// 查找 Weixin.exe 进程 PID +fn find_wechat_pid() -> Option { + // SAFETY: CreateToolhelp32Snapshot 标准 Windows API + let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()? }; + + let mut entry = PROCESSENTRY32 { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + // SAFETY: Process32First/Process32Next 标准快照遍历 + unsafe { + if Process32First(snap, &mut entry).is_err() { + let _ = CloseHandle(snap); + return None; + } + loop { + let name = + std::ffi::CStr::from_ptr(entry.szExeFile.as_ptr() as *const i8).to_string_lossy(); + if name.eq_ignore_ascii_case("Weixin.exe") { + let pid = entry.th32ProcessID; + let _ = CloseHandle(snap); + return Some(pid); + } + if Process32Next(snap, &mut entry).is_err() { + break; + } + } + let _ = CloseHandle(snap); + } + None +} + +pub fn scan_keys(db_dir: &Path) -> Result> { + let pid = find_wechat_pid().context("找不到 Weixin.exe 进程,请确认微信正在运行")?; + eprintln!("WeChat PID: {}", pid); + + // SAFETY: OpenProcess 请求读取权限 + let process = unsafe { + OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, false, pid) + .context("OpenProcess 失败,请以管理员权限运行")? + }; + + let db_salts = collect_db_salts(db_dir); + eprintln!("找到 {} 个加密数据库", db_salts.len()); + + eprintln!("扫描进程内存..."); + let raw_keys = scan_memory(process)?; + eprintln!("找到 {} 个候选密钥", raw_keys.len()); + + // SAFETY: 关闭进程句柄 + unsafe { + let _ = CloseHandle(process); + } + + let entries = match_raw_keys(db_dir, &raw_keys, &db_salts); + eprintln!( + "匹配到 {}/{} 个数据库密钥(来自 {} 个候选 key)", + entries.len(), + db_salts.len(), + raw_keys.len() + ); + Ok(entries) +} + +fn scan_memory(process: HANDLE) -> Result> { + let mut results: Vec<(String, String)> = Vec::new(); + let mut addr: usize = 0; + + loop { + let mut mbi = MEMORY_BASIC_INFORMATION::default(); + // SAFETY: VirtualQueryEx 枚举进程内存区域 + let ret = unsafe { + VirtualQueryEx( + process, + Some(addr as *const _), + &mut mbi, + std::mem::size_of::(), + ) + }; + if ret == 0 { + break; + } + + let region_size = mbi.RegionSize; + let base = mbi.BaseAddress as usize; + + // 只扫描已提交的可读可写页面(含 WRITECOPY / EXECUTE_*WRITE*;见 + // `is_writable_readable_page`,从 old-main #54 捞回)。 + if mbi.State == MEM_COMMIT && is_writable_readable_page(mbi.Protect.0) { + scan_region(process, base, region_size, &mut results); + } + + addr = base.saturating_add(region_size); + if addr == 0 { + break; // overflow + } + } + + Ok(results) +} + +fn scan_region(process: HANDLE, base: usize, size: usize, results: &mut Vec<(String, String)>) { + let overlap = MAX_PATTERN_BYTES; + let mut offset = 0usize; + + loop { + if offset >= size { + break; + } + let chunk_size = std::cmp::min(CHUNK_SIZE, size - offset); + let addr = base + offset; + let mut buf = vec![0u8; chunk_size]; + let mut bytes_read: usize = 0; + + // SAFETY: ReadProcessMemory 读取目标进程内存 + let ok = unsafe { + ReadProcessMemory( + process, + addr as *const _, + buf.as_mut_ptr() as *mut _, + chunk_size, + Some(&mut bytes_read), + ) + .is_ok() + }; + + if ok && bytes_read > 0 { + buf.truncate(bytes_read); + search_pattern(&buf, results); + } + + if chunk_size > overlap { + offset += chunk_size - overlap; + } else { + offset += chunk_size; + } + } +} + +fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) { + scan_key_patterns(buf, results); +}