mirror of
https://github.com/pandorafuture/wx-cli.git
synced 2026-08-29 04:00:55 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bed9e767a | ||
|
|
7a8f881d33 | ||
|
|
c8f90385a3 | ||
|
|
4ffd4355e0 | ||
|
|
ef6d1ca655 | ||
|
|
26bae79fc4 |
@@ -2,6 +2,40 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.7.4] - 2026-07-22
|
||||
|
||||
### Features
|
||||
|
||||
- **Contact and conversation avatars** — Expose an optional `avatar_url` in contact and enriched session JSON, preferring the small avatar and falling back to the large avatar while remaining compatible with older database schemas
|
||||
- **Image quality metadata** — Mark `/api/v1/media` image responses as `full` or `thumbnail`, while continuing to prefer locally available high-resolution/original image files
|
||||
|
||||
### Maintenance
|
||||
|
||||
- Keep formatting and Clippy clean on the current stable Rust toolchain, and serialize integration-test server startup to avoid ephemeral-port races
|
||||
|
||||
## [0.7.3] - 2026-07-10
|
||||
|
||||
### Features
|
||||
|
||||
- **Agent-ready WeChat data layer** — Ship an Agent Skill for Claude Code, Codex, Cursor, and other compatible agents to query and subscribe to local WeChat data
|
||||
- **Cross-conversation timeline** — Add `GET /api/v1/timeline` for time-bounded message reads across all conversations in one request, with pagination, ordering, message-type filters, and privacy filtering
|
||||
- **Compact timeline output** — Return the conversation identity, sender, direction, timestamp, type, and snippet needed by memory, archive, and reporting agents without duplicating raw message payloads
|
||||
|
||||
### Performance
|
||||
|
||||
- **Reuse SQLCipher derived keys** — Cache per-database derived keys and reuse them across open, count, refresh, and reopen paths instead of repeating the 256k-round KDF
|
||||
- **Bound timeline memory** — Keep only the candidates required for the requested page rather than retaining the complete cross-conversation history during sorting
|
||||
- **Faster long-running server queries** — Reuse warm database connections for batch timeline reads, avoiding one CLI process and HTTP round trip per conversation
|
||||
|
||||
### Documentation and maintenance
|
||||
|
||||
- Rewrite the README around user-facing capabilities: local database access, real-time subscriptions, Agent integration, automation, memory, CRM, and workflow use cases
|
||||
- Document Release installation and the bundled Agent Skill
|
||||
- Update project dependencies and GitHub Actions
|
||||
- Restore clean `cargo fmt --check`, `cargo clippy -- -D warnings`, and full-workspace test baselines
|
||||
|
||||
## [0.7.2] - 2026-04-06
|
||||
|
||||
### Features
|
||||
|
||||
@@ -2,6 +2,37 @@
|
||||
|
||||
本文件记录项目的所有重要变更。
|
||||
|
||||
## [未发布]
|
||||
|
||||
## [0.7.4] - 2026-07-22
|
||||
|
||||
### 功能
|
||||
|
||||
- **联系人和会话头像** — 联系人与会话 JSON 新增可选 `avatar_url`;优先使用小头像、回退大头像,并兼容没有头像字段的旧数据库
|
||||
- **图片质量元数据** — `/api/v1/media` 图片响应会标记为 `full` 或 `thumbnail`,并继续优先返回本机已有的高清/原图文件
|
||||
|
||||
### 维护
|
||||
|
||||
- 适配当前 stable Rust 的格式化和 Clippy 检查,并串行化集成测试服务启动,避免并行抢占临时端口
|
||||
|
||||
## [0.7.3] - 2026-07-10
|
||||
|
||||
### 功能
|
||||
|
||||
- **Agent 可用的微信数据层** — 提供 Agent Skill,让 Claude Code、Codex、Cursor 等 Agent 查询和订阅本地微信数据
|
||||
- **跨会话时间线** — 新增 `GET /api/v1/timeline`,可在有界时间范围内一次读取所有会话,并支持分页、排序、消息类型筛选和隐私过滤
|
||||
- **精简时间线输出** — 返回记忆、归档和报告 Agent 所需的会话、发送者、方向、时间、类型和摘要字段
|
||||
|
||||
### 性能
|
||||
|
||||
- 复用 SQLCipher 派生密钥,避免在打开、计数、刷新和重开数据库时重复执行 25.6 万轮 KDF
|
||||
- 限制时间线排序内存,长驻服务会复用预热的数据库连接
|
||||
|
||||
### 文档与维护
|
||||
|
||||
- 重写 README,补充 Release 安装方式和 Agent Skill 说明
|
||||
- 更新依赖与 GitHub Actions,恢复格式化、Clippy 和全量测试基线
|
||||
|
||||
## [0.7.2] - 2026-04-06
|
||||
|
||||
### 功能
|
||||
|
||||
Generated
+49
-42
@@ -263,31 +263,6 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bon"
|
||||
version = "3.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2f04f6fef12d70d42a77b1433c9e0f065238479a6cefc4f5bab105e9873a3c3"
|
||||
dependencies = [
|
||||
"bon-macros",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bon-macros"
|
||||
version = "3.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d0bd4c2f75335ad98052a37efb54f428b492f64340257143b3429c8a508fa7b"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"ident_case",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustversion",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
@@ -594,6 +569,37 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
|
||||
dependencies = [
|
||||
"derive_builder_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_core"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder_macro"
|
||||
version = "0.20.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
|
||||
dependencies = [
|
||||
"derive_builder_core",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -2578,12 +2584,12 @@ checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191"
|
||||
|
||||
[[package]]
|
||||
name = "vergen"
|
||||
version = "10.0.0"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7bdf18a54cf91b4d98a8e8b67f6321606539fbcdcac02536286ad1de37b53fd2"
|
||||
checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bon",
|
||||
"derive_builder",
|
||||
"rustversion",
|
||||
"time",
|
||||
"vergen-lib",
|
||||
@@ -2591,12 +2597,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vergen-gitcl"
|
||||
version = "10.0.0"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4961429ed12888cb3c6dd20f7dc9508c821091a3ba5fec0156ed5a654c1c4572"
|
||||
checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bon",
|
||||
"derive_builder",
|
||||
"rustversion",
|
||||
"time",
|
||||
"vergen",
|
||||
@@ -2605,12 +2611,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "vergen-lib"
|
||||
version = "10.0.0"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "910e8471e27130bbc019e9bfa6bda16dfc4c6dd7c5d0793da70a9256caeae984"
|
||||
checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bon",
|
||||
"derive_builder",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
@@ -3126,7 +3132,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
||||
|
||||
[[package]]
|
||||
name = "wx-cli"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -3162,7 +3168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wx-context"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
@@ -3186,7 +3192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wx-db"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"insta",
|
||||
@@ -3197,12 +3203,13 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"wx-decrypt",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wx-decrypt"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
@@ -3215,7 +3222,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wx-keychain"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
@@ -3237,7 +3244,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wx-media"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"base64",
|
||||
@@ -3255,7 +3262,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wx-monitor"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
@@ -3276,7 +3283,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wx-paths"
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"libc",
|
||||
|
||||
+16
-1
@@ -3,8 +3,23 @@ members = ["crates/wx-decrypt", "crates/wx-keychain", "crates/wx-cli", "crates/w
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.2"
|
||||
version = "0.7.4"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/pandorafuture/wx-cli"
|
||||
description = "WeChat macOS database decryption and query tool"
|
||||
|
||||
# PBKDF2 intentionally runs 256k rounds. Keep the crypto crate optimized in
|
||||
# dev/test builds so parallel integration tests and local debug binaries do not
|
||||
# spend seconds per database deriving SQLCipher keys.
|
||||
[profile.dev.package.wx-decrypt]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.pbkdf2]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.sha2]
|
||||
opt-level = 3
|
||||
|
||||
[profile.dev.package.hmac]
|
||||
opt-level = 3
|
||||
|
||||
@@ -1,6 +1,39 @@
|
||||
# wx-cli
|
||||
|
||||
WeChat macOS 数据库解密与查询工具。支持通过 `key extract`(LLDB hook)提取密钥,解密并查询 WeChat 4.1.7.x / 4.1.8.x 的 Apple SEE 加密 SQLite 数据库。
|
||||
> 把微信变成 Agent 能读取、能搜索、能实时订阅的数据源。
|
||||
|
||||
wx-cli 直接读取 Mac 上的微信本地数据,让你和 Agent 都能访问自己的聊天记录、联系人、群聊和媒体消息。数据默认留在本机,不需要上传聊天数据库,也不依赖云端导出。
|
||||
|
||||
## 它能做什么
|
||||
|
||||
- **读取微信本地数据库**:按联系人、群聊、时间范围和消息类型查询历史消息。
|
||||
- **搜索全部聊天记录**:从所有会话中查关键词,快速找回客户需求、承诺、文件和讨论结论。
|
||||
- **一次读取跨会话时间线**:按时间范围取得所有会话的新消息,适合记忆补全、归档和日报任务。
|
||||
- **实时订阅新消息**:用命令行持续监听,或通过 SSE 把新消息实时推给 Agent 和其他程序。
|
||||
- **导出与处理内容**:把会话导出为 JSON 或文本,并读取图片、语音、视频等媒体内容。
|
||||
- **让 Agent 直接使用**:项目自带 Agent Skill,Claude Code、Codex、Cursor 等工具安装后就知道怎样查询和订阅微信。
|
||||
- **提供稳定的本地服务**:REST API 可供个人助理、自动化任务、工作流和多个 Agent 共同使用。
|
||||
- **保护不想暴露的内容**:可隐藏指定联系人、群聊、标签或群成员,查询和订阅时自动过滤。
|
||||
|
||||
## 你可以基于它在微信上做什么
|
||||
|
||||
wx-cli 提供了最关键的两样东西:完整的历史上下文,以及持续发生的实时消息。把它接给 Agent 后,你可以基于这个项目在微信上做任何事,例如:
|
||||
|
||||
- 给 Agent 建立长期微信记忆,自动维护联系人画像和关系上下文;
|
||||
- 从聊天里识别待办、承诺、商机、风险和需要跟进的人;
|
||||
- 做个人或团队的微信搜索、知识库、CRM、客服和销售助手;
|
||||
- 自动生成日报、周报、客户纪要、对账线索和项目进展;
|
||||
- 监听关键词或关键联系人,在重要消息出现时触发提醒和工作流;
|
||||
- 结合你已有的 Agent 操作或消息发送能力,实现自动回复、业务办理和端到端协作。
|
||||
|
||||
它不是只用来“导出聊天记录”的工具,而是微信之上的 Agent 能力层。
|
||||
|
||||
## 为什么对 Agent 友好
|
||||
|
||||
- 自带可直接安装的 Skill,不需要每次重新教 Agent 命令和数据格式;
|
||||
- 命令行、JSON、REST API 和实时事件订阅覆盖查询与持续运行两类任务;
|
||||
- 长驻服务可复用已打开的数据库,适合高频查询和定时记忆任务;
|
||||
- 所有能力都以本地数据为中心,便于控制隐私边界。
|
||||
|
||||
## 支持范围
|
||||
|
||||
@@ -60,10 +93,9 @@ source ~/.zshrc
|
||||
wx-cli --version
|
||||
```
|
||||
|
||||
### 让 Agent 直接使用
|
||||
|
||||
### AI 编程助手集成
|
||||
|
||||
本项目提供 [Agent Skill](https://skills.sh),安装后 Claude Code、Codex、Cursor 等 AI 编程助手可直接协助查询微信数据:
|
||||
本项目提供 [Agent Skill](https://skills.sh)。安装后,Claude Code、Codex、Cursor 等 Agent 可以直接理解 wx-cli 的能力,并帮你读取历史消息、搜索聊天和订阅新消息:
|
||||
|
||||
```bash
|
||||
npx skills add pandorafuture/wx-cli
|
||||
@@ -141,7 +173,13 @@ wx-cli server stop # 停止
|
||||
wx-cli server restart # 重启
|
||||
```
|
||||
|
||||
REST 端点:`/api/v1/health`、`/api/v1/sessions`、`/api/v1/contacts`、`/api/v1/messages`、`/api/v1/search`、`/api/v1/media`、`/api/v1/events`(SSE)。
|
||||
REST 端点:`/api/v1/health`、`/api/v1/sessions`、`/api/v1/contacts`、`/api/v1/messages`、`/api/v1/timeline`、`/api/v1/search`、`/api/v1/media`、`/api/v1/events`(SSE)。
|
||||
|
||||
`/api/v1/media` 的图片响应会通过 `X-Wechat-Media-Quality: full|thumbnail` 标明本机返回的是完整图还是缩略图;服务始终优先选择本机已经下载的高清/原图。
|
||||
|
||||
会话与联系人 JSON 在本地数据库有记录时会返回可选的 `avatar_url`,可用于展示个人或群聊头像;没有头像字段的旧数据库会省略该值。
|
||||
|
||||
其中 `/api/v1/timeline?since=<unix>&until=<unix>` 可在一次请求中读取时间范围内所有会话的消息,适合 Agent 记忆补全、归档和批处理,避免逐会话反复调用。
|
||||
|
||||
所有查询命令加 `--format json` 可获取 JSON 格式输出。
|
||||
|
||||
|
||||
@@ -258,10 +258,13 @@ wx-cli server status / stop / restart # 管理服务
|
||||
| `GET /api/v1/sessions` | 会话列表 | `limit`, `offset`, `order`, `show_hidden` |
|
||||
| `GET /api/v1/contacts` | 联系人列表 | `limit`, `offset`, `search`, `show_hidden` |
|
||||
| `GET /api/v1/messages` | 消息查询 | `contact`(必填), `limit`, `offset`, `since`, `until`, `type`, `order`, `around_sort_seq`, `around_server_id`, `after_sort_seq`, `context`, `show_hidden` |
|
||||
| `GET /api/v1/timeline` | 跨全部会话按时间批量读取消息 | `since`(必填), `until`(必填), `limit`, `offset`, `type`, `order`, `show_hidden` |
|
||||
| `GET /api/v1/media` | 媒体内容直出 | `server_id`(必填), `talker`(必填), `format=ogg\|mp3`(仅语音) |
|
||||
| `GET /api/v1/search` | 全文搜索 | `q`(必填), `limit`, `offset` |
|
||||
| `GET /api/v1/events` | SSE 事件流 | 无 |
|
||||
|
||||
图片响应会携带 `X-Wechat-Media-Quality: full|thumbnail`。服务会优先返回本机已有的高清/原图文件;若微信只下载过缩略图,则返回 `thumbnail`,调用方不应长期缓存,并可在微信下载原图后重试。
|
||||
|
||||
**认证:** 带 `--token` 启动时须携带 `Authorization: Bearer <token>`。`--host` 不是本机地址时 `--token` 必填。
|
||||
|
||||
当前 HTTP API 为**只读**,没有 send/reply/webhook 等写接口。
|
||||
@@ -311,13 +314,15 @@ wx-cli sessions # 4. 重试查询
|
||||
|
||||
| 命令 | item 关键字段 |
|
||||
|------|-------------|
|
||||
| sessions | `username`, `display_name`, `summary`, `sort_timestamp`, `direction?` |
|
||||
| sessions | `username`, `display_name`, `avatar_url?`, `summary`, `sort_timestamp`, `direction?` |
|
||||
| query | `sort_seq`, `server_id`, `msg_type`, `sender`, `content`, `direction` |
|
||||
| contacts | `user_name`, `alias`, `remark`, `nick_name`, `phone`, `labels` |
|
||||
| timeline API | `sort_seq`, `server_id`, `msg_type`, `sender`, `talker`, `talker_display_name`, `create_time`, `direction`, `snippet`;统一按时间跨会话排序 |
|
||||
| contacts | `user_name`, `alias`, `remark`, `nick_name`, `avatar_url?`, `phone`, `labels` |
|
||||
| search | `server_id`, `talker`, `sender`, `snippet`, `hit_type` |
|
||||
|
||||
**易混淆字段:**
|
||||
- contacts 用 `user_name`(下划线),sessions 用 `username`(无下划线)
|
||||
- `avatar_url` 优先取微信小头像地址,缺失时回退大头像地址;本地没有记录时不输出
|
||||
- message 的 `sender` 才是消息级 self/other 判断依据
|
||||
- `/api/v1/health` 的 `current_account.wxid` 是判断"我发的"的主事实源
|
||||
|
||||
|
||||
@@ -45,4 +45,4 @@ tempfile = "3"
|
||||
silk-rs = "0.2"
|
||||
|
||||
[build-dependencies]
|
||||
vergen-gitcl = { version = "10", features = ["build"] }
|
||||
vergen-gitcl = { version = "9", features = ["build"] }
|
||||
|
||||
@@ -6,7 +6,9 @@ use wx_db::Contact;
|
||||
use super::thin_client::{ThinClientCliArgs, ThinClientOptions};
|
||||
use crate::output::JsonEnvelope;
|
||||
use crate::settings::Settings;
|
||||
use crate::util::{effective_limit_all, open_db_core, print_cache_stats, print_detection_note, try_remote_or_local};
|
||||
use crate::util::{
|
||||
effective_limit_all, open_db_core, print_cache_stats, print_detection_note, try_remote_or_local,
|
||||
};
|
||||
use crate::visibility_projection::project_contacts_envelope;
|
||||
use crate::OutputFormat;
|
||||
|
||||
|
||||
@@ -74,17 +74,15 @@ pub fn cmd_decrypt(
|
||||
KeyMaterial::EncKey { key, salt } => {
|
||||
wx_decrypt::decrypt_db_direct(db_path, &out_path, key, salt, params)
|
||||
}
|
||||
KeyMaterial::EncKeys(pairs) => {
|
||||
match wx_decrypt::read_main_db_salt_for_path(db_path) {
|
||||
Ok(db_salt) => match pairs.iter().find(|p| p.salt == db_salt) {
|
||||
Some(pair) => wx_decrypt::decrypt_db_direct(
|
||||
db_path, &out_path, &pair.key, &pair.salt, params,
|
||||
),
|
||||
None => Err(wx_decrypt::DecryptError::NoMatchingEncKey),
|
||||
},
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
KeyMaterial::EncKeys(pairs) => match wx_decrypt::read_main_db_salt_for_path(db_path) {
|
||||
Ok(db_salt) => match pairs.iter().find(|p| p.salt == db_salt) {
|
||||
Some(pair) => wx_decrypt::decrypt_db_direct(
|
||||
db_path, &out_path, &pair.key, &pair.salt, params,
|
||||
),
|
||||
None => Err(wx_decrypt::DecryptError::NoMatchingEncKey),
|
||||
},
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
};
|
||||
|
||||
match db_result {
|
||||
@@ -98,9 +96,9 @@ pub fn cmd_decrypt(
|
||||
KeyMaterial::RawKey(key) => {
|
||||
wx_decrypt::decrypt_wal(&wal_path, &out_path, key, params)
|
||||
}
|
||||
KeyMaterial::EncKey { key, salt } => wx_decrypt::decrypt_wal_direct(
|
||||
&wal_path, &out_path, key, salt, params,
|
||||
),
|
||||
KeyMaterial::EncKey { key, salt } => {
|
||||
wx_decrypt::decrypt_wal_direct(&wal_path, &out_path, key, salt, params)
|
||||
}
|
||||
KeyMaterial::EncKeys(pairs) => {
|
||||
match wx_decrypt::read_main_db_salt_for_path(&wal_path) {
|
||||
Ok(db_salt) => match pairs.iter().find(|p| p.salt == db_salt) {
|
||||
|
||||
@@ -7,9 +7,9 @@ use wx_context::{
|
||||
};
|
||||
use wx_db::{is_group_chat, MessageContent, MessageQuery, SortOrder, MAX_QUERY_LIMIT};
|
||||
|
||||
use crate::cmd::contacts::build_visibility;
|
||||
use crate::cmd::export_media::{MediaKind, MediaStats};
|
||||
use crate::cmd::query::resolve_talker;
|
||||
use crate::cmd::contacts::build_visibility;
|
||||
use crate::output::{JsonEnvelope, PagingMeta, StatsMeta};
|
||||
use crate::schema::{enrich_message, project_message_items, EnrichedMessage};
|
||||
use crate::util::{
|
||||
@@ -247,10 +247,9 @@ pub fn cmd_export(
|
||||
}
|
||||
|
||||
// Resolve media via parallel pipeline (or skip)
|
||||
let (media_map, media_stats, _media_errors) = if no_media || cache.is_none() {
|
||||
let (media_map, media_stats, _media_errors) = if no_media {
|
||||
(vec![vec![]; projected.len()], MediaStats::default(), None)
|
||||
} else {
|
||||
let c = cache.as_ref().unwrap();
|
||||
} else if let Some(c) = cache.as_ref() {
|
||||
let attach_dir = acct.data_dir.join("msg").join("attach");
|
||||
let decrypted_media = c.decrypted_root().join("message");
|
||||
let hardlink_db = c.decrypted_root().join("hardlink").join("hardlink.db");
|
||||
@@ -294,6 +293,8 @@ pub fn cmd_export(
|
||||
};
|
||||
combined.print_report();
|
||||
(media_map, stats, Some(combined))
|
||||
} else {
|
||||
(vec![vec![]; projected.len()], MediaStats::default(), None)
|
||||
};
|
||||
|
||||
let total_media: usize = media_map.iter().map(Vec::len).sum();
|
||||
|
||||
@@ -421,7 +421,10 @@ impl MediaBridge {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn export_image_bytes(decoded_data: Vec<u8>, decoded_ext: &str) -> (Vec<u8>, String, bool, bool) {
|
||||
pub fn export_image_bytes(
|
||||
decoded_data: Vec<u8>,
|
||||
decoded_ext: &str,
|
||||
) -> (Vec<u8>, String, bool, bool) {
|
||||
if decoded_ext != "wxgf" {
|
||||
return (decoded_data, decoded_ext.to_string(), false, false);
|
||||
}
|
||||
|
||||
@@ -197,10 +197,9 @@ impl VoiceConnectionPool {
|
||||
fn open_all(&self) -> Vec<Connection> {
|
||||
let mut conns = Vec::new();
|
||||
for path in &self.db_paths {
|
||||
if let Ok(conn) = Connection::open_with_flags(
|
||||
path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
||||
) {
|
||||
if let Ok(conn) =
|
||||
Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
{
|
||||
conns.push(conn);
|
||||
}
|
||||
}
|
||||
@@ -209,7 +208,7 @@ impl VoiceConnectionPool {
|
||||
|
||||
pub fn with_connections<R>(&self, f: impl FnOnce(&[Connection]) -> R) -> R {
|
||||
thread_local! {
|
||||
static CONNS: RefCell<Option<(u64, Vec<Connection>)>> = RefCell::new(None);
|
||||
static CONNS: RefCell<Option<(u64, Vec<Connection>)>> = const { RefCell::new(None) };
|
||||
}
|
||||
CONNS.with(|cell| {
|
||||
let mut borrow = cell.borrow_mut();
|
||||
@@ -242,16 +241,12 @@ impl HardlinkConnectionPool {
|
||||
}
|
||||
|
||||
fn open(&self) -> Option<Connection> {
|
||||
Connection::open_with_flags(
|
||||
&self.db_path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
||||
)
|
||||
.ok()
|
||||
Connection::open_with_flags(&self.db_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY).ok()
|
||||
}
|
||||
|
||||
pub fn with_connection<R>(&self, f: impl FnOnce(&Connection) -> R) -> Option<R> {
|
||||
thread_local! {
|
||||
static CONN: RefCell<Option<(u64, Connection)>> = RefCell::new(None);
|
||||
static CONN: RefCell<Option<(u64, Connection)>> = const { RefCell::new(None) };
|
||||
}
|
||||
CONN.with(|cell| {
|
||||
let mut borrow = cell.borrow_mut();
|
||||
@@ -299,6 +294,7 @@ pub struct DupMap {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build shared context from account/session info (pre-compute stage).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn build_shared_context(
|
||||
attach_dir: PathBuf,
|
||||
media_dir: PathBuf,
|
||||
@@ -432,12 +428,7 @@ pub fn dedup(tasks: Vec<MediaTask>) -> (Vec<MediaTask>, DupMap) {
|
||||
unique.push(task);
|
||||
}
|
||||
|
||||
(
|
||||
unique,
|
||||
DupMap {
|
||||
duplicates,
|
||||
},
|
||||
)
|
||||
(unique, DupMap { duplicates })
|
||||
}
|
||||
|
||||
/// Default rayon thread pool size: min(num_cpus, 4).
|
||||
@@ -469,7 +460,12 @@ pub fn resolve_parallel(
|
||||
batches.entry(task.kind()).or_default().push(task);
|
||||
}
|
||||
|
||||
let order = [TaskKind::Image, TaskKind::Voice, TaskKind::Video, TaskKind::File];
|
||||
let order = [
|
||||
TaskKind::Image,
|
||||
TaskKind::Voice,
|
||||
TaskKind::Video,
|
||||
TaskKind::File,
|
||||
];
|
||||
let mut all_results = Vec::new();
|
||||
let mut all_errors = ErrorSummary::default();
|
||||
|
||||
@@ -531,7 +527,10 @@ pub fn resolve_parallel(
|
||||
fn resolve_one(task: &MediaTask, ctx: &SharedContext) -> ResolvedAsset {
|
||||
match task {
|
||||
MediaTask::Image { md5, msg_index } => resolve_image(md5, *msg_index, ctx),
|
||||
MediaTask::Voice { server_id, msg_index } => resolve_voice(*server_id, *msg_index, ctx),
|
||||
MediaTask::Voice {
|
||||
server_id,
|
||||
msg_index,
|
||||
} => resolve_voice(*server_id, *msg_index, ctx),
|
||||
MediaTask::Video {
|
||||
md5,
|
||||
create_time,
|
||||
@@ -668,7 +667,8 @@ fn resolve_voice(server_id: i64, msg_index: usize, ctx: &SharedContext) -> Resol
|
||||
|
||||
let blob = ctx.voice_pool.with_connections(|conns| {
|
||||
for conn in conns {
|
||||
if let Ok(b) = wx_media::extract_voice_with_conn_hint(conn, &svr_id, chat_name_id_hint) {
|
||||
if let Ok(b) = wx_media::extract_voice_with_conn_hint(conn, &svr_id, chat_name_id_hint)
|
||||
{
|
||||
return Some(b);
|
||||
}
|
||||
}
|
||||
@@ -750,9 +750,9 @@ fn resolve_video(
|
||||
ctx: &SharedContext,
|
||||
) -> ResolvedAsset {
|
||||
// Try hardlink DB first
|
||||
let hardlink_result = ctx.hardlink_pool.with_connection(|conn| {
|
||||
wx_media::query_hardlink_with_conn(conn, "video", md5)
|
||||
});
|
||||
let hardlink_result = ctx
|
||||
.hardlink_pool
|
||||
.with_connection(|conn| wx_media::query_hardlink_with_conn(conn, "video", md5));
|
||||
|
||||
let entries = match hardlink_result {
|
||||
Some(Ok(e)) => Some(e),
|
||||
@@ -860,9 +860,9 @@ fn resolve_file(
|
||||
ctx: &SharedContext,
|
||||
) -> ResolvedAsset {
|
||||
// Try hardlink DB first
|
||||
let hardlink_result = ctx.hardlink_pool.with_connection(|conn| {
|
||||
wx_media::query_hardlink_with_conn(conn, "file", md5)
|
||||
});
|
||||
let hardlink_result = ctx
|
||||
.hardlink_pool
|
||||
.with_connection(|conn| wx_media::query_hardlink_with_conn(conn, "file", md5));
|
||||
|
||||
let entries = match hardlink_result {
|
||||
Some(Ok(e)) => Some(e),
|
||||
@@ -889,7 +889,7 @@ fn resolve_file(
|
||||
let filename = format!("{}_{}", md5, entry.file_name);
|
||||
if ctx.write_gate.claim(&filename) {
|
||||
let out_path = ctx.output_media_dir.join(&filename);
|
||||
if let Err(e) = std::fs::copy(&source, &out_path) {
|
||||
if let Err(e) = std::fs::copy(source, &out_path) {
|
||||
return ResolvedAsset {
|
||||
msg_index,
|
||||
asset: None,
|
||||
@@ -973,23 +973,19 @@ pub fn collect(
|
||||
let mut errors = ErrorSummary::default();
|
||||
|
||||
// Build index from results by msg_index
|
||||
let mut by_index: HashMap<usize, (Option<MediaAsset>, Vec<TaskTag>, Option<ExportError>)> =
|
||||
HashMap::new();
|
||||
let mut by_index: HashMap<usize, (Option<MediaAsset>, Vec<TaskTag>)> = HashMap::new();
|
||||
for r in results {
|
||||
if let Some(e) = r.error {
|
||||
errors.errors.push(e);
|
||||
}
|
||||
by_index.insert(
|
||||
r.msg_index,
|
||||
(r.asset, r.tags, None),
|
||||
);
|
||||
by_index.insert(r.msg_index, (r.asset, r.tags));
|
||||
}
|
||||
|
||||
// Place canonical results — count tags always, copy asset only when present.
|
||||
// Matches old MediaBridge: SkippedVideo/SkippedFile stats counted unconditionally;
|
||||
// image stats (ThumbnailImage, WxgfTranscoded, WxgfFallback) also counted
|
||||
// because canonical always does the full resolve.
|
||||
for (msg_idx, (asset, tags, _)) in &by_index {
|
||||
for (msg_idx, (asset, tags)) in &by_index {
|
||||
apply_tags(&mut stats, tags);
|
||||
if let Some(a) = asset {
|
||||
media_map[*msg_idx].push(a.clone());
|
||||
@@ -1003,8 +999,12 @@ pub fn collect(
|
||||
// This two-step approach matches old MediaBridge behavior where skipped/fallback
|
||||
// stats were counted regardless of dedup, but image stats only counted once.
|
||||
for (dup_msg_idx, canonical_msg_idx) in &dup_map.duplicates {
|
||||
if let Some((asset, tags, _)) = by_index.get(canonical_msg_idx) {
|
||||
let dup_tags: Vec<TaskTag> = tags.iter().copied().filter(|t| t.counts_on_duplicate()).collect();
|
||||
if let Some((asset, tags)) = by_index.get(canonical_msg_idx) {
|
||||
let dup_tags: Vec<TaskTag> = tags
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|t| t.counts_on_duplicate())
|
||||
.collect();
|
||||
apply_tags(&mut stats, &dup_tags);
|
||||
if let Some(a) = asset {
|
||||
media_map[*dup_msg_idx].push(a.clone());
|
||||
@@ -1416,7 +1416,10 @@ mod tests {
|
||||
// Second task: Voice
|
||||
assert!(matches!(
|
||||
&tasks[1],
|
||||
MediaTask::Voice { server_id: 2, msg_index: 1 }
|
||||
MediaTask::Voice {
|
||||
server_id: 2,
|
||||
msg_index: 1
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1446,7 +1449,11 @@ mod tests {
|
||||
let encrypted: Vec<u8> = wxgf.iter().map(|b| b ^ xor_key).collect();
|
||||
|
||||
let username_hash = format!("{:x}", wx_media::md5_hash(talker.as_bytes()));
|
||||
let img_dir = root.join("attach").join(&username_hash).join("2026-03").join("Img");
|
||||
let img_dir = root
|
||||
.join("attach")
|
||||
.join(&username_hash)
|
||||
.join("2026-03")
|
||||
.join("Img");
|
||||
std::fs::create_dir_all(&img_dir).unwrap();
|
||||
std::fs::write(img_dir.join(format!("{md5}.dat")), &encrypted).unwrap();
|
||||
|
||||
@@ -1572,10 +1579,7 @@ mod tests {
|
||||
let silk = sample_silk();
|
||||
create_voice_media_db(
|
||||
&media_dir.join("media_0.db"),
|
||||
&[
|
||||
(55, 1000, 1, 101, &silk),
|
||||
(55, 1001, 2, 102, &silk),
|
||||
],
|
||||
&[(55, 1000, 1, 101, &silk), (55, 1001, 2, 102, &silk)],
|
||||
);
|
||||
|
||||
let ctx = Arc::new(build_shared_context(
|
||||
|
||||
@@ -64,10 +64,7 @@ pub async fn cmd_key_extract(timeout_secs: u64) -> Result<(), Box<dyn std::error
|
||||
Some(matched.base_wxid.clone()),
|
||||
);
|
||||
store.save_default()?;
|
||||
eprintln!(
|
||||
"Key saved to {:?}",
|
||||
wx_keychain::KeyStore::default_path()?
|
||||
);
|
||||
eprintln!("Key saved to {:?}", wx_keychain::KeyStore::default_path()?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -102,8 +99,7 @@ pub fn cmd_key_scan() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
// Scan process memory.
|
||||
eprintln!("Scanning WeChat process memory...");
|
||||
let results =
|
||||
wx_keychain::capture_key_mach(pid, &accounts, &wx_decrypt::MACOS_4_1_7_31)?;
|
||||
let results = wx_keychain::capture_key_mach(pid, &accounts, &wx_decrypt::MACOS_4_1_7_31)?;
|
||||
|
||||
// Count total pairs across all results
|
||||
let total_pairs: usize = results
|
||||
@@ -126,18 +122,15 @@ pub fn cmd_key_scan() -> Result<(), Box<dyn std::error::Error>> {
|
||||
for r in &results {
|
||||
let matched = &r.matched_account;
|
||||
|
||||
let nickname = wx_keychain::resolve_nickname(
|
||||
&matched.data_dir,
|
||||
&r.key_material,
|
||||
&matched.base_wxid,
|
||||
)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
" Warning: nickname resolution failed for {}: {e}",
|
||||
matched.account_id
|
||||
);
|
||||
None
|
||||
});
|
||||
let nickname =
|
||||
wx_keychain::resolve_nickname(&matched.data_dir, &r.key_material, &matched.base_wxid)
|
||||
.unwrap_or_else(|e| {
|
||||
eprintln!(
|
||||
" Warning: nickname resolution failed for {}: {e}",
|
||||
matched.account_id
|
||||
);
|
||||
None
|
||||
});
|
||||
|
||||
let pairs = match &r.key_material {
|
||||
wx_decrypt::KeyMaterial::EncKeys(pairs) => pairs,
|
||||
@@ -173,10 +166,7 @@ pub fn cmd_key_scan() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
|
||||
store.save_default()?;
|
||||
eprintln!(
|
||||
"Keys saved to {:?}",
|
||||
wx_keychain::KeyStore::default_path()?
|
||||
);
|
||||
eprintln!("Keys saved to {:?}", wx_keychain::KeyStore::default_path()?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -39,7 +39,13 @@ fn print_paths_table(summary: &PathsSummary) {
|
||||
} else {
|
||||
"[missing]"
|
||||
};
|
||||
println!("{:<width$} {:<60} {}", label, display, status, width = max_label);
|
||||
println!(
|
||||
"{:<width$} {:<60} {}",
|
||||
label,
|
||||
display,
|
||||
status,
|
||||
width = max_label
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,24 +77,23 @@ pub fn cmd_query(
|
||||
|
||||
if options.is_enabled() && !preserve_local_warning {
|
||||
let client = ThinClient::new(options.clone());
|
||||
match client.probe_health().and_then(|_| {
|
||||
fetch_remote_query(
|
||||
&client,
|
||||
contact,
|
||||
since,
|
||||
until,
|
||||
msg_type.clone(),
|
||||
effective_limit,
|
||||
offset,
|
||||
order.clone(),
|
||||
around_sort_seq,
|
||||
around_server_id,
|
||||
context,
|
||||
after_sort_seq,
|
||||
show_hidden,
|
||||
)
|
||||
}) {
|
||||
Ok(envelope) => {
|
||||
match client.probe_health() {
|
||||
Ok(()) => {
|
||||
let envelope = fetch_remote_query(
|
||||
&client,
|
||||
contact,
|
||||
since,
|
||||
until,
|
||||
msg_type.clone(),
|
||||
effective_limit,
|
||||
offset,
|
||||
order.clone(),
|
||||
around_sort_seq,
|
||||
around_server_id,
|
||||
context,
|
||||
after_sort_seq,
|
||||
show_hidden,
|
||||
)?;
|
||||
let is_group = envelope
|
||||
.items
|
||||
.first()
|
||||
@@ -299,9 +298,7 @@ fn load_local_query(
|
||||
// When limit pushdown was used (non-anchor, non-all), total_rows only reflects the
|
||||
// scanned window. Use a lightweight COUNT(*) query to get the actual DB-level total.
|
||||
if !has_anchor && !all {
|
||||
let mt_filter = msg_type
|
||||
.as_ref()
|
||||
.and_then(|s| wx_db::parse_msg_type(s));
|
||||
let mt_filter = msg_type.as_ref().and_then(|s| wx_db::parse_msg_type(s));
|
||||
let db_total = db.count_messages(
|
||||
&talker,
|
||||
since.unwrap_or(0),
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use wx_context::{
|
||||
open_fts_connection_with_key, AccountContext, ContactResolver, ResolveParams,
|
||||
};
|
||||
use wx_context::{register_mm_fts_tokenizer, AccountContext, ContactResolver, ResolveParams};
|
||||
|
||||
use super::thin_client::{ThinClient, ThinClientCliArgs, ThinClientOptions};
|
||||
use crate::output::{JsonEnvelope, PagingMeta, StatsMeta};
|
||||
use crate::schema::{enrich_message_as_hit, enrich_native_fts_hit, SearchHit};
|
||||
use crate::util::{effective_limit_all, open_db_all, print_cache_stats, print_detection_note, try_remote_or_local};
|
||||
use crate::util::{
|
||||
effective_limit_all, open_db_all, print_cache_stats, print_detection_note, try_remote_or_local,
|
||||
};
|
||||
use crate::OutputFormat;
|
||||
|
||||
// Unused imports kept for Task 6 cleanup reference:
|
||||
@@ -64,14 +64,13 @@ fn load_local_search(
|
||||
|
||||
// --- Native FTS search → fallback to scan ---
|
||||
let use_fallback = match db.message_fts_path.as_deref() {
|
||||
Some(fts_path) => match open_fts_connection_with_key(fts_path, acct.raw_key.as_ref()) {
|
||||
Some(fts_path) => match db.open_related_readonly(fts_path).and_then(|conn| {
|
||||
register_mm_fts_tokenizer(&conn).map_err(wx_db::DbError::FtsInit)?;
|
||||
Ok(conn)
|
||||
}) {
|
||||
Ok(conn) => {
|
||||
match wx_db::native_fts::search_message_fts(
|
||||
&conn,
|
||||
keyword,
|
||||
effective_limit,
|
||||
offset,
|
||||
) {
|
||||
match wx_db::native_fts::search_message_fts(&conn, keyword, effective_limit, offset)
|
||||
{
|
||||
Ok(result) => {
|
||||
return native_fts_envelope(
|
||||
result,
|
||||
|
||||
@@ -23,10 +23,7 @@ struct BridgeState {
|
||||
startup_watermark: i64,
|
||||
}
|
||||
|
||||
fn should_broadcast_talker(
|
||||
visibility: &wx_context::VisibilityIndex,
|
||||
talker: &str,
|
||||
) -> bool {
|
||||
fn should_broadcast_talker(visibility: &wx_context::VisibilityIndex, talker: &str) -> bool {
|
||||
!visibility.is_hidden_talker(talker)
|
||||
}
|
||||
|
||||
@@ -407,25 +404,40 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn enrich_messages_filters_hidden_sender_in_group() {
|
||||
let visibility = VisibilityIndex::build(
|
||||
&["wxid_spam".to_string()],
|
||||
&[],
|
||||
&ContactResolver::empty(),
|
||||
);
|
||||
let visibility =
|
||||
VisibilityIndex::build(&["wxid_spam".to_string()], &[], &ContactResolver::empty());
|
||||
let msgs = vec![
|
||||
wx_db::Message {
|
||||
sort_seq: 1, server_id: 1, msg_type: 1, sub_type: 0,
|
||||
sender: "wxid_spam".to_string(), talker: "group@chatroom".to_string(),
|
||||
create_time: 100, content: wx_db::MessageContent::Text("spam".into()), status: 0,
|
||||
sort_seq: 1,
|
||||
server_id: 1,
|
||||
msg_type: 1,
|
||||
sub_type: 0,
|
||||
sender: "wxid_spam".to_string(),
|
||||
talker: "group@chatroom".to_string(),
|
||||
create_time: 100,
|
||||
content: wx_db::MessageContent::Text("spam".into()),
|
||||
status: 0,
|
||||
},
|
||||
wx_db::Message {
|
||||
sort_seq: 2, server_id: 2, msg_type: 1, sub_type: 0,
|
||||
sender: "wxid_normal".to_string(), talker: "group@chatroom".to_string(),
|
||||
create_time: 101, content: wx_db::MessageContent::Text("hello".into()), status: 0,
|
||||
sort_seq: 2,
|
||||
server_id: 2,
|
||||
msg_type: 1,
|
||||
sub_type: 0,
|
||||
sender: "wxid_normal".to_string(),
|
||||
talker: "group@chatroom".to_string(),
|
||||
create_time: 101,
|
||||
content: wx_db::MessageContent::Text("hello".into()),
|
||||
status: 0,
|
||||
},
|
||||
];
|
||||
|
||||
let result = enrich_messages(msgs, "wxid_me", &ContactResolver::empty(), "group@chatroom", &visibility);
|
||||
let result = enrich_messages(
|
||||
msgs,
|
||||
"wxid_me",
|
||||
&ContactResolver::empty(),
|
||||
"group@chatroom",
|
||||
&visibility,
|
||||
);
|
||||
assert_eq!(result.len(), 1, "hidden sender message should be filtered");
|
||||
assert_eq!(result[0].message.sender, "wxid_normal");
|
||||
}
|
||||
@@ -433,11 +445,8 @@ mod tests {
|
||||
#[test]
|
||||
fn session_sender_redaction_in_bridge() {
|
||||
use crate::schema::project_session_sender;
|
||||
let visibility = VisibilityIndex::build(
|
||||
&["wxid_spam".to_string()],
|
||||
&[],
|
||||
&ContactResolver::empty(),
|
||||
);
|
||||
let visibility =
|
||||
VisibilityIndex::build(&["wxid_spam".to_string()], &[], &ContactResolver::empty());
|
||||
let ev = wx_monitor::SessionEvent {
|
||||
username: "group@chatroom".to_string(),
|
||||
sort_timestamp: 1,
|
||||
@@ -448,7 +457,8 @@ mod tests {
|
||||
last_msg_sender: Some("wxid_spam".to_string()),
|
||||
last_sender_display_name: Some("Spammer".to_string()),
|
||||
};
|
||||
let mut enriched = crate::schema::enrich_session_event(ev, "wxid_me", &ContactResolver::empty());
|
||||
let mut enriched =
|
||||
crate::schema::enrich_session_event(ev, "wxid_me", &ContactResolver::empty());
|
||||
project_session_sender(&mut enriched, &visibility);
|
||||
|
||||
assert_eq!(enriched.session.summary, "[消息已隐藏]");
|
||||
|
||||
@@ -47,6 +47,7 @@ mod tests {
|
||||
last_sender_display_name: None,
|
||||
},
|
||||
display_name: "wxid_friend".to_string(),
|
||||
avatar_url: None,
|
||||
direction: None,
|
||||
detected_at: None,
|
||||
},
|
||||
@@ -70,6 +71,7 @@ mod tests {
|
||||
last_sender_display_name: None,
|
||||
},
|
||||
display_name: "wxid_friend".to_string(),
|
||||
avatar_url: None,
|
||||
direction: None,
|
||||
detected_at: Some(2),
|
||||
},
|
||||
|
||||
@@ -8,20 +8,18 @@ use axum::http::StatusCode;
|
||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::cmd::server::types::{RuntimeAccountState, ServerHealthPayload};
|
||||
use crate::output::JsonEnvelope;
|
||||
use crate::output::{JsonEnvelope, PagingMeta, StatsMeta};
|
||||
use crate::schema::{
|
||||
enrich_message, enrich_message_as_hit, enrich_native_fts_hit, enrich_session,
|
||||
project_message_items,
|
||||
};
|
||||
use crate::visibility_projection::{
|
||||
project_contacts_envelope, project_sessions_envelope_enriched,
|
||||
};
|
||||
use crate::visibility_projection::{project_contacts_envelope, project_sessions_envelope_enriched};
|
||||
|
||||
use super::error::ServeError;
|
||||
use super::event::SseEvent;
|
||||
@@ -92,6 +90,68 @@ mod tests {
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
fn timeline_params(since: Option<i64>, until: Option<i64>) -> TimelineParams {
|
||||
TimelineParams {
|
||||
since,
|
||||
until,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
msg_type: None,
|
||||
order: "asc".to_string(),
|
||||
show_hidden: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_requires_a_bounded_time_range() {
|
||||
assert!(timeline_bounds(&timeline_params(None, Some(20))).is_err());
|
||||
assert!(timeline_bounds(&timeline_params(Some(10), None)).is_err());
|
||||
assert!(timeline_bounds(&timeline_params(Some(20), Some(10))).is_err());
|
||||
match timeline_bounds(&timeline_params(Some(10), Some(20))) {
|
||||
Ok(bounds) => assert_eq!(bounds, (10, 20)),
|
||||
Err(_) => panic!("valid timeline bounds should be accepted"),
|
||||
}
|
||||
}
|
||||
|
||||
fn timeline_message(create_time: i64) -> TimelineMessage {
|
||||
TimelineMessage {
|
||||
sort_seq: create_time,
|
||||
server_id: create_time,
|
||||
msg_type: 1,
|
||||
sub_type: 0,
|
||||
sender: "wxid_other".to_string(),
|
||||
talker: "wxid_other".to_string(),
|
||||
talker_display_name: "Other".to_string(),
|
||||
create_time,
|
||||
status: 0,
|
||||
sender_display_name: "Other".to_string(),
|
||||
direction: wx_context::Direction::detect("wxid_other", "wxid_me"),
|
||||
snippet: create_time.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_candidate_trimming_keeps_requested_edge() {
|
||||
let source = [5, 1, 3, 2, 4]
|
||||
.into_iter()
|
||||
.map(timeline_message)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut asc = source.clone();
|
||||
trim_timeline_candidates(&mut asc, wx_db::SortOrder::Asc, 2, false);
|
||||
assert_eq!(
|
||||
asc.iter().map(|item| item.create_time).collect::<Vec<_>>(),
|
||||
vec![1, 2]
|
||||
);
|
||||
|
||||
let mut desc = source;
|
||||
trim_timeline_candidates(&mut desc, wx_db::SortOrder::Desc, 2, false);
|
||||
assert_eq!(
|
||||
desc.iter().map(|item| item.create_time).collect::<Vec<_>>(),
|
||||
vec![5, 4]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -221,8 +281,7 @@ pub async fn handler_contacts(
|
||||
.query_contacts(&query)
|
||||
.map_err(|e| ServeError::Db(e.to_string()))?;
|
||||
|
||||
let envelope =
|
||||
JsonEnvelope::from_query_result(result, wx_db::MAX_QUERY_LIMIT, 0, |c| c);
|
||||
let envelope = JsonEnvelope::from_query_result(result, wx_db::MAX_QUERY_LIMIT, 0, |c| c);
|
||||
Ok::<_, ServeError>(project_contacts_envelope(
|
||||
envelope.items,
|
||||
&visibility,
|
||||
@@ -379,9 +438,7 @@ pub async fn handler_messages(
|
||||
// When limit pushdown was used (non-anchor), total_rows only reflects the
|
||||
// scanned window. Use a lightweight COUNT(*) query for accurate DB-level total.
|
||||
if !has_anchor {
|
||||
let mt_filter = msg_type
|
||||
.as_ref()
|
||||
.and_then(|s| wx_db::parse_msg_type(s));
|
||||
let mt_filter = msg_type.as_ref().and_then(|s| wx_db::parse_msg_type(s));
|
||||
let db_total = guard.count_messages(
|
||||
&talker,
|
||||
since.unwrap_or(0),
|
||||
@@ -405,6 +462,219 @@ pub async fn handler_messages(
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeline — messages across every conversation in one bounded query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TimelineParams {
|
||||
pub since: Option<i64>,
|
||||
pub until: Option<i64>,
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: usize,
|
||||
#[serde(default)]
|
||||
pub offset: usize,
|
||||
#[serde(rename = "type")]
|
||||
pub msg_type: Option<String>,
|
||||
#[serde(default = "default_order")]
|
||||
pub order: String,
|
||||
pub show_hidden: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct TimelineMessage {
|
||||
pub sort_seq: i64,
|
||||
pub server_id: i64,
|
||||
pub msg_type: u32,
|
||||
pub sub_type: u32,
|
||||
pub sender: String,
|
||||
pub talker: String,
|
||||
pub talker_display_name: String,
|
||||
pub create_time: i64,
|
||||
pub status: i32,
|
||||
pub sender_display_name: String,
|
||||
pub direction: wx_context::Direction,
|
||||
pub snippet: String,
|
||||
}
|
||||
|
||||
impl TimelineMessage {
|
||||
fn from_enriched(message: crate::schema::EnrichedMessage, talker_display_name: String) -> Self {
|
||||
let crate::schema::EnrichedMessage {
|
||||
message,
|
||||
sender_display_name,
|
||||
direction,
|
||||
snippet,
|
||||
} = message;
|
||||
Self {
|
||||
sort_seq: message.sort_seq,
|
||||
server_id: message.server_id,
|
||||
msg_type: message.msg_type,
|
||||
sub_type: message.sub_type,
|
||||
sender: message.sender,
|
||||
talker: message.talker,
|
||||
talker_display_name,
|
||||
create_time: message.create_time,
|
||||
status: message.status,
|
||||
sender_display_name,
|
||||
direction,
|
||||
snippet,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_timeline_messages(messages: &mut [TimelineMessage], order: wx_db::SortOrder) {
|
||||
match order {
|
||||
wx_db::SortOrder::Asc => {
|
||||
messages.sort_unstable_by_key(|item| (item.create_time, item.sort_seq, item.server_id))
|
||||
}
|
||||
wx_db::SortOrder::Desc => messages.sort_unstable_by(|a, b| {
|
||||
(b.create_time, b.sort_seq, b.server_id).cmp(&(a.create_time, a.sort_seq, a.server_id))
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_timeline_candidates(
|
||||
messages: &mut Vec<TimelineMessage>,
|
||||
order: wx_db::SortOrder,
|
||||
keep_limit: usize,
|
||||
force: bool,
|
||||
) {
|
||||
if force || messages.len() > keep_limit.saturating_mul(2) {
|
||||
sort_timeline_messages(messages, order);
|
||||
messages.truncate(keep_limit);
|
||||
}
|
||||
}
|
||||
|
||||
fn timeline_bounds(params: &TimelineParams) -> Result<(i64, i64), ServeError> {
|
||||
let since = params
|
||||
.since
|
||||
.ok_or_else(|| ServeError::InvalidParam("missing required parameter: since".into()))?;
|
||||
let until = params
|
||||
.until
|
||||
.ok_or_else(|| ServeError::InvalidParam("missing required parameter: until".into()))?;
|
||||
if since > until {
|
||||
return Err(ServeError::InvalidParam(
|
||||
"since must be less than or equal to until".into(),
|
||||
));
|
||||
}
|
||||
Ok((since, until))
|
||||
}
|
||||
|
||||
/// Read a time-bounded timeline across all conversations in one server request.
|
||||
///
|
||||
/// This is intentionally implemented inside the warm server process. Agent memory jobs and
|
||||
/// archive tools no longer need to launch one CLI process and make one HTTP round-trip for every
|
||||
/// active conversation.
|
||||
pub async fn handler_timeline(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(params): Query<TimelineParams>,
|
||||
) -> Result<impl IntoResponse, ServeError> {
|
||||
let (since, until) = timeline_bounds(¶ms)?;
|
||||
let db = Arc::clone(&state.db);
|
||||
let resolver = Arc::clone(&state.resolver);
|
||||
let visibility = Arc::clone(&state.visibility);
|
||||
let self_wxid = state.self_wxid.clone();
|
||||
let limit = wx_db::effective_limit(params.limit);
|
||||
let offset = params.offset;
|
||||
let keep_limit = offset.saturating_add(limit);
|
||||
let order = parse_order(¶ms.order);
|
||||
let msg_type = params.msg_type.as_deref().and_then(wx_db::parse_msg_type);
|
||||
let show_hidden = matches!(params.show_hidden.as_deref(), Some("1") | Some("true"));
|
||||
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let guard = db.lock().map_err(|e| ServeError::Internal(e.to_string()))?;
|
||||
let sessions = guard
|
||||
.query_sessions(
|
||||
&wx_db::SessionQuery::new()
|
||||
.limit(wx_db::MAX_QUERY_LIMIT)
|
||||
.offset(0),
|
||||
)
|
||||
.map_err(|e| ServeError::Db(e.to_string()))?;
|
||||
|
||||
let mut messages = Vec::new();
|
||||
let mut total = 0usize;
|
||||
let mut scanned = 0usize;
|
||||
let mut skipped = sessions.stats.skipped;
|
||||
let mut shard_warnings = Vec::new();
|
||||
|
||||
for session in sessions.items {
|
||||
let talker = session.username;
|
||||
if !show_hidden && visibility.is_hidden_talker(&talker) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let talker_display_name = resolver.display_with_id(&talker);
|
||||
let mut talker_offset = 0usize;
|
||||
|
||||
loop {
|
||||
let mut query = wx_db::MessageQuery::for_talker(&talker)
|
||||
.since(since)
|
||||
.until(until)
|
||||
.limit(wx_db::MAX_QUERY_LIMIT)
|
||||
.offset(talker_offset)
|
||||
.order(order);
|
||||
if let Some(mt) = msg_type {
|
||||
query = query.msg_type(mt);
|
||||
}
|
||||
|
||||
let page = guard
|
||||
.query_messages(&query)
|
||||
.map_err(|e| ServeError::Db(e.to_string()))?;
|
||||
let returned = page.items.len();
|
||||
scanned = scanned.saturating_add(page.stats.total_rows);
|
||||
skipped = skipped.saturating_add(page.stats.skipped);
|
||||
shard_warnings.extend(page.shard_warnings);
|
||||
|
||||
let enriched = page
|
||||
.items
|
||||
.into_iter()
|
||||
.map(|message| enrich_message(message, &self_wxid, &resolver))
|
||||
.collect();
|
||||
let projected = project_message_items(enriched, &talker, &visibility, show_hidden);
|
||||
total = total.saturating_add(projected.len());
|
||||
messages.extend(projected.into_iter().map(|message| {
|
||||
TimelineMessage::from_enriched(message, talker_display_name.clone())
|
||||
}));
|
||||
|
||||
// Retain only the global candidates needed for this page. The common first-page
|
||||
// path now stays close to 2×limit instead of holding the entire history in memory.
|
||||
trim_timeline_candidates(&mut messages, order, keep_limit, false);
|
||||
|
||||
if returned < wx_db::MAX_QUERY_LIMIT {
|
||||
break;
|
||||
}
|
||||
talker_offset = talker_offset.saturating_add(returned);
|
||||
}
|
||||
}
|
||||
|
||||
trim_timeline_candidates(&mut messages, order, keep_limit, true);
|
||||
let start = offset.min(total);
|
||||
let items: Vec<_> = messages.into_iter().skip(start).take(limit).collect();
|
||||
let returned = items.len();
|
||||
|
||||
Ok::<_, ServeError>(JsonEnvelope {
|
||||
items,
|
||||
paging: PagingMeta {
|
||||
limit,
|
||||
offset,
|
||||
returned,
|
||||
has_more: start + returned < total,
|
||||
total,
|
||||
},
|
||||
stats: StatsMeta {
|
||||
scanned,
|
||||
skipped,
|
||||
elapsed_ms: None,
|
||||
shard_warnings,
|
||||
},
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ServeError::Internal(e.to_string()))??;
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Search
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -447,7 +717,9 @@ pub async fn handler_search(
|
||||
.map_err(|e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()))?;
|
||||
// Lazy-init name2id cache with proper error propagation.
|
||||
let name2id = {
|
||||
let mut cache_guard = state_arc.name2id_cache.lock()
|
||||
let mut cache_guard = state_arc
|
||||
.name2id_cache
|
||||
.lock()
|
||||
.map_err(|e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()))?;
|
||||
match cache_guard.as_ref() {
|
||||
Some(map) => map.clone(),
|
||||
@@ -524,9 +796,7 @@ pub async fn handler_search(
|
||||
let first_attempt = guard
|
||||
.pool()
|
||||
.and_then(|pool| pool.fts_conn())
|
||||
.map(|fts_conn| {
|
||||
wx_db::native_fts::search_message_fts(fts_conn, &q, limit, offset)
|
||||
});
|
||||
.map(|fts_conn| wx_db::native_fts::search_message_fts(fts_conn, &q, limit, offset));
|
||||
|
||||
match first_attempt {
|
||||
Some(Ok(r)) => Some(r),
|
||||
@@ -539,8 +809,8 @@ pub async fn handler_search(
|
||||
guard
|
||||
.pool()
|
||||
.and_then(|pool| pool.fts_conn())
|
||||
.and_then(
|
||||
|fts_conn| match wx_db::native_fts::search_message_fts(
|
||||
.and_then(|fts_conn| {
|
||||
match wx_db::native_fts::search_message_fts(
|
||||
fts_conn, &q, limit, offset,
|
||||
) {
|
||||
Ok(r) => Some(r),
|
||||
@@ -551,8 +821,8 @@ pub async fn handler_search(
|
||||
);
|
||||
None
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
Err(reopen_err) => {
|
||||
eprintln!(
|
||||
|
||||
@@ -8,13 +8,11 @@ use axum::http::HeaderValue;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use tower::ServiceExt;
|
||||
use tower_http::services::ServeFile;
|
||||
use wx_db::{
|
||||
open_readonly_connection, Message, MessageContent, MessageQuery, SortOrder, WechatDb,
|
||||
};
|
||||
use wx_db::{open_readonly_connection, Message, MessageContent, MessageQuery, SortOrder, WechatDb};
|
||||
|
||||
use crate::util::{format_month, sanitize_filename};
|
||||
use super::error::ServeError;
|
||||
use super::state::{AppState, CachedVoicePayload};
|
||||
use crate::util::{format_month, sanitize_filename};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MediaFormat {
|
||||
@@ -44,6 +42,7 @@ enum MediaPayload {
|
||||
InlineBytes {
|
||||
bytes: Vec<u8>,
|
||||
content_type: &'static str,
|
||||
quality: Option<&'static str>,
|
||||
},
|
||||
ServePath {
|
||||
path: PathBuf,
|
||||
@@ -58,11 +57,17 @@ impl MediaPayload {
|
||||
Self::InlineBytes {
|
||||
bytes,
|
||||
content_type,
|
||||
quality,
|
||||
} => {
|
||||
let mut response = bytes.into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
|
||||
if let Some(quality) = quality {
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("x-wechat-media-quality", HeaderValue::from_static(quality));
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
Self::ServePath {
|
||||
@@ -147,8 +152,9 @@ async fn resolve_media(
|
||||
))),
|
||||
MessageContent::Voice => {
|
||||
let db_paths = {
|
||||
let mut cache_guard = state_for_cache.media_db_paths.lock()
|
||||
.map_err(|e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()))?;
|
||||
let mut cache_guard = state_for_cache.media_db_paths.lock().map_err(
|
||||
|e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()),
|
||||
)?;
|
||||
match cache_guard.as_ref() {
|
||||
Some(paths) => paths.clone(),
|
||||
None => {
|
||||
@@ -243,6 +249,14 @@ fn resolve_image(
|
||||
let dat_path = lookup.recommended.ok_or_else(|| {
|
||||
ServeError::NotFound(format!("no candidate image file found for md5={md5}"))
|
||||
})?;
|
||||
let quality = if dat_path
|
||||
.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().ends_with("_t.dat"))
|
||||
{
|
||||
"thumbnail"
|
||||
} else {
|
||||
"full"
|
||||
};
|
||||
let data = std::fs::read(&dat_path)
|
||||
.map_err(|e| ServeError::Internal(format!("failed to read {}: {e}", dat_path.display())))?;
|
||||
let decoded = wx_media::decrypt_dat(&data, &dat_decrypt)
|
||||
@@ -260,12 +274,14 @@ fn resolve_image(
|
||||
return Ok(MediaPayload::InlineBytes {
|
||||
bytes: transcoded.data,
|
||||
content_type: image_content_type(transcoded.ext),
|
||||
quality: Some(quality),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MediaPayload::InlineBytes {
|
||||
bytes: decoded.data,
|
||||
content_type: image_content_type(&decoded.ext),
|
||||
quality: Some(quality),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -289,6 +305,7 @@ fn resolve_voice(
|
||||
return Ok(MediaPayload::InlineBytes {
|
||||
bytes: cached.bytes.clone(),
|
||||
content_type: cached.content_type,
|
||||
quality: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -297,7 +314,7 @@ fn resolve_voice(
|
||||
let mut first_db_error: Option<String> = None;
|
||||
|
||||
for db_path in db_paths {
|
||||
let conn = match open_readonly_connection(&db_path, raw_key.as_ref()) {
|
||||
let conn = match open_readonly_connection(db_path, raw_key.as_ref()) {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
if first_db_error.is_none() {
|
||||
@@ -332,6 +349,7 @@ fn resolve_voice(
|
||||
return Ok(MediaPayload::InlineBytes {
|
||||
bytes: result.data,
|
||||
content_type: result.mime,
|
||||
quality: None,
|
||||
});
|
||||
}
|
||||
Err(wx_media::MediaError::LookupMiss(_))
|
||||
|
||||
@@ -19,8 +19,8 @@ use tokio::signal::unix::SignalKind;
|
||||
use tokio::sync::{broadcast, mpsc, watch};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use wx_context::{
|
||||
open_fts_connection_with_key, register_mm_fts_tokenizer, write_shard_metadata_sidecar,
|
||||
AccountContext, ContactResolver, DecryptRequest, PersistentCache, ResolveParams,
|
||||
register_mm_fts_tokenizer, write_shard_metadata_sidecar, AccountContext, ContactResolver,
|
||||
DecryptRequest, PersistentCache, ResolveParams,
|
||||
};
|
||||
|
||||
use crate::util::{print_cache_stats, print_detection_note};
|
||||
@@ -122,7 +122,10 @@ pub async fn cmd_serve(
|
||||
|
||||
// 3b. Open independent FTS connection (outside WechatDb Mutex)
|
||||
let fts_conn = db.message_fts_path.as_deref().and_then(|fts_path| {
|
||||
match open_fts_connection_with_key(fts_path, acct.raw_key.as_ref()) {
|
||||
match db.open_related_readonly(fts_path).and_then(|conn| {
|
||||
register_mm_fts_tokenizer(&conn).map_err(wx_db::DbError::FtsInit)?;
|
||||
Ok(conn)
|
||||
}) {
|
||||
Ok(conn) => {
|
||||
if let Ok(mode) =
|
||||
conn.query_row("PRAGMA journal_mode", [], |r| r.get::<_, String>(0))
|
||||
@@ -196,7 +199,7 @@ pub async fn cmd_serve(
|
||||
|
||||
// 3c. Open hardlink.db connection (pooled, outside WechatDb Mutex)
|
||||
let hardlink_db_conn = if hardlink_db_path.exists() {
|
||||
match wx_db::open_readonly_connection(&hardlink_db_path, acct.raw_key.as_ref()) {
|
||||
match db.open_related_readonly(&hardlink_db_path) {
|
||||
Ok(conn) => {
|
||||
eprintln!("server/hardlink: opened pooled connection");
|
||||
Some(conn)
|
||||
@@ -222,9 +225,14 @@ pub async fn cmd_serve(
|
||||
}
|
||||
|
||||
let watch_mode = resolve_watch_mode(poll, fsnotify);
|
||||
let monitor_derived_keys = wx_context::persisted_derived_keys(&acct)?;
|
||||
let config = wx_monitor::MonitorConfig {
|
||||
encrypted_session_dir,
|
||||
key_material: acct.key_material.clone(),
|
||||
key_material: if monitor_derived_keys.is_empty() {
|
||||
acct.key_material.clone()
|
||||
} else {
|
||||
wx_decrypt::KeyMaterial::EncKeys(monitor_derived_keys)
|
||||
},
|
||||
params,
|
||||
watch_mode: watch_mode.clone(),
|
||||
poll_interval: Duration::from_millis(poll_ms),
|
||||
@@ -239,7 +247,6 @@ pub async fn cmd_serve(
|
||||
|
||||
// Capture values before moving db into Mutex
|
||||
let fts_path_for_refresh = db.message_fts_path.clone();
|
||||
let raw_key_for_refresh = acct.raw_key;
|
||||
|
||||
// 5. Create refresh task channels
|
||||
let (refresh_tx, refresh_rx) = mpsc::channel::<RefreshTrigger>(64);
|
||||
@@ -273,8 +280,12 @@ pub async fn cmd_serve(
|
||||
hardlink_db_conn,
|
||||
raw_key: acct.raw_key,
|
||||
dat_decrypt,
|
||||
voice_cache: Arc::new(std::sync::Mutex::new(LruCache::new(NonZeroUsize::new(256).unwrap()))),
|
||||
image_xor_cache: Arc::new(std::sync::Mutex::new(LruCache::new(NonZeroUsize::new(1024).unwrap()))),
|
||||
voice_cache: Arc::new(std::sync::Mutex::new(LruCache::new(
|
||||
NonZeroUsize::new(256).unwrap(),
|
||||
))),
|
||||
image_xor_cache: Arc::new(std::sync::Mutex::new(LruCache::new(
|
||||
NonZeroUsize::new(1024).unwrap(),
|
||||
))),
|
||||
name2id_cache: Arc::new(std::sync::Mutex::new(None)),
|
||||
media_db_paths: Arc::new(std::sync::Mutex::new(None)),
|
||||
});
|
||||
@@ -306,6 +317,7 @@ pub async fn cmd_serve(
|
||||
eprintln!(" GET /api/v1/sessions");
|
||||
eprintln!(" GET /api/v1/contacts");
|
||||
eprintln!(" GET /api/v1/messages?contact=<name_or_wxid>");
|
||||
eprintln!(" GET /api/v1/timeline?since=<unix>&until=<unix>");
|
||||
eprintln!(" GET /api/v1/media?server_id=<id>&talker=<wxid>[&format=ogg|mp3]");
|
||||
eprintln!(" GET /api/v1/search?q=<keyword>");
|
||||
eprintln!(" Auth: {auth_status}");
|
||||
@@ -398,7 +410,6 @@ pub async fn cmd_serve(
|
||||
shutdown_bg.clone(),
|
||||
)
|
||||
.with_fts(bg_state.fts_conn.clone(), fts_path_for_refresh)
|
||||
.with_raw_key(raw_key_for_refresh)
|
||||
.with_caches(
|
||||
Some(Arc::clone(&bg_state.name2id_cache)),
|
||||
Some(Arc::clone(&bg_state.media_db_paths)),
|
||||
|
||||
@@ -6,11 +6,13 @@ use rusqlite::Connection;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use wx_context::{
|
||||
open_fts_connection, open_fts_connection_with_key, DecryptProgress, DecryptRequest,
|
||||
open_fts_connection, register_mm_fts_tokenizer, DecryptProgress, DecryptRequest,
|
||||
PersistentCache,
|
||||
};
|
||||
use wx_db::WechatDb;
|
||||
|
||||
type Name2IdCache = Arc<std::sync::Mutex<Option<HashMap<i64, String>>>>;
|
||||
|
||||
/// Signal sent to the refresh task.
|
||||
pub enum RefreshTrigger {
|
||||
Refresh,
|
||||
@@ -35,10 +37,8 @@ pub struct RefreshTask {
|
||||
fts_conn: Option<Arc<std::sync::Mutex<Connection>>>,
|
||||
/// Path to FTS DB for reopening.
|
||||
fts_path: Option<PathBuf>,
|
||||
/// Raw key for encrypted FTS reopen.
|
||||
raw_key: Option<[u8; 32]>,
|
||||
/// Cache of name2id mapping — cleared when FTS is reopened.
|
||||
name2id_cache: Option<Arc<std::sync::Mutex<Option<HashMap<i64, String>>>>>,
|
||||
name2id_cache: Option<Name2IdCache>,
|
||||
/// Cache of media DB paths — cleared on every refresh.
|
||||
media_db_paths: Option<Arc<std::sync::Mutex<Option<Vec<PathBuf>>>>>,
|
||||
/// Cached hardlink.db connection — cleared on refresh so it is reopened lazily.
|
||||
@@ -61,18 +61,12 @@ impl RefreshTask {
|
||||
shutdown,
|
||||
fts_conn: None,
|
||||
fts_path: None,
|
||||
raw_key: None,
|
||||
name2id_cache: None,
|
||||
media_db_paths: None,
|
||||
hardlink_db_conn: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_raw_key(mut self, raw_key: Option<[u8; 32]>) -> Self {
|
||||
self.raw_key = raw_key;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the independent FTS connection and path for refresh reopening.
|
||||
pub fn with_fts(
|
||||
mut self,
|
||||
@@ -87,7 +81,7 @@ impl RefreshTask {
|
||||
/// Set the caches that should be invalidated on refresh.
|
||||
pub fn with_caches(
|
||||
mut self,
|
||||
name2id_cache: Option<Arc<std::sync::Mutex<Option<HashMap<i64, String>>>>>,
|
||||
name2id_cache: Option<Name2IdCache>,
|
||||
media_db_paths: Option<Arc<std::sync::Mutex<Option<Vec<PathBuf>>>>>,
|
||||
hardlink_db_conn: Option<Arc<std::sync::Mutex<Option<Connection>>>>,
|
||||
) -> Self {
|
||||
@@ -128,7 +122,6 @@ impl RefreshTask {
|
||||
let cache = self.cache.clone();
|
||||
let fts_conn = self.fts_conn.clone();
|
||||
let fts_path = self.fts_path.clone();
|
||||
let raw_key = self.raw_key;
|
||||
let success = tokio::task::spawn_blocking(move || {
|
||||
if let Some(cache) = cache {
|
||||
// Decrypt-cache mode: decrypt then selective reopen
|
||||
@@ -264,7 +257,10 @@ impl RefreshTask {
|
||||
|
||||
// Reopen independent FTS connection
|
||||
if let (Some(fts_mutex), Some(path)) = (&fts_conn, &fts_path) {
|
||||
match open_fts_connection_with_key(path, raw_key.as_ref()) {
|
||||
match guard.open_related_readonly(path).and_then(|conn| {
|
||||
register_mm_fts_tokenizer(&conn).map_err(wx_db::DbError::FtsInit)?;
|
||||
Ok(conn)
|
||||
}) {
|
||||
Ok(new_conn) => {
|
||||
if let Ok(mut fts_guard) = fts_mutex.lock()
|
||||
as Result<std::sync::MutexGuard<'_, Connection>, _>
|
||||
|
||||
@@ -15,6 +15,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
||||
.route("/api/v1/sessions", get(handlers::handler_sessions))
|
||||
.route("/api/v1/contacts", get(handlers::handler_contacts))
|
||||
.route("/api/v1/messages", get(handlers::handler_messages))
|
||||
.route("/api/v1/timeline", get(handlers::handler_timeline))
|
||||
.route("/api/v1/media", get(handlers::handler_media))
|
||||
.route("/api/v1/search", get(handlers::handler_search))
|
||||
.route("/api/v1/events", get(handlers::handler_sse))
|
||||
|
||||
@@ -20,7 +20,9 @@ use crate::OutputFormat;
|
||||
const START_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const STOP_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
fn resolve_app_paths(runtime_root: Option<PathBuf>) -> Result<AppPaths, Box<dyn std::error::Error>> {
|
||||
fn resolve_app_paths(
|
||||
runtime_root: Option<PathBuf>,
|
||||
) -> Result<AppPaths, Box<dyn std::error::Error>> {
|
||||
match runtime_root {
|
||||
Some(root) => Ok(AppPaths::with_runtime_root(root)?),
|
||||
None => Ok(AppPaths::new()?),
|
||||
@@ -120,9 +122,8 @@ pub async fn cmd_server_stop(args: ServerStopArgs) -> Result<(), Box<dyn std::er
|
||||
|
||||
pub async fn cmd_server_restart(args: ServerRestartArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let ap = resolve_app_paths(args.runtime_root.clone())?;
|
||||
let config = load_launch_config(&ap)?.ok_or(
|
||||
"no persisted server launch configuration found; run `wx-cli server run` first",
|
||||
)?;
|
||||
let config = load_launch_config(&ap)?
|
||||
.ok_or("no persisted server launch configuration found; run `wx-cli server run` first")?;
|
||||
|
||||
let stop_args = ServerStopArgs {
|
||||
runtime_root: args.runtime_root.clone(),
|
||||
@@ -286,9 +287,7 @@ fn spawn_worker(
|
||||
command.arg("--runtime-root").arg(root);
|
||||
}
|
||||
|
||||
command
|
||||
.arg("--worker-id")
|
||||
.arg(worker_id);
|
||||
command.arg("--worker-id").arg(worker_id);
|
||||
|
||||
if let Some(key) = &config.key {
|
||||
command.arg("--key").arg(key);
|
||||
|
||||
@@ -6,7 +6,9 @@ use super::contacts::build_visibility;
|
||||
use super::thin_client::{ThinClientCliArgs, ThinClientOptions};
|
||||
use crate::output::JsonEnvelope;
|
||||
use crate::schema::{enrich_session, EnrichedSession};
|
||||
use crate::util::{effective_limit_all, open_db_core, print_cache_stats, print_detection_note, try_remote_or_local};
|
||||
use crate::util::{
|
||||
effective_limit_all, open_db_core, print_cache_stats, print_detection_note, try_remote_or_local,
|
||||
};
|
||||
use crate::visibility_projection::project_sessions_envelope_enriched;
|
||||
use crate::{OutputFormat, SortOrderArg};
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ fn format_watch_line(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::items_after_test_module)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -197,15 +198,20 @@ mod tests {
|
||||
#[test]
|
||||
fn watch_text_hidden_sender_shows_placeholder() {
|
||||
use crate::schema::project_session_sender;
|
||||
let visibility = VisibilityIndex::build(
|
||||
&["wxid_spam".to_string()], &[], &ContactResolver::empty(),
|
||||
);
|
||||
let visibility =
|
||||
VisibilityIndex::build(&["wxid_spam".to_string()], &[], &ContactResolver::empty());
|
||||
let mut enriched = make_enriched_session("group@chatroom", "spam msg", Some("wxid_spam"));
|
||||
project_session_sender(&mut enriched, &visibility);
|
||||
|
||||
let line = format_watch_line_from_enriched(&enriched, &ContactResolver::empty(), "wxid_me");
|
||||
assert!(line.contains("[消息已隐藏]"), "should show placeholder: {line}");
|
||||
assert!(!line.contains("wxid_spam"), "should not leak sender wxid: {line}");
|
||||
assert!(
|
||||
line.contains("[消息已隐藏]"),
|
||||
"should show placeholder: {line}"
|
||||
);
|
||||
assert!(
|
||||
!line.contains("wxid_spam"),
|
||||
"should not leak sender wxid: {line}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -281,6 +287,7 @@ fn should_emit_event(
|
||||
show_hidden || !visibility.is_hidden_talker(&event.username)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn cmd_watch(
|
||||
key_hex: Option<String>,
|
||||
data_dir: Option<PathBuf>,
|
||||
@@ -318,9 +325,14 @@ pub async fn cmd_watch(
|
||||
}
|
||||
|
||||
let watch_mode = resolve_watch_mode(poll, fsnotify);
|
||||
let monitor_derived_keys = wx_context::persisted_derived_keys(&acct)?;
|
||||
let config = wx_monitor::MonitorConfig {
|
||||
encrypted_session_dir,
|
||||
key_material: acct.key_material.clone(),
|
||||
key_material: if monitor_derived_keys.is_empty() {
|
||||
acct.key_material.clone()
|
||||
} else {
|
||||
wx_decrypt::KeyMaterial::EncKeys(monitor_derived_keys)
|
||||
},
|
||||
params,
|
||||
watch_mode: watch_mode.clone(),
|
||||
poll_interval: Duration::from_millis(poll_ms),
|
||||
|
||||
@@ -17,6 +17,8 @@ pub struct EnrichedSession {
|
||||
pub session: Session,
|
||||
pub display_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub direction: Option<Direction>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub detected_at: Option<i64>,
|
||||
@@ -61,10 +63,12 @@ pub fn enrich_session(
|
||||
detected_at: Option<i64>,
|
||||
) -> EnrichedSession {
|
||||
let display_name = resolver.display_with_id(&session.username);
|
||||
let avatar_url = resolver.avatar_url(&session.username).map(str::to_string);
|
||||
let direction = derive_session_direction(session.last_msg_sender.as_deref(), self_wxid);
|
||||
EnrichedSession {
|
||||
session,
|
||||
display_name,
|
||||
avatar_url,
|
||||
direction,
|
||||
detected_at,
|
||||
}
|
||||
@@ -76,6 +80,7 @@ pub fn enrich_session_event(
|
||||
resolver: &ContactResolver,
|
||||
) -> EnrichedSession {
|
||||
let display_name = resolver.display_with_id(&ev.username);
|
||||
let avatar_url = resolver.avatar_url(&ev.username).map(str::to_string);
|
||||
let direction = derive_session_direction(ev.last_msg_sender.as_deref(), self_wxid);
|
||||
let session = Session {
|
||||
username: ev.username,
|
||||
@@ -88,6 +93,7 @@ pub fn enrich_session_event(
|
||||
EnrichedSession {
|
||||
session,
|
||||
display_name,
|
||||
avatar_url,
|
||||
direction,
|
||||
detected_at: Some(ev.detected_at),
|
||||
}
|
||||
@@ -540,7 +546,11 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_enriched(sender: &str, talker: &str, content: wx_db::MessageContent) -> EnrichedMessage {
|
||||
fn make_enriched(
|
||||
sender: &str,
|
||||
talker: &str,
|
||||
content: wx_db::MessageContent,
|
||||
) -> EnrichedMessage {
|
||||
let msg = make_message(sender, talker, content);
|
||||
let snippet = format_content(&msg);
|
||||
EnrichedMessage {
|
||||
@@ -562,21 +572,33 @@ mod tests {
|
||||
#[test]
|
||||
fn project_message_item_non_group_does_not_filter() {
|
||||
let vis = vis_with_hidden_persons(&["wxid_spam"]);
|
||||
let msg = make_enriched("wxid_spam", "wxid_spam", wx_db::MessageContent::Text("hi".into()));
|
||||
let msg = make_enriched(
|
||||
"wxid_spam",
|
||||
"wxid_spam",
|
||||
wx_db::MessageContent::Text("hi".into()),
|
||||
);
|
||||
assert!(project_message_item(msg, "wxid_spam", &vis).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_message_item_group_hidden_sender_filtered() {
|
||||
let vis = vis_with_hidden_persons(&["wxid_spam"]);
|
||||
let msg = make_enriched("wxid_spam", "group@chatroom", wx_db::MessageContent::Text("spam".into()));
|
||||
let msg = make_enriched(
|
||||
"wxid_spam",
|
||||
"group@chatroom",
|
||||
wx_db::MessageContent::Text("spam".into()),
|
||||
);
|
||||
assert!(project_message_item(msg, "group@chatroom", &vis).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_message_item_group_visible_sender_kept() {
|
||||
let vis = vis_with_hidden_persons(&["wxid_spam"]);
|
||||
let msg = make_enriched("wxid_normal", "group@chatroom", wx_db::MessageContent::Text("hi".into()));
|
||||
let msg = make_enriched(
|
||||
"wxid_normal",
|
||||
"group@chatroom",
|
||||
wx_db::MessageContent::Text("hi".into()),
|
||||
);
|
||||
assert!(project_message_item(msg, "group@chatroom", &vis).is_some());
|
||||
}
|
||||
|
||||
@@ -679,8 +701,16 @@ mod tests {
|
||||
fn project_message_items_show_hidden_bypasses() {
|
||||
let vis = vis_with_hidden_persons(&["wxid_spam"]);
|
||||
let items = vec![
|
||||
make_enriched("wxid_spam", "group@chatroom", wx_db::MessageContent::Text("spam".into())),
|
||||
make_enriched("wxid_normal", "group@chatroom", wx_db::MessageContent::Text("hi".into())),
|
||||
make_enriched(
|
||||
"wxid_spam",
|
||||
"group@chatroom",
|
||||
wx_db::MessageContent::Text("spam".into()),
|
||||
),
|
||||
make_enriched(
|
||||
"wxid_normal",
|
||||
"group@chatroom",
|
||||
wx_db::MessageContent::Text("hi".into()),
|
||||
),
|
||||
];
|
||||
let result = project_message_items(items, "group@chatroom", &vis, true);
|
||||
assert_eq!(result.len(), 2);
|
||||
@@ -690,8 +720,16 @@ mod tests {
|
||||
fn project_message_items_filters_hidden_sender() {
|
||||
let vis = vis_with_hidden_persons(&["wxid_spam"]);
|
||||
let items = vec![
|
||||
make_enriched("wxid_spam", "group@chatroom", wx_db::MessageContent::Text("spam".into())),
|
||||
make_enriched("wxid_normal", "group@chatroom", wx_db::MessageContent::Text("hi".into())),
|
||||
make_enriched(
|
||||
"wxid_spam",
|
||||
"group@chatroom",
|
||||
wx_db::MessageContent::Text("spam".into()),
|
||||
),
|
||||
make_enriched(
|
||||
"wxid_normal",
|
||||
"group@chatroom",
|
||||
wx_db::MessageContent::Text("hi".into()),
|
||||
),
|
||||
];
|
||||
let result = project_message_items(items, "group@chatroom", &vis, false);
|
||||
assert_eq!(result.len(), 1);
|
||||
@@ -711,6 +749,7 @@ mod tests {
|
||||
last_sender_display_name: None,
|
||||
},
|
||||
display_name: "Spam".to_string(),
|
||||
avatar_url: None,
|
||||
direction: Some(Direction::Incoming),
|
||||
detected_at: None,
|
||||
};
|
||||
@@ -732,6 +771,7 @@ mod tests {
|
||||
last_sender_display_name: Some("Spammer".to_string()),
|
||||
},
|
||||
display_name: "Group".to_string(),
|
||||
avatar_url: None,
|
||||
direction: Some(Direction::Incoming),
|
||||
detected_at: None,
|
||||
};
|
||||
@@ -755,11 +795,15 @@ mod tests {
|
||||
last_sender_display_name: Some("Normal".to_string()),
|
||||
},
|
||||
display_name: "Group".to_string(),
|
||||
avatar_url: None,
|
||||
direction: Some(Direction::Incoming),
|
||||
detected_at: None,
|
||||
};
|
||||
project_session_sender(&mut session, &vis);
|
||||
assert_eq!(session.session.summary, "normal message");
|
||||
assert_eq!(session.session.last_msg_sender.as_deref(), Some("wxid_normal"));
|
||||
assert_eq!(
|
||||
session.session.last_msg_sender.as_deref(),
|
||||
Some("wxid_normal")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+17
-23
@@ -3,6 +3,12 @@ use std::path::PathBuf;
|
||||
use crate::cmd::thin_client::{ThinClient, ThinClientError, ThinClientOptions};
|
||||
use wx_context::{AccountContext, DecryptRequest, DecryptStats, PersistentCache};
|
||||
|
||||
type OpenDbAllResult = (
|
||||
wx_db::WechatDb,
|
||||
Option<PersistentCache>,
|
||||
Option<DecryptStats>,
|
||||
);
|
||||
|
||||
/// Open a WechatDb: direct encrypted open if raw_key available, else decrypt+cache (core only).
|
||||
pub fn open_db_core(
|
||||
acct: &AccountContext,
|
||||
@@ -10,7 +16,7 @@ pub fn open_db_core(
|
||||
) -> Result<(wx_db::WechatDb, Option<DecryptStats>), Box<dyn std::error::Error>> {
|
||||
if acct.raw_key.is_some() {
|
||||
eprintln!("Direct encrypted open (SQLCipher)");
|
||||
let db = wx_context::open_encrypted_db(acct)?;
|
||||
let db = wx_context::open_encrypted_db_core(acct)?;
|
||||
Ok((db, None))
|
||||
} else {
|
||||
let params = &wx_decrypt::MACOS_4_1_7_31;
|
||||
@@ -18,7 +24,7 @@ pub fn open_db_core(
|
||||
let stats = DecryptRequest::new()
|
||||
.core()
|
||||
.execute_with_progress(&cache, progress)?;
|
||||
let db = wx_db::WechatDb::open(cache.decrypted_root())?;
|
||||
let db = wx_db::WechatDb::open_core(cache.decrypted_root())?;
|
||||
Ok((db, Some(stats)))
|
||||
}
|
||||
}
|
||||
@@ -27,14 +33,7 @@ pub fn open_db_core(
|
||||
pub fn open_db_all(
|
||||
acct: &AccountContext,
|
||||
progress: impl Fn(wx_context::DecryptProgress) + Send + Sync,
|
||||
) -> Result<
|
||||
(
|
||||
wx_db::WechatDb,
|
||||
Option<PersistentCache>,
|
||||
Option<DecryptStats>,
|
||||
),
|
||||
Box<dyn std::error::Error>,
|
||||
> {
|
||||
) -> Result<OpenDbAllResult, Box<dyn std::error::Error>> {
|
||||
if acct.raw_key.is_some() {
|
||||
eprintln!("Direct encrypted open (SQLCipher)");
|
||||
let db = wx_context::open_encrypted_db(acct)?;
|
||||
@@ -139,9 +138,10 @@ pub fn effective_limit_all(all: bool, limit: usize) -> usize {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt a remote API call via ThinClient; on connection/auth failure fall back to the
|
||||
/// local path. This encapsulates the `probe_health → remote_fn → should_fallback → local_fn`
|
||||
/// pattern shared by `search`, `contacts`, and `sessions`.
|
||||
/// Attempt a remote API call via ThinClient. In auto mode, fall back locally only when
|
||||
/// the initial health probe cannot reach/authenticate with a usable server. Once health
|
||||
/// succeeds, a failed business request is returned to the caller instead of launching an
|
||||
/// expensive local SQLCipher query after waiting for the remote timeout.
|
||||
pub fn try_remote_or_local<T>(
|
||||
options: &ThinClientOptions,
|
||||
remote_fn: impl FnOnce(&ThinClient) -> Result<T, ThinClientError>,
|
||||
@@ -150,8 +150,8 @@ pub fn try_remote_or_local<T>(
|
||||
) -> Result<T, Box<dyn std::error::Error>> {
|
||||
if options.is_enabled() {
|
||||
let client = ThinClient::new(options.clone());
|
||||
match client.probe_health().and_then(|_| remote_fn(&client)) {
|
||||
Ok(result) => return Ok(result),
|
||||
match client.probe_health() {
|
||||
Ok(()) => return remote_fn(&client).map_err(Into::into),
|
||||
Err(err) if err.should_fallback(options.mode) => {
|
||||
eprintln!(
|
||||
"note: remote server unavailable, falling back to local {label} ({})",
|
||||
@@ -248,14 +248,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn effective_limit_all_true_returns_max() {
|
||||
assert_eq!(
|
||||
effective_limit_all(true, 0),
|
||||
wx_db::MAX_QUERY_LIMIT
|
||||
);
|
||||
assert_eq!(
|
||||
effective_limit_all(true, 50),
|
||||
wx_db::MAX_QUERY_LIMIT
|
||||
);
|
||||
assert_eq!(effective_limit_all(true, 0), wx_db::MAX_QUERY_LIMIT);
|
||||
assert_eq!(effective_limit_all(true, 50), wx_db::MAX_QUERY_LIMIT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -95,9 +95,15 @@ fn ignore_tags_hide_matching_contact_at_both_talker_and_sender_level() {
|
||||
"json",
|
||||
],
|
||||
);
|
||||
let items = group_messages["items"].as_array().expect("query items array");
|
||||
let items = group_messages["items"]
|
||||
.as_array()
|
||||
.expect("query items array");
|
||||
// The only group message is from wxid_hidden_tagged; it should be filtered out
|
||||
assert_eq!(items.len(), 0, "tagged contact's group messages should be sender-level filtered: {group_messages}");
|
||||
assert_eq!(
|
||||
items.len(),
|
||||
0,
|
||||
"tagged contact's group messages should be sender-level filtered: {group_messages}"
|
||||
);
|
||||
|
||||
// Session should show placeholder for group where last sender is tagged
|
||||
let sessions = run_json(
|
||||
@@ -112,9 +118,7 @@ fn ignore_tags_hide_matching_contact_at_both_talker_and_sender_level() {
|
||||
"json",
|
||||
],
|
||||
);
|
||||
let items = sessions["items"]
|
||||
.as_array()
|
||||
.expect("sessions items array");
|
||||
let items = sessions["items"].as_array().expect("sessions items array");
|
||||
let group_session = items
|
||||
.iter()
|
||||
.find(|item| item["username"].as_str() == Some(TALKER_GROUP));
|
||||
@@ -331,16 +335,8 @@ fn create_encrypted_contact_db(path: &Path, raw_key: &[u8; 32]) {
|
||||
)
|
||||
.expect("insert bob");
|
||||
|
||||
let tagged_extra = encode_extra_buffer_for_test(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("1"),
|
||||
);
|
||||
let tagged_extra =
|
||||
encode_extra_buffer_for_test(None, None, None, None, None, None, None, Some("1"));
|
||||
conn.execute(
|
||||
"INSERT INTO contact (username, nick_name, extra_buffer) VALUES (?1, ?2, ?3)",
|
||||
params![TALKER_TAGGED, "Sensitive Person", tagged_extra],
|
||||
@@ -451,8 +447,11 @@ fn create_encrypted_message_db(path: &Path, raw_key: &[u8; 32]) {
|
||||
group = TABLE_GROUP,
|
||||
),
|
||||
|conn| {
|
||||
conn.execute("INSERT INTO Timestamp VALUES (?1)", params![1_700_000_000_i64])
|
||||
.expect("insert timestamp");
|
||||
conn.execute(
|
||||
"INSERT INTO Timestamp VALUES (?1)",
|
||||
params![1_700_000_000_i64],
|
||||
)
|
||||
.expect("insert timestamp");
|
||||
conn.execute(
|
||||
"INSERT INTO Name2Id VALUES (?1, ?2)",
|
||||
params![1_i64, TALKER_ALICE],
|
||||
@@ -729,8 +728,11 @@ fn create_sender_account(root: &Path) {
|
||||
group = TABLE_GROUP_SENDER,
|
||||
),
|
||||
|conn| {
|
||||
conn.execute("INSERT INTO Timestamp VALUES (?1)", params![1_700_000_000_i64])
|
||||
.expect("insert timestamp");
|
||||
conn.execute(
|
||||
"INSERT INTO Timestamp VALUES (?1)",
|
||||
params![1_700_000_000_i64],
|
||||
)
|
||||
.expect("insert timestamp");
|
||||
conn.execute(
|
||||
"INSERT INTO Name2Id VALUES (?1, ?2)",
|
||||
params![1_i64, TALKER_ALICE],
|
||||
@@ -754,8 +756,15 @@ fn create_sender_account(root: &Path) {
|
||||
table = TABLE_ALICE
|
||||
),
|
||||
params![
|
||||
100_i64, 3001_i64, 1_i64, 1_i64, 1_700_000_301_i64,
|
||||
b"private hello" as &[u8], None::<Vec<u8>>, 0_i32, None::<i32>,
|
||||
100_i64,
|
||||
3001_i64,
|
||||
1_i64,
|
||||
1_i64,
|
||||
1_700_000_301_i64,
|
||||
b"private hello" as &[u8],
|
||||
None::<Vec<u8>>,
|
||||
0_i32,
|
||||
None::<i32>,
|
||||
],
|
||||
)
|
||||
.expect("insert alice private message");
|
||||
@@ -767,8 +776,15 @@ fn create_sender_account(root: &Path) {
|
||||
table = TABLE_GROUP_SENDER
|
||||
),
|
||||
params![
|
||||
200_i64, 4001_i64, 1_i64, 1_i64, 1_700_000_101_i64,
|
||||
b"alice says hello in group" as &[u8], None::<Vec<u8>>, 0_i32, None::<i32>,
|
||||
200_i64,
|
||||
4001_i64,
|
||||
1_i64,
|
||||
1_i64,
|
||||
1_700_000_101_i64,
|
||||
b"alice says hello in group" as &[u8],
|
||||
None::<Vec<u8>>,
|
||||
0_i32,
|
||||
None::<i32>,
|
||||
],
|
||||
)
|
||||
.expect("insert alice group message");
|
||||
@@ -780,8 +796,15 @@ fn create_sender_account(root: &Path) {
|
||||
table = TABLE_GROUP_SENDER
|
||||
),
|
||||
params![
|
||||
210_i64, 4002_i64, 1_i64, 5_i64, 1_700_000_102_i64,
|
||||
b"spam content" as &[u8], None::<Vec<u8>>, 0_i32, None::<i32>,
|
||||
210_i64,
|
||||
4002_i64,
|
||||
1_i64,
|
||||
5_i64,
|
||||
1_700_000_102_i64,
|
||||
b"spam content" as &[u8],
|
||||
None::<Vec<u8>>,
|
||||
0_i32,
|
||||
None::<i32>,
|
||||
],
|
||||
)
|
||||
.expect("insert spam group message");
|
||||
@@ -795,10 +818,15 @@ fn create_sender_account(root: &Path) {
|
||||
table = TABLE_GROUP_SENDER
|
||||
),
|
||||
params![
|
||||
220_i64, 4003_i64,
|
||||
220_i64,
|
||||
4003_i64,
|
||||
quote_local_type,
|
||||
1_i64, 1_700_000_103_i64,
|
||||
quote_xml.as_bytes(), None::<Vec<u8>>, 0_i32, None::<i32>,
|
||||
1_i64,
|
||||
1_700_000_103_i64,
|
||||
quote_xml.as_bytes(),
|
||||
None::<Vec<u8>>,
|
||||
0_i32,
|
||||
None::<i32>,
|
||||
],
|
||||
)
|
||||
.expect("insert quote message");
|
||||
@@ -814,22 +842,38 @@ fn sender_hiding_filters_hidden_sender_messages_in_group() {
|
||||
let result = run_json(
|
||||
fixture.path(),
|
||||
&[
|
||||
"query", TALKER_GROUP,
|
||||
"--data-dir", senders_dir.as_str(),
|
||||
"--key", TEST_KEY_HEX,
|
||||
"--format", "json",
|
||||
"query",
|
||||
TALKER_GROUP,
|
||||
"--data-dir",
|
||||
senders_dir.as_str(),
|
||||
"--key",
|
||||
TEST_KEY_HEX,
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
);
|
||||
let items = result["items"].as_array().expect("query items array");
|
||||
|
||||
// spam message (server_id=4002) should be filtered out
|
||||
let senders: Vec<&str> = items.iter().map(|i| i["sender"].as_str().unwrap()).collect();
|
||||
assert!(!senders.contains(&TALKER_SPAM), "hidden sender message should be filtered: {result}");
|
||||
assert!(senders.contains(&TALKER_ALICE), "visible sender should remain: {result}");
|
||||
let senders: Vec<&str> = items
|
||||
.iter()
|
||||
.map(|i| i["sender"].as_str().unwrap())
|
||||
.collect();
|
||||
assert!(
|
||||
!senders.contains(&TALKER_SPAM),
|
||||
"hidden sender message should be filtered: {result}"
|
||||
);
|
||||
assert!(
|
||||
senders.contains(&TALKER_ALICE),
|
||||
"visible sender should remain: {result}"
|
||||
);
|
||||
|
||||
// paging.total should NOT change (DB-level count)
|
||||
// paging.returned should reflect filtered items
|
||||
assert_eq!(result["paging"]["returned"].as_u64().unwrap(), items.len() as u64);
|
||||
assert_eq!(
|
||||
result["paging"]["returned"].as_u64().unwrap(),
|
||||
items.len() as u64
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -841,14 +885,22 @@ fn sender_hiding_does_not_affect_private_chat() {
|
||||
let result = run_json(
|
||||
fixture.path(),
|
||||
&[
|
||||
"query", TALKER_ALICE,
|
||||
"--data-dir", senders_dir.as_str(),
|
||||
"--key", TEST_KEY_HEX,
|
||||
"--format", "json",
|
||||
"query",
|
||||
TALKER_ALICE,
|
||||
"--data-dir",
|
||||
senders_dir.as_str(),
|
||||
"--key",
|
||||
TEST_KEY_HEX,
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
);
|
||||
let items = result["items"].as_array().expect("query items array");
|
||||
assert_eq!(items.len(), 1, "private chat should not be filtered: {result}");
|
||||
assert_eq!(
|
||||
items.len(),
|
||||
1,
|
||||
"private chat should not be filtered: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -859,27 +911,51 @@ fn sender_hiding_redacts_quote_referring_hidden_sender() {
|
||||
let result = run_json(
|
||||
fixture.path(),
|
||||
&[
|
||||
"query", TALKER_GROUP,
|
||||
"--data-dir", senders_dir.as_str(),
|
||||
"--key", TEST_KEY_HEX,
|
||||
"--format", "json",
|
||||
"query",
|
||||
TALKER_GROUP,
|
||||
"--data-dir",
|
||||
senders_dir.as_str(),
|
||||
"--key",
|
||||
TEST_KEY_HEX,
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
);
|
||||
let items = result["items"].as_array().expect("query items array");
|
||||
|
||||
// Find the quote message (server_id=4003)
|
||||
let quote = items.iter().find(|i| i["server_id"].as_i64() == Some(4003));
|
||||
assert!(quote.is_some(), "quote message should be present (alice's reply): items={:?}", items.iter().map(|i| i["server_id"].as_i64()).collect::<Vec<_>>());
|
||||
assert!(
|
||||
quote.is_some(),
|
||||
"quote message should be present (alice's reply): items={:?}",
|
||||
items
|
||||
.iter()
|
||||
.map(|i| i["server_id"].as_i64())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
let quote = quote.unwrap();
|
||||
|
||||
// refer_sender and refer_content should be null (redacted)
|
||||
let q = "e["content"]["Quote"];
|
||||
assert!(q["refer_sender"].is_null(), "refer_sender should be redacted: {quote}");
|
||||
assert!(q["refer_content"].is_null(), "refer_content should be redacted: {quote}");
|
||||
assert!(
|
||||
q["refer_sender"].is_null(),
|
||||
"refer_sender should be redacted: {quote}"
|
||||
);
|
||||
assert!(
|
||||
q["refer_content"].is_null(),
|
||||
"refer_content should be redacted: {quote}"
|
||||
);
|
||||
// reply_text should be preserved
|
||||
assert!(q["reply_text"].as_str().unwrap().contains("my reply"), "reply_text should be preserved: {quote}");
|
||||
assert!(
|
||||
q["reply_text"].as_str().unwrap().contains("my reply"),
|
||||
"reply_text should be preserved: {quote}"
|
||||
);
|
||||
// raw_xml should be cleared
|
||||
assert_eq!(q["raw_xml"].as_str(), Some(""), "raw_xml should be empty: {quote}");
|
||||
assert_eq!(
|
||||
q["raw_xml"].as_str(),
|
||||
Some(""),
|
||||
"raw_xml should be empty: {quote}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -890,19 +966,33 @@ fn sender_hiding_show_hidden_restores_all_messages_and_quotes() {
|
||||
let result = run_json(
|
||||
fixture.path(),
|
||||
&[
|
||||
"query", TALKER_GROUP,
|
||||
"--data-dir", senders_dir.as_str(),
|
||||
"--key", TEST_KEY_HEX,
|
||||
"query",
|
||||
TALKER_GROUP,
|
||||
"--data-dir",
|
||||
senders_dir.as_str(),
|
||||
"--key",
|
||||
TEST_KEY_HEX,
|
||||
"--show-hidden",
|
||||
"--format", "json",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
);
|
||||
let items = result["items"].as_array().expect("query items array");
|
||||
assert_eq!(items.len(), 3, "show_hidden should restore all 3 messages: {result}");
|
||||
assert_eq!(
|
||||
items.len(),
|
||||
3,
|
||||
"show_hidden should restore all 3 messages: {result}"
|
||||
);
|
||||
|
||||
// Quote should have refer_sender intact
|
||||
let quote = items.iter().find(|i| i["server_id"].as_i64() == Some(4003)).unwrap();
|
||||
assert!(!quote["content"]["Quote"]["refer_sender"].is_null(), "refer_sender should be intact with show_hidden: {quote}");
|
||||
let quote = items
|
||||
.iter()
|
||||
.find(|i| i["server_id"].as_i64() == Some(4003))
|
||||
.unwrap();
|
||||
assert!(
|
||||
!quote["content"]["Quote"]["refer_sender"].is_null(),
|
||||
"refer_sender should be intact with show_hidden: {quote}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -914,22 +1004,37 @@ fn sender_hiding_session_placeholder_when_last_sender_hidden() {
|
||||
fixture.path(),
|
||||
&[
|
||||
"sessions",
|
||||
"--data-dir", senders_dir.as_str(),
|
||||
"--key", TEST_KEY_HEX,
|
||||
"--format", "json",
|
||||
"--data-dir",
|
||||
senders_dir.as_str(),
|
||||
"--key",
|
||||
TEST_KEY_HEX,
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
);
|
||||
let items = sessions["items"].as_array().expect("sessions items array");
|
||||
|
||||
let group_session = items.iter().find(|i| i["username"].as_str() == Some(TALKER_GROUP));
|
||||
assert!(group_session.is_some(), "group session should be visible: {sessions}");
|
||||
let group_session = items
|
||||
.iter()
|
||||
.find(|i| i["username"].as_str() == Some(TALKER_GROUP));
|
||||
assert!(
|
||||
group_session.is_some(),
|
||||
"group session should be visible: {sessions}"
|
||||
);
|
||||
let group_session = group_session.unwrap();
|
||||
|
||||
// Summary should be placeholder, sender fields should be null
|
||||
assert_eq!(group_session["summary"].as_str(), Some("[消息已隐藏]"),
|
||||
"summary should be placeholder: {group_session}");
|
||||
assert!(group_session["last_msg_sender"].is_null(),
|
||||
"last_msg_sender should be null: {group_session}");
|
||||
assert!(group_session["direction"].is_null(),
|
||||
"direction should be null: {group_session}");
|
||||
assert_eq!(
|
||||
group_session["summary"].as_str(),
|
||||
Some("[消息已隐藏]"),
|
||||
"summary should be placeholder: {group_session}"
|
||||
);
|
||||
assert!(
|
||||
group_session["last_msg_sender"].is_null(),
|
||||
"last_msg_sender should be null: {group_session}"
|
||||
);
|
||||
assert!(
|
||||
group_session["direction"].is_null(),
|
||||
"direction should be null: {group_session}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -18,6 +19,7 @@ const MSG_TABLE: &str = "Msg_29a6db07e8bbdb53f5d54cc3c309f3f1";
|
||||
const GROUP_TALKER: &str = "test@chatroom";
|
||||
const GROUP_MSG_TABLE: &str = "Msg_1d282e28b02b5c9f9522f855de32f9a8";
|
||||
const HIDDEN_SENDER: &str = "wxid_spam";
|
||||
static SERVER_START_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn bin() -> &'static str {
|
||||
env!("CARGO_BIN_EXE_wx-cli")
|
||||
@@ -98,8 +100,10 @@ fn serve_media_visible_sender_in_group_with_hidden_persons_not_visibility_blocke
|
||||
let body = String::from_utf8_lossy(&response.body);
|
||||
// The media endpoint was reached (not blocked by visibility) but the asset
|
||||
// isn't on disk. Acceptable.
|
||||
assert!(!body.contains("message not found"),
|
||||
"visible sender should NOT get visibility 404: {body}");
|
||||
assert!(
|
||||
!body.contains("message not found"),
|
||||
"visible sender should NOT get visibility 404: {body}"
|
||||
);
|
||||
}
|
||||
// If 200, even better — means asset resolution succeeded
|
||||
}
|
||||
@@ -148,9 +152,22 @@ fn serve_media_dispatch_image_returns_png_bytes() {
|
||||
);
|
||||
assert_eq!(response.status_code, 200, "{response:#?}");
|
||||
assert_eq!(response.header("content-type"), Some("image/png"));
|
||||
assert_eq!(response.header("x-wechat-media-quality"), Some("full"));
|
||||
assert_eq!(&response.body[..8], b"\x89PNG\r\n\x1a\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_media_thumbnail_only_image_reports_quality() {
|
||||
let server = spawn_test_server();
|
||||
let response = http_get(
|
||||
&server.base_url,
|
||||
"/api/v1/media?server_id=3009&talker=wxid_alice",
|
||||
);
|
||||
assert_eq!(response.status_code, 200, "{response:#?}");
|
||||
assert_eq!(response.header("content-type"), Some("image/png"));
|
||||
assert_eq!(response.header("x-wechat-media-quality"), Some("thumbnail"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serve_media_wxgf_embedded_png_returns_png_without_ffmpeg() {
|
||||
let server = spawn_test_server_with_env(&[("FFMPEG_PATH", "/definitely-missing-ffmpeg")]);
|
||||
@@ -317,16 +334,17 @@ fn spawn_test_server_with_env(envs: &[(&str, &str)]) -> TestServer {
|
||||
spawn_test_server_with_setup(envs, &[])
|
||||
}
|
||||
|
||||
fn spawn_test_server_with_setup(
|
||||
envs: &[(&str, &str)],
|
||||
hidden_contacts: &[&str],
|
||||
) -> TestServer {
|
||||
fn spawn_test_server_with_setup(envs: &[(&str, &str)], hidden_contacts: &[&str]) -> TestServer {
|
||||
let fixture = create_fixture();
|
||||
if !hidden_contacts.is_empty() {
|
||||
write_settings(fixture.path(), hidden_contacts);
|
||||
}
|
||||
let account_dir = fixture.path().join(TEST_ACCOUNT_ID);
|
||||
let runtime_root = fixture.path().join("runtime");
|
||||
// Keep ephemeral-port selection and worker binding atomic across parallel tests.
|
||||
let _start_guard = SERVER_START_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let port = find_open_port();
|
||||
let mut command = Command::new(bin());
|
||||
command
|
||||
@@ -713,6 +731,27 @@ fn create_encrypted_message_db(path: &Path, raw_key: &[u8; 32]) {
|
||||
)
|
||||
.expect("insert image ok message");
|
||||
|
||||
let image_thumb_only_info =
|
||||
encode_packed_info_for_test(Some("md5_image_thumb_only"), None);
|
||||
conn.execute(
|
||||
&format!(
|
||||
"INSERT INTO [{table}] VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
table = MSG_TABLE
|
||||
),
|
||||
params![
|
||||
301_i64,
|
||||
3009_i64,
|
||||
3_i64,
|
||||
1_i64,
|
||||
1_709_251_200_i64,
|
||||
Vec::<u8>::new(),
|
||||
image_thumb_only_info,
|
||||
0_i32,
|
||||
None::<i32>,
|
||||
],
|
||||
)
|
||||
.expect("insert thumbnail-only image message");
|
||||
|
||||
let image_wxgf_png_info = encode_packed_info_for_test(Some("md5_image_wxgf_png"), None);
|
||||
conn.execute(
|
||||
&format!(
|
||||
@@ -864,8 +903,8 @@ fn create_encrypted_message_db(path: &Path, raw_key: &[u8; 32]) {
|
||||
params![
|
||||
900_i64,
|
||||
7001_i64,
|
||||
3_i64, // msg_type=3 (image)
|
||||
3_i64, // real_sender_id=3 → HIDDEN_SENDER
|
||||
3_i64, // msg_type=3 (image)
|
||||
3_i64, // real_sender_id=3 → HIDDEN_SENDER
|
||||
1_700_000_900_i64,
|
||||
Vec::<u8>::new(),
|
||||
group_image_info,
|
||||
@@ -885,8 +924,8 @@ fn create_encrypted_message_db(path: &Path, raw_key: &[u8; 32]) {
|
||||
params![
|
||||
910_i64,
|
||||
7002_i64,
|
||||
3_i64, // msg_type=3 (image)
|
||||
1_i64, // real_sender_id=1 → TALKER (visible)
|
||||
3_i64, // msg_type=3 (image)
|
||||
1_i64, // real_sender_id=1 → TALKER (visible)
|
||||
1_700_000_910_i64,
|
||||
Vec::<u8>::new(),
|
||||
group_visible_info,
|
||||
@@ -986,6 +1025,8 @@ fn create_image_fixture(attach_dir: &Path) {
|
||||
let encrypted = xor_bytes(&png, xor_key);
|
||||
fs::write(month_dir.join("md5_image_ok_t.dat"), &encrypted).expect("write thumb dat");
|
||||
fs::write(month_dir.join("md5_image_ok.dat"), &encrypted).expect("write image dat");
|
||||
fs::write(month_dir.join("md5_image_thumb_only_t.dat"), &encrypted)
|
||||
.expect("write thumbnail-only dat");
|
||||
|
||||
let wxgf_png = sample_wxgf_with_embedded_png();
|
||||
let wxgf_png_encrypted = xor_bytes(&wxgf_png, xor_key);
|
||||
|
||||
@@ -4,6 +4,8 @@ use std::process::Command;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn bin() -> &'static str {
|
||||
env!("CARGO_BIN_EXE_wx-cli")
|
||||
}
|
||||
@@ -278,7 +280,8 @@ fn server_only_fails_when_remote_unavailable() {
|
||||
|
||||
#[test]
|
||||
fn unavailable_remote_falls_back_to_local() {
|
||||
let output = Command::new(bin())
|
||||
let (mut command, _home) = command_without_local_account();
|
||||
let output = command
|
||||
.args(["sessions", "--server-url", "http://127.0.0.1:9"])
|
||||
.output()
|
||||
.expect("run sessions fallback");
|
||||
@@ -314,7 +317,8 @@ fn no_server_bypasses_remote_probe() {
|
||||
}
|
||||
});
|
||||
|
||||
let output = Command::new(bin())
|
||||
let (mut command, _home) = command_without_local_account();
|
||||
let output = command
|
||||
.args([
|
||||
"sessions",
|
||||
"--no-server",
|
||||
@@ -333,6 +337,39 @@ fn no_server_bypasses_remote_probe() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn healthy_server_business_transport_failure_does_not_fall_back() {
|
||||
let (base_url, handle) = spawn_sequence_server(2, |_request, index| match index {
|
||||
0 => http_response("200 OK", "{\"ready\":true}"),
|
||||
// Close the second connection without a response. This is classified as an
|
||||
// unavailable transport error, but health already proved the server was selected.
|
||||
1 => String::new(),
|
||||
_ => unreachable!(),
|
||||
});
|
||||
|
||||
let output = Command::new(bin())
|
||||
.args(["sessions", "--server-url", &base_url])
|
||||
.output()
|
||||
.expect("run sessions with failed business request");
|
||||
|
||||
assert!(!output.status.success());
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(stderr.contains("error:"));
|
||||
assert!(!stderr.contains("falling back to local"));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
fn command_without_local_account() -> (Command, TempDir) {
|
||||
let home = TempDir::new().expect("create isolated home");
|
||||
let mut command = Command::new(bin());
|
||||
command
|
||||
.env("HOME", home.path())
|
||||
.env_remove("WECHAT_CLI_DATA_DIR")
|
||||
.env_remove("WECHAT_CLI_ACCOUNT")
|
||||
.env_remove("WECHAT_CLI_KEY");
|
||||
(command, home)
|
||||
}
|
||||
|
||||
fn spawn_sequence_server(
|
||||
expected_requests: usize,
|
||||
responder: impl Fn(String, usize) -> String + Send + 'static,
|
||||
|
||||
@@ -773,10 +773,7 @@ mod tests {
|
||||
&tmp.path().join("cache").join("test.db"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
wx_decrypt::DecryptError::NoMatchingEncKey
|
||||
));
|
||||
assert!(matches!(err, wx_decrypt::DecryptError::NoMatchingEncKey));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -15,6 +15,7 @@ struct ResolvedContact {
|
||||
signature: Option<String>,
|
||||
region: Option<String>,
|
||||
labels: Vec<String>,
|
||||
avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ContactResolver {
|
||||
@@ -56,6 +57,7 @@ impl ContactResolver {
|
||||
signature: c.signature,
|
||||
region: c.region,
|
||||
labels: c.labels,
|
||||
avatar_url: c.avatar_url,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -92,6 +94,13 @@ impl ContactResolver {
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
|
||||
/// Resolve a wxid to its preferred avatar URL.
|
||||
pub fn avatar_url(&self, wxid: &str) -> Option<&str> {
|
||||
self.contacts
|
||||
.get(wxid)
|
||||
.and_then(|contact| contact.avatar_url.as_deref())
|
||||
}
|
||||
|
||||
/// Iterate all contacts with their wxid and labels.
|
||||
/// Used by VisibilityIndex to expand ignore_tags.
|
||||
pub fn all_labels(&self) -> impl Iterator<Item = (&String, &[String])> {
|
||||
@@ -200,6 +209,7 @@ mod tests {
|
||||
signature: None,
|
||||
region: None,
|
||||
labels: Vec::new(),
|
||||
avatar_url: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -235,6 +245,7 @@ mod tests {
|
||||
signature: None,
|
||||
region: None,
|
||||
labels: labels.iter().map(|s| s.to_string()).collect(),
|
||||
avatar_url: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -36,6 +36,25 @@ pub use progress::{DecryptProgress, DecryptStats};
|
||||
pub use shard_routing::{route_shards_for_query, write_shard_metadata_sidecar};
|
||||
pub use visibility::VisibilityIndex;
|
||||
|
||||
/// Read persisted per-database derived keys for this account.
|
||||
/// Ephemeral `--key` contexts deliberately ignore the store because the supplied
|
||||
/// raw key may not match its cached entries.
|
||||
pub fn persisted_derived_keys(
|
||||
account: &AccountContext,
|
||||
) -> Result<Vec<wx_decrypt::EncKeyPair>, ContextError> {
|
||||
if !account.writeback_enabled {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let store = wx_keychain::KeyStore::load_default()?;
|
||||
Ok(match store.resolve_key_material(&account.account_id) {
|
||||
Some(wx_decrypt::KeyMaterial::EncKeys(pairs)) => pairs,
|
||||
Some(wx_decrypt::KeyMaterial::EncKey { key, salt }) => {
|
||||
vec![wx_decrypt::EncKeyPair { key, salt }]
|
||||
}
|
||||
_ => Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Open encrypted WeChat DB directory directly (no pool, no FTS).
|
||||
/// For one-shot commands: contacts, sessions, query, search, export.
|
||||
pub fn open_encrypted_db(account: &AccountContext) -> Result<wx_db::WechatDb, ContextError> {
|
||||
@@ -43,7 +62,25 @@ pub fn open_encrypted_db(account: &AccountContext) -> Result<wx_db::WechatDb, Co
|
||||
.raw_key
|
||||
.ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?;
|
||||
let encrypted_root = account.data_dir.join("db_storage");
|
||||
let db = wx_db::WechatDb::open_encrypted(&encrypted_root, raw_key)?;
|
||||
let derived_keys = persisted_derived_keys(account)?;
|
||||
let db =
|
||||
wx_db::WechatDb::open_encrypted_with_key_cache(&encrypted_root, raw_key, &derived_keys)?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
/// Open only encrypted contact.db and session.db directly.
|
||||
/// This avoids deriving keys for and scanning message shards for core-only commands.
|
||||
pub fn open_encrypted_db_core(account: &AccountContext) -> Result<wx_db::WechatDb, ContextError> {
|
||||
let raw_key = account
|
||||
.raw_key
|
||||
.ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?;
|
||||
let encrypted_root = account.data_dir.join("db_storage");
|
||||
let derived_keys = persisted_derived_keys(account)?;
|
||||
let db = wx_db::WechatDb::open_encrypted_core_with_key_cache(
|
||||
&encrypted_root,
|
||||
raw_key,
|
||||
&derived_keys,
|
||||
)?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
@@ -56,9 +93,11 @@ pub fn open_encrypted_db_with_pool(
|
||||
.raw_key
|
||||
.ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?;
|
||||
let encrypted_root = account.data_dir.join("db_storage");
|
||||
let db = wx_db::WechatDb::open_encrypted_with_pool(
|
||||
let derived_keys = persisted_derived_keys(account)?;
|
||||
let db = wx_db::WechatDb::open_encrypted_with_pool_and_key_cache(
|
||||
&encrypted_root,
|
||||
raw_key,
|
||||
&derived_keys,
|
||||
register_mm_fts_tokenizer,
|
||||
)?;
|
||||
Ok(db)
|
||||
|
||||
@@ -77,8 +77,7 @@ impl VisibilityIndex {
|
||||
///
|
||||
/// Hidden talkers OR hidden senders in visible groups cannot access media.
|
||||
pub fn allows_media_for_sender(&self, talker: &str, sender: &str) -> bool {
|
||||
!self.hidden_persons.contains(talker)
|
||||
&& !self.is_hidden_sender_in_group(talker, sender)
|
||||
!self.hidden_persons.contains(talker) && !self.is_hidden_sender_in_group(talker, sender)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,12 +229,9 @@ mod tests {
|
||||
#[test]
|
||||
fn allows_media_for_sender_covers_both_levels() {
|
||||
let idx = VisibilityIndex {
|
||||
hidden_persons: vec![
|
||||
"hidden_group@chatroom".to_string(),
|
||||
"wxid_spam".to_string(),
|
||||
]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
hidden_persons: vec!["hidden_group@chatroom".to_string(), "wxid_spam".to_string()]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
};
|
||||
// Hidden talker → no media
|
||||
assert!(!idx.allows_media_for_sender("hidden_group@chatroom", "wxid_anyone"));
|
||||
|
||||
@@ -12,6 +12,7 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
hex = "0.4"
|
||||
wx-decrypt = { path = "../wx-decrypt" }
|
||||
|
||||
[dev-dependencies]
|
||||
insta = { version = "1", features = ["yaml"] }
|
||||
|
||||
@@ -43,16 +43,19 @@ impl WechatDb {
|
||||
limit: usize,
|
||||
label_map: &HashMap<String, String>,
|
||||
) -> Result<QueryResult<Contact>, DbError> {
|
||||
let avatar_expr = self.contact_avatar_select_expr()?;
|
||||
let total_rows: usize =
|
||||
self.contact_conn
|
||||
.query_row("SELECT COUNT(*) FROM contact", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})? as usize;
|
||||
|
||||
let mut stmt = self.contact_conn.prepare(
|
||||
"SELECT username, alias, remark, nick_name, description, extra_buffer \
|
||||
FROM contact ORDER BY username ASC LIMIT ?1 OFFSET ?2",
|
||||
)?;
|
||||
let sql = format!(
|
||||
"SELECT username, alias, remark, nick_name, description, extra_buffer, \
|
||||
{avatar_expr} AS avatar_url \
|
||||
FROM contact ORDER BY username ASC LIMIT ?1 OFFSET ?2"
|
||||
);
|
||||
let mut stmt = self.contact_conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(
|
||||
[
|
||||
Value::Integer(limit as i64),
|
||||
@@ -81,6 +84,7 @@ impl WechatDb {
|
||||
label_map: &HashMap<String, String>,
|
||||
) -> Result<QueryResult<Contact>, DbError> {
|
||||
let kw_lower = query.keyword.as_ref().unwrap().to_lowercase();
|
||||
let avatar_expr = self.contact_avatar_select_expr()?;
|
||||
|
||||
let total_rows: usize =
|
||||
self.contact_conn
|
||||
@@ -88,10 +92,12 @@ impl WechatDb {
|
||||
row.get::<_, i64>(0)
|
||||
})? as usize;
|
||||
|
||||
let mut stmt = self.contact_conn.prepare(
|
||||
"SELECT username, alias, remark, nick_name, description, extra_buffer \
|
||||
FROM contact ORDER BY username ASC",
|
||||
)?;
|
||||
let sql = format!(
|
||||
"SELECT username, alias, remark, nick_name, description, extra_buffer, \
|
||||
{avatar_expr} AS avatar_url \
|
||||
FROM contact ORDER BY username ASC"
|
||||
);
|
||||
let mut stmt = self.contact_conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map([], |row| self.map_contact_row(row, label_map))?;
|
||||
|
||||
let all_contacts: Vec<Contact> = rows.filter_map(|r| r.ok()).collect();
|
||||
@@ -126,6 +132,10 @@ impl WechatDb {
|
||||
let nick_name: String = row.get::<_, String>(3).unwrap_or_default();
|
||||
let memo: Option<String> = row.get::<_, Option<String>>(4).unwrap_or(None);
|
||||
let extra_buffer: Vec<u8> = row.get::<_, Vec<u8>>(5).unwrap_or_default();
|
||||
let avatar_url: Option<String> = row
|
||||
.get::<_, Option<String>>(6)
|
||||
.unwrap_or(None)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
let extra = contact_proto::decode_extra_buffer(&extra_buffer);
|
||||
|
||||
@@ -147,6 +157,22 @@ impl WechatDb {
|
||||
source_scene: extra.source_scene,
|
||||
phone: extra.phone,
|
||||
labels,
|
||||
avatar_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a compatible avatar expression for WeChat database variants.
|
||||
/// Older fixtures and database versions may not contain either column.
|
||||
fn contact_avatar_select_expr(&self) -> Result<&'static str, DbError> {
|
||||
let has_small =
|
||||
decode::check_column_exists(&self.contact_conn, "contact", "small_head_url")?;
|
||||
let has_big = decode::check_column_exists(&self.contact_conn, "contact", "big_head_url")?;
|
||||
|
||||
Ok(match (has_small, has_big) {
|
||||
(true, true) => "COALESCE(NULLIF(small_head_url, ''), NULLIF(big_head_url, ''))",
|
||||
(true, false) => "NULLIF(small_head_url, '')",
|
||||
(false, true) => "NULLIF(big_head_url, '')",
|
||||
(false, false) => "NULL",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -302,11 +302,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn non_group_returns_fallback_unchanged() {
|
||||
let (sender, content) = parse_group_sender(
|
||||
false,
|
||||
"hello world".to_string(),
|
||||
"fallback".to_string(),
|
||||
);
|
||||
let (sender, content) =
|
||||
parse_group_sender(false, "hello world".to_string(), "fallback".to_string());
|
||||
assert_eq!(sender, "fallback");
|
||||
assert_eq!(content, "hello world");
|
||||
}
|
||||
@@ -335,22 +332,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn group_empty_content_after_separator() {
|
||||
let (sender, content) = parse_group_sender(
|
||||
true,
|
||||
"wxid_abc:\n".to_string(),
|
||||
"fallback".to_string(),
|
||||
);
|
||||
let (sender, content) =
|
||||
parse_group_sender(true, "wxid_abc:\n".to_string(), "fallback".to_string());
|
||||
assert_eq!(sender, "wxid_abc");
|
||||
assert_eq!(content, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_only_colon_before_newline() {
|
||||
let (sender, content) = parse_group_sender(
|
||||
true,
|
||||
":\nsome content".to_string(),
|
||||
"fallback".to_string(),
|
||||
);
|
||||
let (sender, content) =
|
||||
parse_group_sender(true, ":\nsome content".to_string(), "fallback".to_string());
|
||||
assert_eq!(sender, "");
|
||||
assert_eq!(content, "some content");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ use rusqlite::types::ValueRef;
|
||||
use rusqlite::Connection;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::decode::{check_column_exists, decode_content, msg_table_name, parse_content, parse_group_sender};
|
||||
use crate::decode::{
|
||||
check_column_exists, decode_content, msg_table_name, parse_content, parse_group_sender,
|
||||
};
|
||||
use crate::error::DbError;
|
||||
use crate::model::{split_local_type, MessageContent};
|
||||
use crate::open::WechatDb;
|
||||
@@ -414,7 +416,7 @@ impl WechatDb {
|
||||
)?;
|
||||
|
||||
for shard in &self.shards {
|
||||
let shard_conn = WechatDb::open_shard_with_key(shard, self.raw_key.as_ref())?;
|
||||
let shard_conn = WechatDb::open_shard_with_key(shard, self.sqlcipher_key.as_ref())?;
|
||||
|
||||
// List Msg_* tables in this shard
|
||||
let mut table_stmt = shard_conn.prepare(
|
||||
@@ -573,7 +575,8 @@ impl WechatDb {
|
||||
let decoded_text = decode_content(&raw_content, wcdb_ct)?;
|
||||
|
||||
// Group sender parsing
|
||||
let (sender, content_text) = parse_group_sender(is_group, decoded_text, sender_from_name2id);
|
||||
let (sender, content_text) =
|
||||
parse_group_sender(is_group, decoded_text, sender_from_name2id);
|
||||
|
||||
let (msg_type, sub_type) = split_local_type(local_type as i64);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::model::{
|
||||
effective_limit, split_local_type, AnchorMode, Message, MessageQuery, MessageQueryResult,
|
||||
QueryStats, SortOrder,
|
||||
};
|
||||
use crate::open::{MessageShard, WechatDb};
|
||||
use crate::open::{MessageShard, SqlcipherKey, WechatDb};
|
||||
|
||||
/// Dispatch mode for regular (non-anchor) queries.
|
||||
enum RegularQueryMode {
|
||||
@@ -56,13 +56,13 @@ fn prepare_shard_query<'a>(
|
||||
table_name: &str,
|
||||
warnings: &mut Vec<ShardWarning>,
|
||||
pooled_conn: Option<&'a Connection>,
|
||||
raw_key: Option<&[u8; 32]>,
|
||||
sqlcipher_key: Option<&SqlcipherKey>,
|
||||
) -> Option<PreparedShard<'a>> {
|
||||
let shard_path = shard.path.display().to_string();
|
||||
|
||||
let conn = match pooled_conn {
|
||||
Some(conn) => ShardConnection::Borrowed(conn),
|
||||
None => match WechatDb::open_shard_with_key(shard, raw_key) {
|
||||
None => match WechatDb::open_shard_with_key(shard, sqlcipher_key) {
|
||||
Ok(c) => ShardConnection::Owned(c),
|
||||
Err(e) => {
|
||||
warnings.push(ShardWarning {
|
||||
@@ -176,7 +176,7 @@ impl WechatDb {
|
||||
&table_name,
|
||||
&mut shard_warnings,
|
||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||
self.raw_key.as_ref(),
|
||||
self.sqlcipher_key.as_ref(),
|
||||
) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
@@ -296,12 +296,12 @@ impl WechatDb {
|
||||
for shard in &shards {
|
||||
let count = if let Some(pool) = self.pool() {
|
||||
if let Some(conn) = pool.get(&shard.path) {
|
||||
Self::count_shard(&conn, &sql, start_time, end_time, msg_type_filter)
|
||||
Self::count_shard(conn, &sql, start_time, end_time, msg_type_filter)
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
match crate::open::open_connection(&shard.path, self.raw_key.as_ref()) {
|
||||
match crate::open::open_connection(&shard.path, self.sqlcipher_key.as_ref()) {
|
||||
Ok(conn) => {
|
||||
Self::count_shard(&conn, &sql, start_time, end_time, msg_type_filter)
|
||||
}
|
||||
@@ -323,9 +323,11 @@ impl WechatDb {
|
||||
msg_type_filter: Option<u32>,
|
||||
) -> usize {
|
||||
let result = if let Some(mt) = msg_type_filter {
|
||||
conn.query_row(sql, [start_time, end_time, mt as i64], |row: &rusqlite::Row<'_>| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
conn.query_row(
|
||||
sql,
|
||||
[start_time, end_time, mt as i64],
|
||||
|row: &rusqlite::Row<'_>| row.get::<_, i64>(0),
|
||||
)
|
||||
} else {
|
||||
conn.query_row(sql, [start_time, end_time], |row: &rusqlite::Row<'_>| {
|
||||
row.get::<_, i64>(0)
|
||||
@@ -385,7 +387,7 @@ impl WechatDb {
|
||||
table_name,
|
||||
&mut shard_warnings,
|
||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||
self.raw_key.as_ref(),
|
||||
self.sqlcipher_key.as_ref(),
|
||||
) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
@@ -476,7 +478,7 @@ impl WechatDb {
|
||||
table_name,
|
||||
&mut shard_warnings,
|
||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||
self.raw_key.as_ref(),
|
||||
self.sqlcipher_key.as_ref(),
|
||||
) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
@@ -607,7 +609,7 @@ impl WechatDb {
|
||||
table_name,
|
||||
&mut shard_warnings,
|
||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||
self.raw_key.as_ref(),
|
||||
self.sqlcipher_key.as_ref(),
|
||||
) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
@@ -683,7 +685,7 @@ impl WechatDb {
|
||||
table_name,
|
||||
&mut shard_warnings,
|
||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||
self.raw_key.as_ref(),
|
||||
self.sqlcipher_key.as_ref(),
|
||||
) {
|
||||
Some(p) => p,
|
||||
None => continue,
|
||||
@@ -812,16 +814,21 @@ impl WechatDb {
|
||||
known_usernames.iter().map(|u| (u.clone(), 0)).collect();
|
||||
|
||||
for shard in self.all_shards() {
|
||||
let conn = match WechatDb::open_shard_with_key(shard, self.raw_key.as_ref()) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"warn: bulk_max_sort_seq: open shard {} failed: {e}",
|
||||
shard.path.display()
|
||||
);
|
||||
continue;
|
||||
let conn = if let Some(conn) = self.pool().and_then(|pool| pool.get(&shard.path)) {
|
||||
ShardConnection::Borrowed(conn)
|
||||
} else {
|
||||
match WechatDb::open_shard_with_key(shard, self.sqlcipher_key.as_ref()) {
|
||||
Ok(conn) => ShardConnection::Owned(conn),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"warn: bulk_max_sort_seq: open shard {} failed: {e}",
|
||||
shard.path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let conn = conn.as_conn();
|
||||
|
||||
// Discover Msg_* tables in this shard
|
||||
let mut stmt = match conn
|
||||
|
||||
@@ -380,6 +380,9 @@ pub struct Contact {
|
||||
pub phone: Option<String>,
|
||||
/// Resolved label names from extra_buffer + contact_label table.
|
||||
pub labels: Vec<String>,
|
||||
/// Preferred avatar URL from `small_head_url`, falling back to `big_head_url`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar_url: Option<String>,
|
||||
}
|
||||
|
||||
/// A WeChat chatroom (group chat) entry with its member list.
|
||||
|
||||
+314
-41
@@ -2,7 +2,7 @@ use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::os::raw::c_void;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
@@ -18,6 +18,111 @@ pub(crate) struct MessageShard {
|
||||
pub end_unix: i64,
|
||||
}
|
||||
|
||||
/// A raw WeChat key plus an in-process cache of SQLCipher's derived keys.
|
||||
///
|
||||
/// SQLCipher normally runs its 256k-round PBKDF2 every time a connection is
|
||||
/// opened. WeChat uses a different salt per database, but the same database is
|
||||
/// often opened several times during one command (metadata scan, query, count,
|
||||
/// refresh). Passing SQLCipher's raw keyspec lets us derive once per salt and
|
||||
/// reuse the result for every subsequent connection.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SqlcipherKey {
|
||||
raw_key: [u8; 32],
|
||||
derived_keys: Arc<Mutex<HashMap<[u8; 16], CachedKey>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CachedKey {
|
||||
key: [u8; 32],
|
||||
/// Preloaded keys come from the persisted key store and get one raw-key
|
||||
/// fallback if validation fails. Keys derived in this process are trusted.
|
||||
preloaded: bool,
|
||||
}
|
||||
|
||||
impl SqlcipherKey {
|
||||
fn new(raw_key: [u8; 32]) -> Self {
|
||||
Self::with_preloaded(raw_key, &[])
|
||||
}
|
||||
|
||||
fn with_preloaded(raw_key: [u8; 32], pairs: &[wx_decrypt::EncKeyPair]) -> Self {
|
||||
let derived_keys = pairs
|
||||
.iter()
|
||||
.map(|pair| {
|
||||
(
|
||||
pair.salt,
|
||||
CachedKey {
|
||||
key: pair.key,
|
||||
preloaded: true,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
raw_key,
|
||||
derived_keys: Arc::new(Mutex::new(derived_keys)),
|
||||
}
|
||||
}
|
||||
|
||||
fn keyspec_for_path(&self, path: &Path) -> Result<(Vec<u8>, [u8; 16], bool), DbError> {
|
||||
let salt = wx_decrypt::read_db_salt(path)
|
||||
.map_err(|e| DbError::EncryptionKey(format!("failed to read database salt: {e}")))?;
|
||||
|
||||
let cached = {
|
||||
let mut cache = self.derived_keys.lock().map_err(|_| {
|
||||
DbError::EncryptionKey("derived-key cache lock was poisoned".into())
|
||||
})?;
|
||||
*cache.entry(salt).or_insert_with(|| {
|
||||
let key = wx_decrypt::kdf::derive_enc_key(
|
||||
&self.raw_key,
|
||||
&salt,
|
||||
&wx_decrypt::MACOS_4_1_7_31,
|
||||
);
|
||||
CachedKey {
|
||||
key,
|
||||
preloaded: false,
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
// SQLCipher raw-key syntax includes the original 16-byte database salt.
|
||||
// Supplying this ASCII keyspec to sqlite3_key() skips SQLCipher's PBKDF2.
|
||||
let keyspec = format!("x'{}{}'", hex::encode(cached.key), hex::encode(salt)).into_bytes();
|
||||
Ok((keyspec, salt, cached.preloaded))
|
||||
}
|
||||
|
||||
fn mark_verified(&self, salt: [u8; 16]) -> Result<(), DbError> {
|
||||
let mut cache = self
|
||||
.derived_keys
|
||||
.lock()
|
||||
.map_err(|_| DbError::EncryptionKey("derived-key cache lock was poisoned".into()))?;
|
||||
if let Some(entry) = cache.get_mut(&salt) {
|
||||
entry.preloaded = false;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rederive_keyspec(&self, salt: [u8; 16]) -> Result<Vec<u8>, DbError> {
|
||||
let key =
|
||||
wx_decrypt::kdf::derive_enc_key(&self.raw_key, &salt, &wx_decrypt::MACOS_4_1_7_31);
|
||||
self.derived_keys
|
||||
.lock()
|
||||
.map_err(|_| DbError::EncryptionKey("derived-key cache lock was poisoned".into()))?
|
||||
.insert(
|
||||
salt,
|
||||
CachedKey {
|
||||
key,
|
||||
preloaded: false,
|
||||
},
|
||||
);
|
||||
Ok(format!("x'{}{}'", hex::encode(key), hex::encode(salt)).into_bytes())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn cached_salt_count(&self) -> usize {
|
||||
self.derived_keys.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle to an opened (decrypted) WeChat database directory.
|
||||
///
|
||||
/// Holds connections to contact/session databases and metadata about
|
||||
@@ -34,8 +139,8 @@ pub struct WechatDb {
|
||||
pub contact_fts_path: Option<PathBuf>,
|
||||
/// Optional pre-opened connection pool for serve mode.
|
||||
pub(crate) pool: Option<ShardPool>,
|
||||
/// Raw key for encrypted direct open. Stored for reopen operations.
|
||||
pub(crate) raw_key: Option<[u8; 32]>,
|
||||
/// Shared raw/derived key state for encrypted direct open and reopen operations.
|
||||
pub(crate) sqlcipher_key: Option<SqlcipherKey>,
|
||||
/// Lazily initialized cache of label_id -> label_name from contact_label table.
|
||||
/// Cleared on `reopen_contacts()` so label changes are visible.
|
||||
pub(crate) label_cache: RwLock<Option<HashMap<String, String>>>,
|
||||
@@ -54,30 +159,59 @@ pub fn open_readonly_connection(
|
||||
path: &Path,
|
||||
raw_key: Option<&[u8; 32]>,
|
||||
) -> Result<Connection, DbError> {
|
||||
let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
||||
if let Some(key) = raw_key {
|
||||
unsafe {
|
||||
let rc = rusqlite::ffi::sqlite3_key(conn.handle(), key.as_ptr() as *const c_void, 32);
|
||||
if rc != 0 {
|
||||
return Err(DbError::EncryptionKey(format!(
|
||||
"sqlite3_key failed: rc={rc}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
|
||||
r.get::<_, i64>(0)
|
||||
})
|
||||
.map_err(|_| DbError::EncryptionKey("incorrect key or not an encrypted database".into()))?;
|
||||
conn.execute_batch("PRAGMA query_only = ON")?;
|
||||
}
|
||||
Ok(conn)
|
||||
let key = raw_key.copied().map(SqlcipherKey::new);
|
||||
open_connection(path, key.as_ref())
|
||||
}
|
||||
|
||||
pub(crate) fn open_connection(
|
||||
path: &Path,
|
||||
raw_key: Option<&[u8; 32]>,
|
||||
sqlcipher_key: Option<&SqlcipherKey>,
|
||||
) -> Result<Connection, DbError> {
|
||||
open_readonly_connection(path, raw_key)
|
||||
if let Some(key) = sqlcipher_key {
|
||||
let (keyspec, salt, preloaded) = key.keyspec_for_path(path)?;
|
||||
match open_connection_with_keyspec(path, &keyspec) {
|
||||
Ok(conn) => {
|
||||
if preloaded {
|
||||
key.mark_verified(salt)?;
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
Err(DbError::EncryptionKey(_)) if preloaded => {
|
||||
// Persisted entries are an optimization, never a single point of
|
||||
// failure. Re-derive once from the raw key if an entry is stale.
|
||||
let keyspec = key.rederive_keyspec(salt)?;
|
||||
open_connection_with_keyspec(path, &keyspec)
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
} else {
|
||||
Ok(Connection::open_with_flags(
|
||||
path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
||||
)?)
|
||||
}
|
||||
}
|
||||
|
||||
fn open_connection_with_keyspec(path: &Path, keyspec: &[u8]) -> Result<Connection, DbError> {
|
||||
let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
||||
unsafe {
|
||||
let rc = rusqlite::ffi::sqlite3_key(
|
||||
conn.handle(),
|
||||
keyspec.as_ptr() as *const c_void,
|
||||
keyspec.len() as i32,
|
||||
);
|
||||
if rc != 0 {
|
||||
return Err(DbError::EncryptionKey(format!(
|
||||
"sqlite3_key failed: rc={rc}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
|
||||
r.get::<_, i64>(0)
|
||||
})
|
||||
.map_err(|_| DbError::EncryptionKey("incorrect key or not an encrypted database".into()))?;
|
||||
conn.execute_batch("PRAGMA query_only = ON")?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
impl WechatDb {
|
||||
@@ -87,7 +221,13 @@ impl WechatDb {
|
||||
/// does not exist. Message shards are optional here; message queries will
|
||||
/// return `DbError::NoShards` if no numbered shard is available.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, DbError> {
|
||||
Self::open_internal(path.as_ref(), None)
|
||||
Self::open_internal(path.as_ref(), None, true)
|
||||
}
|
||||
|
||||
/// Open only contact.db and session.db, without scanning message shards.
|
||||
/// Useful for contacts, sessions, and monitoring commands that never read messages.
|
||||
pub fn open_core(path: impl AsRef<Path>) -> Result<Self, DbError> {
|
||||
Self::open_internal(path.as_ref(), None, false)
|
||||
}
|
||||
|
||||
/// Open a decrypted WeChat database directory with a pre-opened
|
||||
@@ -104,7 +244,39 @@ impl WechatDb {
|
||||
|
||||
/// Open an encrypted WeChat database directory directly using `sqlite3_key()`.
|
||||
pub fn open_encrypted(path: impl AsRef<Path>, raw_key: [u8; 32]) -> Result<Self, DbError> {
|
||||
Self::open_internal(path.as_ref(), Some(raw_key))
|
||||
Self::open_internal(path.as_ref(), Some(SqlcipherKey::new(raw_key)), true)
|
||||
}
|
||||
|
||||
/// Open an encrypted directory and seed the per-salt cache with persisted
|
||||
/// derived keys, falling back to the raw key for missing or stale entries.
|
||||
pub fn open_encrypted_with_key_cache(
|
||||
path: impl AsRef<Path>,
|
||||
raw_key: [u8; 32],
|
||||
pairs: &[wx_decrypt::EncKeyPair],
|
||||
) -> Result<Self, DbError> {
|
||||
Self::open_internal(
|
||||
path.as_ref(),
|
||||
Some(SqlcipherKey::with_preloaded(raw_key, pairs)),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// Open only encrypted contact.db and session.db, without scanning message shards.
|
||||
pub fn open_encrypted_core(path: impl AsRef<Path>, raw_key: [u8; 32]) -> Result<Self, DbError> {
|
||||
Self::open_internal(path.as_ref(), Some(SqlcipherKey::new(raw_key)), false)
|
||||
}
|
||||
|
||||
/// Core-only variant of [`WechatDb::open_encrypted_with_key_cache`].
|
||||
pub fn open_encrypted_core_with_key_cache(
|
||||
path: impl AsRef<Path>,
|
||||
raw_key: [u8; 32],
|
||||
pairs: &[wx_decrypt::EncKeyPair],
|
||||
) -> Result<Self, DbError> {
|
||||
Self::open_internal(
|
||||
path.as_ref(),
|
||||
Some(SqlcipherKey::with_preloaded(raw_key, pairs)),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
/// Open an encrypted WeChat database directory with a pre-opened
|
||||
@@ -114,35 +286,53 @@ impl WechatDb {
|
||||
raw_key: [u8; 32],
|
||||
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
|
||||
) -> Result<Self, DbError> {
|
||||
Self::open_with_pool_internal(path, Some(raw_key), fts_init)
|
||||
Self::open_with_pool_internal(path, Some(SqlcipherKey::new(raw_key)), fts_init)
|
||||
}
|
||||
|
||||
fn open_internal(path: &Path, raw_key: Option<[u8; 32]>) -> Result<Self, DbError> {
|
||||
/// Pool variant seeded with persisted per-salt derived keys.
|
||||
pub fn open_encrypted_with_pool_and_key_cache(
|
||||
path: impl AsRef<Path>,
|
||||
raw_key: [u8; 32],
|
||||
pairs: &[wx_decrypt::EncKeyPair],
|
||||
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
|
||||
) -> Result<Self, DbError> {
|
||||
Self::open_with_pool_internal(
|
||||
path,
|
||||
Some(SqlcipherKey::with_preloaded(raw_key, pairs)),
|
||||
fts_init,
|
||||
)
|
||||
}
|
||||
|
||||
fn open_internal(
|
||||
path: &Path,
|
||||
sqlcipher_key: Option<SqlcipherKey>,
|
||||
scan_message_shards: bool,
|
||||
) -> Result<Self, DbError> {
|
||||
if !path.exists() {
|
||||
return Err(DbError::NotFound(path.display().to_string()));
|
||||
}
|
||||
|
||||
let key_ref = raw_key.as_ref();
|
||||
let key_ref = sqlcipher_key.as_ref();
|
||||
|
||||
// Open contact.db
|
||||
let contact_path = path.join("contact").join("contact.db");
|
||||
if !contact_path.exists() {
|
||||
return Err(DbError::NotFound(contact_path.display().to_string()));
|
||||
}
|
||||
let contact_conn = open_readonly_connection(&contact_path, key_ref)?;
|
||||
let contact_conn = open_connection(&contact_path, key_ref)?;
|
||||
|
||||
// Open session.db
|
||||
let session_path = path.join("session").join("session.db");
|
||||
if !session_path.exists() {
|
||||
return Err(DbError::NotFound(session_path.display().to_string()));
|
||||
}
|
||||
let session_conn = open_readonly_connection(&session_path, key_ref)?;
|
||||
let session_conn = open_connection(&session_path, key_ref)?;
|
||||
|
||||
// Scan message shards
|
||||
let msg_dir = path.join("message");
|
||||
let mut shards = Vec::new();
|
||||
|
||||
if msg_dir.is_dir() {
|
||||
if scan_message_shards && msg_dir.is_dir() {
|
||||
let mut entries: Vec<PathBuf> = std::fs::read_dir(&msg_dir)?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
@@ -196,23 +386,23 @@ impl WechatDb {
|
||||
}
|
||||
},
|
||||
pool: None,
|
||||
raw_key,
|
||||
sqlcipher_key,
|
||||
label_cache: RwLock::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
fn open_with_pool_internal(
|
||||
path: impl AsRef<Path>,
|
||||
raw_key: Option<[u8; 32]>,
|
||||
sqlcipher_key: Option<SqlcipherKey>,
|
||||
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
|
||||
) -> Result<Self, DbError> {
|
||||
let mut db = Self::open_internal(path.as_ref(), raw_key)?;
|
||||
let mut db = Self::open_internal(path.as_ref(), sqlcipher_key.clone(), true)?;
|
||||
let fts_init_arc: Arc<crate::pool::FtsInitFn> = Arc::new(fts_init);
|
||||
let pool = ShardPool::open(
|
||||
&db.shards,
|
||||
db.message_fts_path.as_deref(),
|
||||
Some(fts_init_arc),
|
||||
raw_key,
|
||||
sqlcipher_key,
|
||||
)?;
|
||||
db.pool = Some(pool);
|
||||
Ok(db)
|
||||
@@ -220,14 +410,14 @@ impl WechatDb {
|
||||
|
||||
/// Re-open the session.db connection to pick up external changes.
|
||||
pub fn reopen_sessions(&mut self) -> Result<(), DbError> {
|
||||
self.session_conn = open_connection(&self.session_path, self.raw_key.as_ref())?;
|
||||
self.session_conn = open_connection(&self.session_path, self.sqlcipher_key.as_ref())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-open the contact.db connection to pick up external changes.
|
||||
/// Also invalidates the label cache so it is reloaded on next query.
|
||||
pub fn reopen_contacts(&mut self) -> Result<(), DbError> {
|
||||
self.contact_conn = open_connection(&self.contact_path, self.raw_key.as_ref())?;
|
||||
self.contact_conn = open_connection(&self.contact_path, self.sqlcipher_key.as_ref())?;
|
||||
*self.label_cache.write().unwrap() = None;
|
||||
Ok(())
|
||||
}
|
||||
@@ -270,6 +460,13 @@ impl WechatDb {
|
||||
self.pool.as_ref()
|
||||
}
|
||||
|
||||
/// Open another database from the same encrypted account while reusing this
|
||||
/// handle's derived-key cache. This is used by serve-mode auxiliary FTS and
|
||||
/// media connections so refreshes do not re-run PBKDF2.
|
||||
pub fn open_related_readonly(&self, path: &Path) -> Result<Connection, DbError> {
|
||||
open_connection(path, self.sqlcipher_key.as_ref())
|
||||
}
|
||||
|
||||
/// Return shards whose time range overlaps `[start, end]`.
|
||||
pub(crate) fn shards_for_range(&self, start: i64, end: i64) -> Vec<&MessageShard> {
|
||||
self.shards
|
||||
@@ -307,9 +504,9 @@ impl WechatDb {
|
||||
/// Open a SQLite connection to a specific shard, optionally encrypted.
|
||||
pub(crate) fn open_shard_with_key(
|
||||
shard: &MessageShard,
|
||||
raw_key: Option<&[u8; 32]>,
|
||||
sqlcipher_key: Option<&SqlcipherKey>,
|
||||
) -> Result<Connection, DbError> {
|
||||
open_readonly_connection(&shard.path, raw_key)
|
||||
open_connection(&shard.path, sqlcipher_key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,8 +538,8 @@ fn is_numbered_message_shard(path: &Path) -> bool {
|
||||
|
||||
/// Try to read the timestamp from a message shard's Timestamp table.
|
||||
/// Returns 0 if the table does not exist or is empty.
|
||||
fn read_shard_timestamp(path: &Path, raw_key: Option<&[u8; 32]>) -> i64 {
|
||||
let conn = match open_connection(path, raw_key) {
|
||||
fn read_shard_timestamp(path: &Path, sqlcipher_key: Option<&SqlcipherKey>) -> i64 {
|
||||
let conn = match open_connection(path, sqlcipher_key) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return 0,
|
||||
};
|
||||
@@ -425,9 +622,35 @@ mod tests {
|
||||
build_encrypted_db_storage(&root, &raw_key);
|
||||
|
||||
let mut db = WechatDb::open_encrypted(&root, raw_key).unwrap();
|
||||
let key = db.sqlcipher_key.clone().unwrap();
|
||||
let cached_before = key.cached_salt_count();
|
||||
assert_eq!(
|
||||
cached_before, 3,
|
||||
"contact, session, and message salts cached"
|
||||
);
|
||||
// Reopen should succeed (re-applies sqlite3_key)
|
||||
db.reopen_sessions().unwrap();
|
||||
db.reopen_contacts().unwrap();
|
||||
assert_eq!(
|
||||
key.cached_salt_count(),
|
||||
cached_before,
|
||||
"reopen must reuse derived keys instead of deriving again"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_core_does_not_scan_message_shards() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let root = tmp.path().join("db_storage");
|
||||
std::fs::create_dir_all(root.join("contact")).unwrap();
|
||||
std::fs::create_dir_all(root.join("session")).unwrap();
|
||||
std::fs::create_dir_all(root.join("message")).unwrap();
|
||||
Connection::open(root.join("contact/contact.db")).unwrap();
|
||||
Connection::open(root.join("session/session.db")).unwrap();
|
||||
std::fs::write(root.join("message/message_0.db"), b"not a sqlite database").unwrap();
|
||||
|
||||
let db = WechatDb::open_core(&root).unwrap();
|
||||
assert!(db.shards.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -457,7 +680,57 @@ mod tests {
|
||||
"CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (42);",
|
||||
);
|
||||
|
||||
let conn = open_connection(&path, Some(&raw_key)).unwrap();
|
||||
let key = SqlcipherKey::new(raw_key);
|
||||
let conn = open_connection(&path, Some(&key)).unwrap();
|
||||
let val: i64 = conn
|
||||
.query_row("SELECT id FROM t", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(val, 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preloaded_derived_key_opens_encrypted_database() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("enc.db");
|
||||
let raw_key = [0xAB_u8; 32];
|
||||
create_encrypted_db(
|
||||
&path,
|
||||
&raw_key,
|
||||
"CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (42);",
|
||||
);
|
||||
let salt = wx_decrypt::read_db_salt(&path).unwrap();
|
||||
let enc_key = wx_decrypt::kdf::derive_enc_key(&raw_key, &salt, &wx_decrypt::MACOS_4_1_7_31);
|
||||
let key =
|
||||
SqlcipherKey::with_preloaded(raw_key, &[wx_decrypt::EncKeyPair { key: enc_key, salt }]);
|
||||
|
||||
let conn = open_connection(&path, Some(&key)).unwrap();
|
||||
let val: i64 = conn
|
||||
.query_row("SELECT id FROM t", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(val, 42);
|
||||
assert_eq!(key.cached_salt_count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_preloaded_key_falls_back_to_raw_key() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("enc.db");
|
||||
let raw_key = [0xAB_u8; 32];
|
||||
create_encrypted_db(
|
||||
&path,
|
||||
&raw_key,
|
||||
"CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (42);",
|
||||
);
|
||||
let salt = wx_decrypt::read_db_salt(&path).unwrap();
|
||||
let key = SqlcipherKey::with_preloaded(
|
||||
raw_key,
|
||||
&[wx_decrypt::EncKeyPair {
|
||||
key: [0xCD; 32],
|
||||
salt,
|
||||
}],
|
||||
);
|
||||
|
||||
let conn = open_connection(&path, Some(&key)).unwrap();
|
||||
let val: i64 = conn
|
||||
.query_row("SELECT id FROM t", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
|
||||
+10
-10
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::DbError;
|
||||
use crate::open::MessageShard;
|
||||
use crate::open::{MessageShard, SqlcipherKey};
|
||||
|
||||
pub(crate) type FtsInitFn = dyn Fn(&Connection) -> Result<(), String> + Send + Sync;
|
||||
|
||||
@@ -19,7 +19,7 @@ pub struct ShardPool {
|
||||
fts_conn: Option<Connection>,
|
||||
fts_path: Option<PathBuf>,
|
||||
fts_init: Option<Arc<FtsInitFn>>,
|
||||
raw_key: Option<[u8; 32]>,
|
||||
sqlcipher_key: Option<SqlcipherKey>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ShardPool {
|
||||
@@ -41,22 +41,22 @@ impl ShardPool {
|
||||
shards: &[MessageShard],
|
||||
fts_path: Option<&Path>,
|
||||
fts_init: Option<Arc<FtsInitFn>>,
|
||||
raw_key: Option<[u8; 32]>,
|
||||
sqlcipher_key: Option<SqlcipherKey>,
|
||||
) -> Result<Self, DbError> {
|
||||
let mut conns = HashMap::with_capacity(shards.len());
|
||||
for shard in shards {
|
||||
let conn = crate::open::open_connection(&shard.path, raw_key.as_ref())?;
|
||||
let conn = crate::open::open_connection(&shard.path, sqlcipher_key.as_ref())?;
|
||||
conns.insert(shard.path.clone(), conn);
|
||||
}
|
||||
|
||||
let fts_conn = match (fts_path, &fts_init) {
|
||||
(Some(path), Some(init)) => {
|
||||
let conn = crate::open::open_connection(path, raw_key.as_ref())?;
|
||||
let conn = crate::open::open_connection(path, sqlcipher_key.as_ref())?;
|
||||
init(&conn).map_err(DbError::FtsInit)?;
|
||||
Some(conn)
|
||||
}
|
||||
(Some(path), None) => {
|
||||
let conn = crate::open::open_connection(path, raw_key.as_ref())?;
|
||||
let conn = crate::open::open_connection(path, sqlcipher_key.as_ref())?;
|
||||
Some(conn)
|
||||
}
|
||||
_ => None,
|
||||
@@ -67,7 +67,7 @@ impl ShardPool {
|
||||
fts_conn,
|
||||
fts_path: fts_path.map(|p| p.to_path_buf()),
|
||||
fts_init,
|
||||
raw_key,
|
||||
sqlcipher_key,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ impl ShardPool {
|
||||
/// Close and reopen one shard connection.
|
||||
pub fn reopen_shard(&mut self, path: &Path) -> Result<(), DbError> {
|
||||
if self.conns.contains_key(path) {
|
||||
let conn = crate::open::open_connection(path, self.raw_key.as_ref())?;
|
||||
let conn = crate::open::open_connection(path, self.sqlcipher_key.as_ref())?;
|
||||
self.conns.insert(path.to_path_buf(), conn);
|
||||
}
|
||||
Ok(())
|
||||
@@ -93,7 +93,7 @@ impl ShardPool {
|
||||
/// Close and reopen the FTS connection, re-registering the tokenizer.
|
||||
pub fn reopen_fts(&mut self) -> Result<(), DbError> {
|
||||
if let Some(path) = &self.fts_path {
|
||||
let conn = crate::open::open_connection(path, self.raw_key.as_ref())?;
|
||||
let conn = crate::open::open_connection(path, self.sqlcipher_key.as_ref())?;
|
||||
if let Some(init) = &self.fts_init {
|
||||
init(&conn).map_err(DbError::FtsInit)?;
|
||||
}
|
||||
@@ -106,7 +106,7 @@ impl ShardPool {
|
||||
pub fn reopen_all(&mut self) -> Result<(), DbError> {
|
||||
let paths: Vec<PathBuf> = self.conns.keys().cloned().collect();
|
||||
for path in paths {
|
||||
let conn = crate::open::open_connection(&path, self.raw_key.as_ref())?;
|
||||
let conn = crate::open::open_connection(&path, self.sqlcipher_key.as_ref())?;
|
||||
self.conns.insert(path, conn);
|
||||
}
|
||||
self.reopen_fts()?;
|
||||
|
||||
@@ -530,10 +530,7 @@ mod tests {
|
||||
#[test]
|
||||
fn extract_quote_fromusr_normal() {
|
||||
let xml = r#"<msg><appmsg><title>reply</title><refermsg><fromusr>wxid_alice</fromusr><content>hi</content></refermsg></appmsg></msg>"#;
|
||||
assert_eq!(
|
||||
extract_quote_fromusr(xml),
|
||||
Some("wxid_alice".to_string())
|
||||
);
|
||||
assert_eq!(extract_quote_fromusr(xml), Some("wxid_alice".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -552,19 +549,13 @@ mod tests {
|
||||
fn extract_quote_fromusr_prefers_chatusr_in_group() {
|
||||
// In group chats, <fromusr> is the chatroom ID, <chatusr> is the actual sender
|
||||
let xml = r#"<msg><appmsg><title>reply</title><refermsg><fromusr>group@chatroom</fromusr><chatusr>wxid_sender</chatusr><displayname>Sender</displayname><content>hi</content></refermsg></appmsg></msg>"#;
|
||||
assert_eq!(
|
||||
extract_quote_fromusr(xml),
|
||||
Some("wxid_sender".to_string())
|
||||
);
|
||||
assert_eq!(extract_quote_fromusr(xml), Some("wxid_sender".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_quote_fromusr_falls_back_to_fromusr_without_chatusr() {
|
||||
// In private chats, only <fromusr> exists (no <chatusr>)
|
||||
let xml = r#"<msg><appmsg><title>reply</title><refermsg><fromusr>wxid_bob</fromusr><displayname>Bob</displayname><content>hi</content></refermsg></appmsg></msg>"#;
|
||||
assert_eq!(
|
||||
extract_quote_fromusr(xml),
|
||||
Some("wxid_bob".to_string())
|
||||
);
|
||||
assert_eq!(extract_quote_fromusr(xml), Some("wxid_bob".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,6 +282,55 @@ fn contacts_sessions_query_contacts_limit() {
|
||||
assert_eq!(result.items.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contacts_sessions_query_contacts_avatar_url() {
|
||||
let dir = create_fixture();
|
||||
let contact_path = dir.path().join("contact").join("contact.db");
|
||||
let conn = Connection::open(contact_path).unwrap();
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE contact ADD COLUMN small_head_url TEXT;
|
||||
ALTER TABLE contact ADD COLUMN big_head_url TEXT;",
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE contact SET small_head_url = ?1, big_head_url = ?2 WHERE username = ?3",
|
||||
params![
|
||||
"https://avatar.example/alice-small.jpg",
|
||||
"https://avatar.example/alice-big.jpg",
|
||||
"wxid_alice"
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"UPDATE contact SET big_head_url = ?1 WHERE username = ?2",
|
||||
params!["https://avatar.example/bob-big.jpg", "wxid_bob"],
|
||||
)
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
let db = WechatDb::open(dir.path()).unwrap();
|
||||
let result = db.query_contacts(&ContactQuery::new()).unwrap();
|
||||
let alice = result
|
||||
.items
|
||||
.iter()
|
||||
.find(|contact| contact.user_name == "wxid_alice")
|
||||
.unwrap();
|
||||
let bob = result
|
||||
.items
|
||||
.iter()
|
||||
.find(|contact| contact.user_name == "wxid_bob")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
alice.avatar_url.as_deref(),
|
||||
Some("https://avatar.example/alice-small.jpg")
|
||||
);
|
||||
assert_eq!(
|
||||
bob.avatar_url.as_deref(),
|
||||
Some("https://avatar.example/bob-big.jpg")
|
||||
);
|
||||
}
|
||||
|
||||
// ---- sessions tests ----
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -17,11 +17,11 @@ pub use process::config_dir;
|
||||
pub use process::detect_active_account;
|
||||
pub use process::{
|
||||
ensure_supported_wechat_version, extract_base_wxid, find_account_dirs, find_account_dirs_under,
|
||||
find_wechat_pid, is_xwechat_files_root, AccountDirInfo, ActiveAccount,
|
||||
DetectionSource, SUPPORTED_VERSION,
|
||||
find_wechat_pid, is_xwechat_files_root, AccountDirInfo, ActiveAccount, DetectionSource,
|
||||
SUPPORTED_VERSION,
|
||||
};
|
||||
pub use wx_decrypt::read_db_salt;
|
||||
pub use store::{AccountKey, EncKeyEntry, KeyStore};
|
||||
pub use wx_decrypt::read_db_salt;
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
|
||||
@@ -46,7 +46,11 @@ pub async fn capture_key(
|
||||
// Pre-read salts from all accounts. Skip unreadable DBs.
|
||||
let account_salts: Vec<([u8; 16], &AccountDirInfo)> = accounts
|
||||
.iter()
|
||||
.filter_map(|a| wx_decrypt::read_db_salt(&a.message_db_path).ok().map(|salt| (salt, a)))
|
||||
.filter_map(|a| {
|
||||
wx_decrypt::read_db_salt(&a.message_db_path)
|
||||
.ok()
|
||||
.map(|salt| (salt, a))
|
||||
})
|
||||
.collect();
|
||||
|
||||
if account_salts.is_empty() {
|
||||
|
||||
@@ -138,16 +138,16 @@ fn get_wechat_version() -> Result<String, KeychainError> {
|
||||
|
||||
/// Shared config directory resolved by `AppPaths`.
|
||||
pub fn config_dir() -> Result<PathBuf, KeychainError> {
|
||||
let ap = wx_paths::AppPaths::new()
|
||||
.map_err(|e| KeychainError::Other(e.to_string()))?;
|
||||
let ap = wx_paths::AppPaths::new().map_err(|e| KeychainError::Other(e.to_string()))?;
|
||||
Ok(ap.config_dir())
|
||||
}
|
||||
|
||||
/// Default xwechat_files base path.
|
||||
fn default_xwechat_files_base() -> Result<PathBuf, KeychainError> {
|
||||
let ap = wx_paths::AppPaths::new()
|
||||
.map_err(|e| KeychainError::Other(e.to_string()))?;
|
||||
Ok(ap.home().join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files"))
|
||||
let ap = wx_paths::AppPaths::new().map_err(|e| KeychainError::Other(e.to_string()))?;
|
||||
Ok(ap
|
||||
.home()
|
||||
.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files"))
|
||||
}
|
||||
|
||||
/// Detect account directories from the filesystem (without WeChat running).
|
||||
@@ -350,7 +350,6 @@ fn tiebreak_by_wal_mtime<'a>(accounts: &[&'a AccountDirInfo]) -> Option<&'a Acco
|
||||
best
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -57,15 +57,13 @@ pub struct KeyStore {
|
||||
impl KeyStore {
|
||||
/// Default path: `<config_dir>/keys.toml`
|
||||
pub fn default_path() -> Result<PathBuf, KeychainError> {
|
||||
let ap = wx_paths::AppPaths::new()
|
||||
.map_err(|e| KeychainError::Other(e.to_string()))?;
|
||||
let ap = wx_paths::AppPaths::new().map_err(|e| KeychainError::Other(e.to_string()))?;
|
||||
Ok(ap.keys_file())
|
||||
}
|
||||
|
||||
/// Load from the default path, creating an empty store if the file doesn't exist.
|
||||
pub fn load_default() -> Result<Self, KeychainError> {
|
||||
let ap = wx_paths::AppPaths::new()
|
||||
.map_err(|e| KeychainError::Other(e.to_string()))?;
|
||||
let ap = wx_paths::AppPaths::new().map_err(|e| KeychainError::Other(e.to_string()))?;
|
||||
ap.migrate_config()
|
||||
.map_err(|e| KeychainError::Other(format!("config migration failed: {}", e)))?;
|
||||
let path = ap.keys_file();
|
||||
@@ -354,7 +352,6 @@ impl KeyStore {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -58,7 +58,11 @@ pub fn reset_ffmpeg_cache() {
|
||||
FFPROBE_CACHED.store(false, Ordering::Release);
|
||||
}
|
||||
|
||||
fn run_command_with_piped_input(bin: String, input: &[u8], args: &[&str]) -> Result<Output, MediaError> {
|
||||
fn run_command_with_piped_input(
|
||||
bin: String,
|
||||
input: &[u8],
|
||||
args: &[&str],
|
||||
) -> Result<Output, MediaError> {
|
||||
let mut child = Command::new(&bin)
|
||||
.args(args)
|
||||
.stdin(Stdio::piped())
|
||||
@@ -76,10 +80,12 @@ fn run_command_with_piped_input(bin: String, input: &[u8], args: &[&str]) -> Res
|
||||
})
|
||||
});
|
||||
|
||||
let output = child.wait_with_output().map_err(|e| MediaError::FfmpegFailed {
|
||||
status: -1,
|
||||
stderr: e.to_string(),
|
||||
})?;
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| MediaError::FfmpegFailed {
|
||||
status: -1,
|
||||
stderr: e.to_string(),
|
||||
})?;
|
||||
|
||||
if let Some(writer) = writer {
|
||||
let _ = writer.join();
|
||||
|
||||
@@ -22,7 +22,7 @@ pub fn query_hardlink_with_conn(
|
||||
media_type: &str,
|
||||
key: &str,
|
||||
) -> Result<Vec<HardlinkEntry>, MediaError> {
|
||||
let table = resolve_table(&conn, media_type)?;
|
||||
let table = resolve_table(conn, media_type)?;
|
||||
|
||||
let query = format!(
|
||||
"SELECT f.md5, f.file_name, f.file_size, f.modify_time,
|
||||
|
||||
@@ -37,9 +37,7 @@ fn extract_wxid_from_data_dir(data_dir: &Path) -> Result<String, MediaError> {
|
||||
|
||||
Ok(data_dir.parent().map_or_else(
|
||||
|| extract_wxid(dir_name),
|
||||
|root| {
|
||||
wx_keychain::process::extract_base_wxid_for_account_dir_under_root(root, dir_name)
|
||||
},
|
||||
|root| wx_keychain::process::extract_base_wxid_for_account_dir_under_root(root, dir_name),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -85,7 +85,9 @@ pub use audio_transcode::{transcode_silk_to_mp3, transcode_silk_to_ogg_opus};
|
||||
pub use dat::{decrypt_dat, detect_dat_format, detect_image_type, detect_xor_key};
|
||||
pub use error::MediaError;
|
||||
pub use fallback::{find_file_by_name, find_video_by_md5};
|
||||
pub use ffmpeg::{ffmpeg_available, ffprobe_available, reset_ffmpeg_cache, run_ffmpeg, run_ffprobe};
|
||||
pub use ffmpeg::{
|
||||
ffmpeg_available, ffprobe_available, reset_ffmpeg_cache, run_ffmpeg, run_ffprobe,
|
||||
};
|
||||
pub use hardlink::{query_hardlink, query_hardlink_with_conn};
|
||||
pub use image_resolver::{resolve_image, resolve_image_by_md5};
|
||||
pub use image_transcode::transcode_wxgf;
|
||||
@@ -97,7 +99,9 @@ pub use types::{
|
||||
MediaLookupResult, TranscodeAudioResult, TranscodeImageResult, VoiceBlob,
|
||||
};
|
||||
pub use video_decrypt::{decrypt_video, decrypt_video_with_keystream};
|
||||
pub use voice::{extract_voice, extract_voice_with_conn, extract_voice_with_conn_hint, find_media_dbs};
|
||||
pub use voice::{
|
||||
extract_voice, extract_voice_with_conn, extract_voice_with_conn_hint, find_media_dbs,
|
||||
};
|
||||
pub use wxgf::{parse_wxgf, WxgfContent};
|
||||
|
||||
/// Compute MD5 hash of bytes, returning the `md5::Digest` (displays as hex).
|
||||
|
||||
@@ -12,8 +12,13 @@ fn sample_silk() -> Vec<u8> {
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
fn long_sample_silk() -> Vec<u8> {
|
||||
silk_rs::encode_silk(vec![0_u8; silent_pcm_frame().len() * 250], 24_000, 24_000, true)
|
||||
.unwrap()
|
||||
silk_rs::encode_silk(
|
||||
vec![0_u8; silent_pcm_frame().len() * 250],
|
||||
24_000,
|
||||
24_000,
|
||||
true,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[cfg(feature = "audio")]
|
||||
|
||||
@@ -15,21 +15,21 @@ fn make_xor_dat(key: u8) -> Vec<u8> {
|
||||
/// Header: 07 08 V1 08 07 (6B) + aes_size LE (4B) + xor_size LE (4B) + 0x01 (1B) = 15B
|
||||
/// Then AES-ECB encrypted payload with fixed key, then raw, then XOR tail.
|
||||
fn make_v1_dat() -> Vec<u8> {
|
||||
use aes::cipher::{BlockCipherEncrypt, KeyInit};
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
|
||||
let key = b"cfcd208495d565ef"; // md5("0")[:16]
|
||||
let cipher = Aes128::new(key.into());
|
||||
|
||||
// Plaintext: JPEG header (16 bytes = 1 AES block) with PKCS7 padding
|
||||
let mut block1 = [
|
||||
let mut block1 = Array::from([
|
||||
0xFFu8, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
|
||||
0x01,
|
||||
];
|
||||
let mut block2 = [16u8; 16]; // full PKCS7 padding block
|
||||
]);
|
||||
let mut block2 = Array::from([16u8; 16]); // full PKCS7 padding block
|
||||
|
||||
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block1));
|
||||
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block2));
|
||||
cipher.encrypt_block(&mut block1);
|
||||
cipher.encrypt_block(&mut block2);
|
||||
|
||||
let aes_size: u32 = 16; // original plaintext size
|
||||
let xor_size: u32 = 0;
|
||||
@@ -46,20 +46,20 @@ fn make_v1_dat() -> Vec<u8> {
|
||||
|
||||
/// Build a V2-encrypted `.dat` file with known AES key and XOR tail.
|
||||
fn make_v2_dat(aes_key: &[u8; 16], xor_key: u8) -> Vec<u8> {
|
||||
use aes::cipher::{BlockCipherEncrypt, KeyInit};
|
||||
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||
use aes::Aes128;
|
||||
|
||||
let cipher = Aes128::new(aes_key.into());
|
||||
|
||||
// Plaintext: PNG header (16 bytes = 1 block)
|
||||
let mut block1 = [
|
||||
let mut block1 = Array::from([
|
||||
0x89u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
|
||||
0x52,
|
||||
];
|
||||
let mut block2 = [16u8; 16]; // PKCS7 padding block
|
||||
]);
|
||||
let mut block2 = Array::from([16u8; 16]); // PKCS7 padding block
|
||||
|
||||
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block1));
|
||||
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block2));
|
||||
cipher.encrypt_block(&mut block1);
|
||||
cipher.encrypt_block(&mut block2);
|
||||
|
||||
let aes_size: u32 = 16;
|
||||
// Tail: 4 bytes XOR-encrypted
|
||||
|
||||
@@ -184,10 +184,7 @@ fn voice_query_with_conn_returns_stable_not_found_errors() {
|
||||
fn voice_query_with_conn_hint_returns_chat_name_id_from_indexed_lookup() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let db_path = tmp.path().join("media.db");
|
||||
create_indexed_media_db(
|
||||
&db_path,
|
||||
&[(55, 1000, 1, "srv_hint_1", b"hinted_blob")],
|
||||
);
|
||||
create_indexed_media_db(&db_path, &[(55, 1000, 1, "srv_hint_1", b"hinted_blob")]);
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
|
||||
let blob = wx_media::extract_voice_with_conn_hint(&conn, "srv_hint_1", Some(55)).unwrap();
|
||||
|
||||
@@ -118,11 +118,7 @@ impl DecryptCache {
|
||||
wx_decrypt::dispatch_decrypt_db(src, dst, &self.key_material, self.params)
|
||||
}
|
||||
|
||||
fn do_decrypt_wal(
|
||||
&self,
|
||||
wal: &Path,
|
||||
dst: &Path,
|
||||
) -> Result<usize, wx_decrypt::DecryptError> {
|
||||
fn do_decrypt_wal(&self, wal: &Path, dst: &Path) -> Result<usize, wx_decrypt::DecryptError> {
|
||||
wx_decrypt::dispatch_decrypt_wal(wal, dst, &self.key_material, self.params)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
||||
|
||||
use futures_core::Stream;
|
||||
use wx_db::{SessionQuery, WechatDb};
|
||||
use wx_decrypt::{CryptoParams, KeyMaterial};
|
||||
use wx_decrypt::{CryptoParams, EncKeyPair, KeyMaterial};
|
||||
|
||||
use crate::cache::{DecryptCache, UpdateKind};
|
||||
use crate::error::MonitorError;
|
||||
@@ -100,7 +100,19 @@ impl WechatMonitor {
|
||||
let (db, cache) = if let (Some(raw_key), Some(ref encrypted_root)) =
|
||||
(config.raw_key, &config.encrypted_root)
|
||||
{
|
||||
let db = WechatDb::open_encrypted(encrypted_root, raw_key)?;
|
||||
let derived_keys: Vec<EncKeyPair> = match &config.key_material {
|
||||
KeyMaterial::EncKeys(pairs) => pairs.clone(),
|
||||
KeyMaterial::EncKey { key, salt } => vec![EncKeyPair {
|
||||
key: *key,
|
||||
salt: *salt,
|
||||
}],
|
||||
KeyMaterial::RawKey(_) => Vec::new(),
|
||||
};
|
||||
let db = WechatDb::open_encrypted_core_with_key_cache(
|
||||
encrypted_root,
|
||||
raw_key,
|
||||
&derived_keys,
|
||||
)?;
|
||||
(db, None)
|
||||
} else {
|
||||
let mut cache = DecryptCache::new(
|
||||
@@ -109,7 +121,7 @@ impl WechatMonitor {
|
||||
config.params,
|
||||
)?;
|
||||
cache.initial_decrypt()?;
|
||||
let db = WechatDb::open(cache.decrypted_root())?;
|
||||
let db = WechatDb::open_core(cache.decrypted_root())?;
|
||||
(db, Some(cache))
|
||||
};
|
||||
|
||||
|
||||
@@ -217,10 +217,7 @@ async fn monitor_detects_session_change() {
|
||||
|
||||
// Should be an Updated event for wxid_bob (the new session)
|
||||
assert_eq!(event.username, "wxid_bob");
|
||||
assert!(matches!(
|
||||
event.kind,
|
||||
wx_monitor::SessionEventKind::Updated
|
||||
));
|
||||
assert!(matches!(event.kind, wx_monitor::SessionEventKind::Updated));
|
||||
|
||||
// Stop monitor and assert clean exit
|
||||
monitor.stop();
|
||||
|
||||
+26
-10
@@ -211,7 +211,9 @@ impl AppPaths {
|
||||
|
||||
/// `<temp_root>/lldb/wechat_lldb_output.txt`
|
||||
pub fn lldb_output_file() -> PathBuf {
|
||||
Self::temp_root().join("lldb").join("wechat_lldb_output.txt")
|
||||
Self::temp_root()
|
||||
.join("lldb")
|
||||
.join("wechat_lldb_output.txt")
|
||||
}
|
||||
|
||||
/// `<temp_root>/nickname/<pid>_<nanos>.db`
|
||||
@@ -242,13 +244,21 @@ impl AppPaths {
|
||||
/// Current platform identifier.
|
||||
pub fn platform() -> &'static str {
|
||||
#[cfg(target_os = "macos")]
|
||||
{ "macos" }
|
||||
{
|
||||
"macos"
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{ "linux" }
|
||||
{
|
||||
"linux"
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{ "windows" }
|
||||
{
|
||||
"windows"
|
||||
}
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{ "unknown" }
|
||||
{
|
||||
"unknown"
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a summary of all paths.
|
||||
@@ -359,21 +369,21 @@ mod tests {
|
||||
#[test]
|
||||
fn server_lock_file_under_server_state() {
|
||||
let ap = AppPaths::new().unwrap();
|
||||
assert!(ap.server_lock_file().starts_with(&ap.server_state_dir()));
|
||||
assert!(ap.server_lock_file().starts_with(ap.server_state_dir()));
|
||||
assert!(ap.server_lock_file().ends_with("manager.lock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_config_file_under_server_state() {
|
||||
let ap = AppPaths::new().unwrap();
|
||||
assert!(ap.server_config_file().starts_with(&ap.server_state_dir()));
|
||||
assert!(ap.server_config_file().starts_with(ap.server_state_dir()));
|
||||
assert!(ap.server_config_file().ends_with("config.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_state_file_under_server_state() {
|
||||
let ap = AppPaths::new().unwrap();
|
||||
assert!(ap.server_state_file().starts_with(&ap.server_state_dir()));
|
||||
assert!(ap.server_state_file().starts_with(ap.server_state_dir()));
|
||||
assert!(ap.server_state_file().ends_with("state.json"));
|
||||
}
|
||||
|
||||
@@ -442,7 +452,10 @@ mod tests {
|
||||
let ap = AppPaths::new().unwrap();
|
||||
let config = ap.config_dir();
|
||||
assert!(
|
||||
config.to_str().unwrap().contains("Application Support/wx-cli/config"),
|
||||
config
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("Application Support/wx-cli/config"),
|
||||
"macOS config should be under Application Support: {:?}",
|
||||
config
|
||||
);
|
||||
@@ -464,7 +477,10 @@ mod tests {
|
||||
let ap = AppPaths::new().unwrap();
|
||||
let state = ap.state_root();
|
||||
assert!(
|
||||
state.to_str().unwrap().contains("Application Support/wx-cli/state"),
|
||||
state
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("Application Support/wx-cli/state"),
|
||||
"macOS state should be under Application Support: {:?}",
|
||||
state
|
||||
);
|
||||
|
||||
@@ -120,8 +120,14 @@ mod tests {
|
||||
}
|
||||
|
||||
// New files exist
|
||||
assert_eq!(fs::read_to_string(new_config.join("keys.toml")).unwrap(), "test-keys");
|
||||
assert_eq!(fs::read_to_string(new_config.join("settings.toml")).unwrap(), "test-settings");
|
||||
assert_eq!(
|
||||
fs::read_to_string(new_config.join("keys.toml")).unwrap(),
|
||||
"test-keys"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(new_config.join("settings.toml")).unwrap(),
|
||||
"test-settings"
|
||||
);
|
||||
|
||||
// Old files deleted
|
||||
assert!(!old_config.join("keys.toml").exists());
|
||||
@@ -153,7 +159,10 @@ mod tests {
|
||||
}
|
||||
|
||||
// New file unchanged
|
||||
assert_eq!(fs::read_to_string(new_config.join("keys.toml")).unwrap(), "new-keys");
|
||||
assert_eq!(
|
||||
fs::read_to_string(new_config.join("keys.toml")).unwrap(),
|
||||
"new-keys"
|
||||
);
|
||||
// Old file still exists (wasn't migrated because dst exists)
|
||||
assert!(old_config.join("keys.toml").exists());
|
||||
}
|
||||
|
||||
@@ -26,9 +26,7 @@ impl PlatformBaseDirs {
|
||||
let config_root = dirs::config_dir()
|
||||
.ok_or(PathsError::NoConfig)?
|
||||
.join("wx-cli");
|
||||
let cache_root = dirs::cache_dir()
|
||||
.ok_or(PathsError::NoCache)?
|
||||
.join("wx-cli");
|
||||
let cache_root = dirs::cache_dir().ok_or(PathsError::NoCache)?.join("wx-cli");
|
||||
let state_root = dirs::state_dir()
|
||||
.or_else(dirs::data_local_dir)
|
||||
.or_else(dirs::data_dir)
|
||||
@@ -48,9 +46,7 @@ impl PlatformBaseDirs {
|
||||
let config_root = dirs::config_dir()
|
||||
.ok_or(PathsError::NoConfig)?
|
||||
.join("wx-cli");
|
||||
let cache_root = dirs::cache_dir()
|
||||
.ok_or(PathsError::NoCache)?
|
||||
.join("wx-cli");
|
||||
let cache_root = dirs::cache_dir().ok_or(PathsError::NoCache)?.join("wx-cli");
|
||||
let local_data = dirs::data_local_dir()
|
||||
.or_else(dirs::data_dir)
|
||||
.ok_or(PathsError::NoState)?
|
||||
@@ -71,9 +67,7 @@ impl PlatformBaseDirs {
|
||||
let config_root = dirs::config_dir()
|
||||
.ok_or(PathsError::NoConfig)?
|
||||
.join("wx-cli");
|
||||
let cache_root = dirs::cache_dir()
|
||||
.ok_or(PathsError::NoCache)?
|
||||
.join("wx-cli");
|
||||
let cache_root = dirs::cache_dir().ok_or(PathsError::NoCache)?.join("wx-cli");
|
||||
let state_root = dirs::state_dir()
|
||||
.or_else(dirs::data_local_dir)
|
||||
.or_else(dirs::data_dir)
|
||||
|
||||
Reference in New Issue
Block a user