mirror of
https://github.com/pandorafuture/wx-cli.git
synced 2026-08-29 04:00:55 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9412ad134 | ||
|
|
18ebd7c8be | ||
|
|
fffe4c9c22 |
Generated
+1
@@ -3203,6 +3203,7 @@ dependencies = [
|
|||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
|
"wx-decrypt",
|
||||||
"zstd",
|
"zstd",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+15
@@ -8,3 +8,18 @@ edition = "2021"
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/pandorafuture/wx-cli"
|
repository = "https://github.com/pandorafuture/wx-cli"
|
||||||
description = "WeChat macOS database decryption and query tool"
|
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
|
# 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
|
wx-cli --version
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 让 Agent 直接使用
|
||||||
|
|
||||||
### AI 编程助手集成
|
本项目提供 [Agent Skill](https://skills.sh)。安装后,Claude Code、Codex、Cursor 等 Agent 可以直接理解 wx-cli 的能力,并帮你读取历史消息、搜索聊天和订阅新消息:
|
||||||
|
|
||||||
本项目提供 [Agent Skill](https://skills.sh),安装后 Claude Code、Codex、Cursor 等 AI 编程助手可直接协助查询微信数据:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx skills add pandorafuture/wx-cli
|
npx skills add pandorafuture/wx-cli
|
||||||
@@ -141,7 +173,9 @@ wx-cli server stop # 停止
|
|||||||
wx-cli server restart # 重启
|
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/timeline?since=<unix>&until=<unix>` 可在一次请求中读取时间范围内所有会话的消息,适合 Agent 记忆补全、归档和批处理,避免逐会话反复调用。
|
||||||
|
|
||||||
所有查询命令加 `--format json` 可获取 JSON 格式输出。
|
所有查询命令加 `--format json` 可获取 JSON 格式输出。
|
||||||
|
|
||||||
|
|||||||
@@ -258,6 +258,7 @@ wx-cli server status / stop / restart # 管理服务
|
|||||||
| `GET /api/v1/sessions` | 会话列表 | `limit`, `offset`, `order`, `show_hidden` |
|
| `GET /api/v1/sessions` | 会话列表 | `limit`, `offset`, `order`, `show_hidden` |
|
||||||
| `GET /api/v1/contacts` | 联系人列表 | `limit`, `offset`, `search`, `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/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/media` | 媒体内容直出 | `server_id`(必填), `talker`(必填), `format=ogg\|mp3`(仅语音) |
|
||||||
| `GET /api/v1/search` | 全文搜索 | `q`(必填), `limit`, `offset` |
|
| `GET /api/v1/search` | 全文搜索 | `q`(必填), `limit`, `offset` |
|
||||||
| `GET /api/v1/events` | SSE 事件流 | 无 |
|
| `GET /api/v1/events` | SSE 事件流 | 无 |
|
||||||
@@ -313,6 +314,7 @@ wx-cli sessions # 4. 重试查询
|
|||||||
|------|-------------|
|
|------|-------------|
|
||||||
| sessions | `username`, `display_name`, `summary`, `sort_timestamp`, `direction?` |
|
| sessions | `username`, `display_name`, `summary`, `sort_timestamp`, `direction?` |
|
||||||
| query | `sort_seq`, `server_id`, `msg_type`, `sender`, `content`, `direction` |
|
| query | `sort_seq`, `server_id`, `msg_type`, `sender`, `content`, `direction` |
|
||||||
|
| timeline API | `sort_seq`, `server_id`, `msg_type`, `sender`, `talker`, `talker_display_name`, `create_time`, `direction`, `snippet`;统一按时间跨会话排序 |
|
||||||
| contacts | `user_name`, `alias`, `remark`, `nick_name`, `phone`, `labels` |
|
| contacts | `user_name`, `alias`, `remark`, `nick_name`, `phone`, `labels` |
|
||||||
| search | `server_id`, `talker`, `sender`, `snippet`, `hit_type` |
|
| search | `server_id`, `talker`, `sender`, `snippet`, `hit_type` |
|
||||||
|
|
||||||
|
|||||||
@@ -77,24 +77,23 @@ pub fn cmd_query(
|
|||||||
|
|
||||||
if options.is_enabled() && !preserve_local_warning {
|
if options.is_enabled() && !preserve_local_warning {
|
||||||
let client = ThinClient::new(options.clone());
|
let client = ThinClient::new(options.clone());
|
||||||
match client.probe_health().and_then(|_| {
|
match client.probe_health() {
|
||||||
fetch_remote_query(
|
Ok(()) => {
|
||||||
&client,
|
let envelope = fetch_remote_query(
|
||||||
contact,
|
&client,
|
||||||
since,
|
contact,
|
||||||
until,
|
since,
|
||||||
msg_type.clone(),
|
until,
|
||||||
effective_limit,
|
msg_type.clone(),
|
||||||
offset,
|
effective_limit,
|
||||||
order.clone(),
|
offset,
|
||||||
around_sort_seq,
|
order.clone(),
|
||||||
around_server_id,
|
around_sort_seq,
|
||||||
context,
|
around_server_id,
|
||||||
after_sort_seq,
|
context,
|
||||||
show_hidden,
|
after_sort_seq,
|
||||||
)
|
show_hidden,
|
||||||
}) {
|
)?;
|
||||||
Ok(envelope) => {
|
|
||||||
let is_group = envelope
|
let is_group = envelope
|
||||||
.items
|
.items
|
||||||
.first()
|
.first()
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use wx_context::{
|
use wx_context::{register_mm_fts_tokenizer, AccountContext, ContactResolver, ResolveParams};
|
||||||
open_fts_connection_with_key, AccountContext, ContactResolver, ResolveParams,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::thin_client::{ThinClient, ThinClientCliArgs, ThinClientOptions};
|
use super::thin_client::{ThinClient, ThinClientCliArgs, ThinClientOptions};
|
||||||
use crate::output::{JsonEnvelope, PagingMeta, StatsMeta};
|
use crate::output::{JsonEnvelope, PagingMeta, StatsMeta};
|
||||||
@@ -64,7 +62,10 @@ fn load_local_search(
|
|||||||
|
|
||||||
// --- Native FTS search → fallback to scan ---
|
// --- Native FTS search → fallback to scan ---
|
||||||
let use_fallback = match db.message_fts_path.as_deref() {
|
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) => {
|
Ok(conn) => {
|
||||||
match wx_db::native_fts::search_message_fts(
|
match wx_db::native_fts::search_message_fts(
|
||||||
&conn,
|
&conn,
|
||||||
|
|||||||
@@ -8,20 +8,18 @@ use axum::http::StatusCode;
|
|||||||
use axum::response::sse::{Event, KeepAlive, Sse};
|
use axum::response::sse::{Event, KeepAlive, Sse};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use axum::Json;
|
use axum::Json;
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio_stream::wrappers::BroadcastStream;
|
use tokio_stream::wrappers::BroadcastStream;
|
||||||
use tokio_stream::StreamExt;
|
use tokio_stream::StreamExt;
|
||||||
|
|
||||||
use crate::cmd::server::types::{RuntimeAccountState, ServerHealthPayload};
|
use crate::cmd::server::types::{RuntimeAccountState, ServerHealthPayload};
|
||||||
use crate::output::JsonEnvelope;
|
use crate::output::{JsonEnvelope, PagingMeta, StatsMeta};
|
||||||
use crate::schema::{
|
use crate::schema::{
|
||||||
enrich_message, enrich_message_as_hit, enrich_native_fts_hit, enrich_session,
|
enrich_message, enrich_message_as_hit, enrich_native_fts_hit, enrich_session,
|
||||||
project_message_items,
|
project_message_items,
|
||||||
};
|
};
|
||||||
use crate::visibility_projection::{
|
use crate::visibility_projection::{project_contacts_envelope, project_sessions_envelope_enriched};
|
||||||
project_contacts_envelope, project_sessions_envelope_enriched,
|
|
||||||
};
|
|
||||||
|
|
||||||
use super::error::ServeError;
|
use super::error::ServeError;
|
||||||
use super::event::SseEvent;
|
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)]
|
#[derive(Deserialize)]
|
||||||
@@ -221,8 +281,7 @@ pub async fn handler_contacts(
|
|||||||
.query_contacts(&query)
|
.query_contacts(&query)
|
||||||
.map_err(|e| ServeError::Db(e.to_string()))?;
|
.map_err(|e| ServeError::Db(e.to_string()))?;
|
||||||
|
|
||||||
let envelope =
|
let envelope = JsonEnvelope::from_query_result(result, wx_db::MAX_QUERY_LIMIT, 0, |c| c);
|
||||||
JsonEnvelope::from_query_result(result, wx_db::MAX_QUERY_LIMIT, 0, |c| c);
|
|
||||||
Ok::<_, ServeError>(project_contacts_envelope(
|
Ok::<_, ServeError>(project_contacts_envelope(
|
||||||
envelope.items,
|
envelope.items,
|
||||||
&visibility,
|
&visibility,
|
||||||
@@ -379,9 +438,7 @@ pub async fn handler_messages(
|
|||||||
// When limit pushdown was used (non-anchor), total_rows only reflects the
|
// When limit pushdown was used (non-anchor), total_rows only reflects the
|
||||||
// scanned window. Use a lightweight COUNT(*) query for accurate DB-level total.
|
// scanned window. Use a lightweight COUNT(*) query for accurate DB-level total.
|
||||||
if !has_anchor {
|
if !has_anchor {
|
||||||
let mt_filter = msg_type
|
let mt_filter = msg_type.as_ref().and_then(|s| wx_db::parse_msg_type(s));
|
||||||
.as_ref()
|
|
||||||
.and_then(|s| wx_db::parse_msg_type(s));
|
|
||||||
let db_total = guard.count_messages(
|
let db_total = guard.count_messages(
|
||||||
&talker,
|
&talker,
|
||||||
since.unwrap_or(0),
|
since.unwrap_or(0),
|
||||||
@@ -405,6 +462,219 @@ pub async fn handler_messages(
|
|||||||
Ok(Json(result))
|
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
|
// Search
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -447,7 +717,9 @@ pub async fn handler_search(
|
|||||||
.map_err(|e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()))?;
|
.map_err(|e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()))?;
|
||||||
// Lazy-init name2id cache with proper error propagation.
|
// Lazy-init name2id cache with proper error propagation.
|
||||||
let name2id = {
|
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()))?;
|
.map_err(|e: std::sync::PoisonError<_>| ServeError::Internal(e.to_string()))?;
|
||||||
match cache_guard.as_ref() {
|
match cache_guard.as_ref() {
|
||||||
Some(map) => map.clone(),
|
Some(map) => map.clone(),
|
||||||
@@ -524,9 +796,7 @@ pub async fn handler_search(
|
|||||||
let first_attempt = guard
|
let first_attempt = guard
|
||||||
.pool()
|
.pool()
|
||||||
.and_then(|pool| pool.fts_conn())
|
.and_then(|pool| pool.fts_conn())
|
||||||
.map(|fts_conn| {
|
.map(|fts_conn| wx_db::native_fts::search_message_fts(fts_conn, &q, limit, offset));
|
||||||
wx_db::native_fts::search_message_fts(fts_conn, &q, limit, offset)
|
|
||||||
});
|
|
||||||
|
|
||||||
match first_attempt {
|
match first_attempt {
|
||||||
Some(Ok(r)) => Some(r),
|
Some(Ok(r)) => Some(r),
|
||||||
@@ -539,8 +809,8 @@ pub async fn handler_search(
|
|||||||
guard
|
guard
|
||||||
.pool()
|
.pool()
|
||||||
.and_then(|pool| pool.fts_conn())
|
.and_then(|pool| pool.fts_conn())
|
||||||
.and_then(
|
.and_then(|fts_conn| {
|
||||||
|fts_conn| match wx_db::native_fts::search_message_fts(
|
match wx_db::native_fts::search_message_fts(
|
||||||
fts_conn, &q, limit, offset,
|
fts_conn, &q, limit, offset,
|
||||||
) {
|
) {
|
||||||
Ok(r) => Some(r),
|
Ok(r) => Some(r),
|
||||||
@@ -551,8 +821,8 @@ pub async fn handler_search(
|
|||||||
);
|
);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
Err(reopen_err) => {
|
Err(reopen_err) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ use tokio::signal::unix::SignalKind;
|
|||||||
use tokio::sync::{broadcast, mpsc, watch};
|
use tokio::sync::{broadcast, mpsc, watch};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use wx_context::{
|
use wx_context::{
|
||||||
open_fts_connection_with_key, register_mm_fts_tokenizer, write_shard_metadata_sidecar,
|
register_mm_fts_tokenizer, write_shard_metadata_sidecar, AccountContext, ContactResolver,
|
||||||
AccountContext, ContactResolver, DecryptRequest, PersistentCache, ResolveParams,
|
DecryptRequest, PersistentCache, ResolveParams,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::util::{print_cache_stats, print_detection_note};
|
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)
|
// 3b. Open independent FTS connection (outside WechatDb Mutex)
|
||||||
let fts_conn = db.message_fts_path.as_deref().and_then(|fts_path| {
|
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) => {
|
Ok(conn) => {
|
||||||
if let Ok(mode) =
|
if let Ok(mode) =
|
||||||
conn.query_row("PRAGMA journal_mode", [], |r| r.get::<_, String>(0))
|
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)
|
// 3c. Open hardlink.db connection (pooled, outside WechatDb Mutex)
|
||||||
let hardlink_db_conn = if hardlink_db_path.exists() {
|
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) => {
|
Ok(conn) => {
|
||||||
eprintln!("server/hardlink: opened pooled connection");
|
eprintln!("server/hardlink: opened pooled connection");
|
||||||
Some(conn)
|
Some(conn)
|
||||||
@@ -222,9 +225,14 @@ pub async fn cmd_serve(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let watch_mode = resolve_watch_mode(poll, fsnotify);
|
let watch_mode = resolve_watch_mode(poll, fsnotify);
|
||||||
|
let monitor_derived_keys = wx_context::persisted_derived_keys(&acct)?;
|
||||||
let config = wx_monitor::MonitorConfig {
|
let config = wx_monitor::MonitorConfig {
|
||||||
encrypted_session_dir,
|
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,
|
params,
|
||||||
watch_mode: watch_mode.clone(),
|
watch_mode: watch_mode.clone(),
|
||||||
poll_interval: Duration::from_millis(poll_ms),
|
poll_interval: Duration::from_millis(poll_ms),
|
||||||
@@ -239,7 +247,6 @@ pub async fn cmd_serve(
|
|||||||
|
|
||||||
// Capture values before moving db into Mutex
|
// Capture values before moving db into Mutex
|
||||||
let fts_path_for_refresh = db.message_fts_path.clone();
|
let fts_path_for_refresh = db.message_fts_path.clone();
|
||||||
let raw_key_for_refresh = acct.raw_key;
|
|
||||||
|
|
||||||
// 5. Create refresh task channels
|
// 5. Create refresh task channels
|
||||||
let (refresh_tx, refresh_rx) = mpsc::channel::<RefreshTrigger>(64);
|
let (refresh_tx, refresh_rx) = mpsc::channel::<RefreshTrigger>(64);
|
||||||
@@ -306,6 +313,7 @@ pub async fn cmd_serve(
|
|||||||
eprintln!(" GET /api/v1/sessions");
|
eprintln!(" GET /api/v1/sessions");
|
||||||
eprintln!(" GET /api/v1/contacts");
|
eprintln!(" GET /api/v1/contacts");
|
||||||
eprintln!(" GET /api/v1/messages?contact=<name_or_wxid>");
|
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/media?server_id=<id>&talker=<wxid>[&format=ogg|mp3]");
|
||||||
eprintln!(" GET /api/v1/search?q=<keyword>");
|
eprintln!(" GET /api/v1/search?q=<keyword>");
|
||||||
eprintln!(" Auth: {auth_status}");
|
eprintln!(" Auth: {auth_status}");
|
||||||
@@ -398,7 +406,6 @@ pub async fn cmd_serve(
|
|||||||
shutdown_bg.clone(),
|
shutdown_bg.clone(),
|
||||||
)
|
)
|
||||||
.with_fts(bg_state.fts_conn.clone(), fts_path_for_refresh)
|
.with_fts(bg_state.fts_conn.clone(), fts_path_for_refresh)
|
||||||
.with_raw_key(raw_key_for_refresh)
|
|
||||||
.with_caches(
|
.with_caches(
|
||||||
Some(Arc::clone(&bg_state.name2id_cache)),
|
Some(Arc::clone(&bg_state.name2id_cache)),
|
||||||
Some(Arc::clone(&bg_state.media_db_paths)),
|
Some(Arc::clone(&bg_state.media_db_paths)),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use rusqlite::Connection;
|
|||||||
use tokio::sync::{mpsc, watch};
|
use tokio::sync::{mpsc, watch};
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
use wx_context::{
|
use wx_context::{
|
||||||
open_fts_connection, open_fts_connection_with_key, DecryptProgress, DecryptRequest,
|
open_fts_connection, register_mm_fts_tokenizer, DecryptProgress, DecryptRequest,
|
||||||
PersistentCache,
|
PersistentCache,
|
||||||
};
|
};
|
||||||
use wx_db::WechatDb;
|
use wx_db::WechatDb;
|
||||||
@@ -35,8 +35,6 @@ pub struct RefreshTask {
|
|||||||
fts_conn: Option<Arc<std::sync::Mutex<Connection>>>,
|
fts_conn: Option<Arc<std::sync::Mutex<Connection>>>,
|
||||||
/// Path to FTS DB for reopening.
|
/// Path to FTS DB for reopening.
|
||||||
fts_path: Option<PathBuf>,
|
fts_path: Option<PathBuf>,
|
||||||
/// Raw key for encrypted FTS reopen.
|
|
||||||
raw_key: Option<[u8; 32]>,
|
|
||||||
/// Cache of name2id mapping — cleared when FTS is reopened.
|
/// Cache of name2id mapping — cleared when FTS is reopened.
|
||||||
name2id_cache: Option<Arc<std::sync::Mutex<Option<HashMap<i64, String>>>>>,
|
name2id_cache: Option<Arc<std::sync::Mutex<Option<HashMap<i64, String>>>>>,
|
||||||
/// Cache of media DB paths — cleared on every refresh.
|
/// Cache of media DB paths — cleared on every refresh.
|
||||||
@@ -61,18 +59,12 @@ impl RefreshTask {
|
|||||||
shutdown,
|
shutdown,
|
||||||
fts_conn: None,
|
fts_conn: None,
|
||||||
fts_path: None,
|
fts_path: None,
|
||||||
raw_key: None,
|
|
||||||
name2id_cache: None,
|
name2id_cache: None,
|
||||||
media_db_paths: None,
|
media_db_paths: None,
|
||||||
hardlink_db_conn: 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.
|
/// Set the independent FTS connection and path for refresh reopening.
|
||||||
pub fn with_fts(
|
pub fn with_fts(
|
||||||
mut self,
|
mut self,
|
||||||
@@ -128,7 +120,6 @@ impl RefreshTask {
|
|||||||
let cache = self.cache.clone();
|
let cache = self.cache.clone();
|
||||||
let fts_conn = self.fts_conn.clone();
|
let fts_conn = self.fts_conn.clone();
|
||||||
let fts_path = self.fts_path.clone();
|
let fts_path = self.fts_path.clone();
|
||||||
let raw_key = self.raw_key;
|
|
||||||
let success = tokio::task::spawn_blocking(move || {
|
let success = tokio::task::spawn_blocking(move || {
|
||||||
if let Some(cache) = cache {
|
if let Some(cache) = cache {
|
||||||
// Decrypt-cache mode: decrypt then selective reopen
|
// Decrypt-cache mode: decrypt then selective reopen
|
||||||
@@ -264,7 +255,10 @@ impl RefreshTask {
|
|||||||
|
|
||||||
// Reopen independent FTS connection
|
// Reopen independent FTS connection
|
||||||
if let (Some(fts_mutex), Some(path)) = (&fts_conn, &fts_path) {
|
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) => {
|
Ok(new_conn) => {
|
||||||
if let Ok(mut fts_guard) = fts_mutex.lock()
|
if let Ok(mut fts_guard) = fts_mutex.lock()
|
||||||
as Result<std::sync::MutexGuard<'_, Connection>, _>
|
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/sessions", get(handlers::handler_sessions))
|
||||||
.route("/api/v1/contacts", get(handlers::handler_contacts))
|
.route("/api/v1/contacts", get(handlers::handler_contacts))
|
||||||
.route("/api/v1/messages", get(handlers::handler_messages))
|
.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/media", get(handlers::handler_media))
|
||||||
.route("/api/v1/search", get(handlers::handler_search))
|
.route("/api/v1/search", get(handlers::handler_search))
|
||||||
.route("/api/v1/events", get(handlers::handler_sse))
|
.route("/api/v1/events", get(handlers::handler_sse))
|
||||||
|
|||||||
@@ -318,9 +318,14 @@ pub async fn cmd_watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let watch_mode = resolve_watch_mode(poll, fsnotify);
|
let watch_mode = resolve_watch_mode(poll, fsnotify);
|
||||||
|
let monitor_derived_keys = wx_context::persisted_derived_keys(&acct)?;
|
||||||
let config = wx_monitor::MonitorConfig {
|
let config = wx_monitor::MonitorConfig {
|
||||||
encrypted_session_dir,
|
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,
|
params,
|
||||||
watch_mode: watch_mode.clone(),
|
watch_mode: watch_mode.clone(),
|
||||||
poll_interval: Duration::from_millis(poll_ms),
|
poll_interval: Duration::from_millis(poll_ms),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub fn open_db_core(
|
|||||||
) -> Result<(wx_db::WechatDb, Option<DecryptStats>), Box<dyn std::error::Error>> {
|
) -> Result<(wx_db::WechatDb, Option<DecryptStats>), Box<dyn std::error::Error>> {
|
||||||
if acct.raw_key.is_some() {
|
if acct.raw_key.is_some() {
|
||||||
eprintln!("Direct encrypted open (SQLCipher)");
|
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))
|
Ok((db, None))
|
||||||
} else {
|
} else {
|
||||||
let params = &wx_decrypt::MACOS_4_1_7_31;
|
let params = &wx_decrypt::MACOS_4_1_7_31;
|
||||||
@@ -18,7 +18,7 @@ pub fn open_db_core(
|
|||||||
let stats = DecryptRequest::new()
|
let stats = DecryptRequest::new()
|
||||||
.core()
|
.core()
|
||||||
.execute_with_progress(&cache, progress)?;
|
.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)))
|
Ok((db, Some(stats)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,9 +139,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
|
/// Attempt a remote API call via ThinClient. In auto mode, fall back locally only when
|
||||||
/// local path. This encapsulates the `probe_health → remote_fn → should_fallback → local_fn`
|
/// the initial health probe cannot reach/authenticate with a usable server. Once health
|
||||||
/// pattern shared by `search`, `contacts`, and `sessions`.
|
/// 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>(
|
pub fn try_remote_or_local<T>(
|
||||||
options: &ThinClientOptions,
|
options: &ThinClientOptions,
|
||||||
remote_fn: impl FnOnce(&ThinClient) -> Result<T, ThinClientError>,
|
remote_fn: impl FnOnce(&ThinClient) -> Result<T, ThinClientError>,
|
||||||
@@ -150,8 +151,8 @@ pub fn try_remote_or_local<T>(
|
|||||||
) -> Result<T, Box<dyn std::error::Error>> {
|
) -> Result<T, Box<dyn std::error::Error>> {
|
||||||
if options.is_enabled() {
|
if options.is_enabled() {
|
||||||
let client = ThinClient::new(options.clone());
|
let client = ThinClient::new(options.clone());
|
||||||
match client.probe_health().and_then(|_| remote_fn(&client)) {
|
match client.probe_health() {
|
||||||
Ok(result) => return Ok(result),
|
Ok(()) => return remote_fn(&client).map_err(Into::into),
|
||||||
Err(err) if err.should_fallback(options.mode) => {
|
Err(err) if err.should_fallback(options.mode) => {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"note: remote server unavailable, falling back to local {label} ({})",
|
"note: remote server unavailable, falling back to local {label} ({})",
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ use std::process::Command;
|
|||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn bin() -> &'static str {
|
fn bin() -> &'static str {
|
||||||
env!("CARGO_BIN_EXE_wx-cli")
|
env!("CARGO_BIN_EXE_wx-cli")
|
||||||
}
|
}
|
||||||
@@ -278,7 +280,8 @@ fn server_only_fails_when_remote_unavailable() {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unavailable_remote_falls_back_to_local() {
|
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"])
|
.args(["sessions", "--server-url", "http://127.0.0.1:9"])
|
||||||
.output()
|
.output()
|
||||||
.expect("run sessions fallback");
|
.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([
|
.args([
|
||||||
"sessions",
|
"sessions",
|
||||||
"--no-server",
|
"--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(
|
fn spawn_sequence_server(
|
||||||
expected_requests: usize,
|
expected_requests: usize,
|
||||||
responder: impl Fn(String, usize) -> String + Send + 'static,
|
responder: impl Fn(String, usize) -> String + Send + 'static,
|
||||||
|
|||||||
@@ -36,6 +36,25 @@ pub use progress::{DecryptProgress, DecryptStats};
|
|||||||
pub use shard_routing::{route_shards_for_query, write_shard_metadata_sidecar};
|
pub use shard_routing::{route_shards_for_query, write_shard_metadata_sidecar};
|
||||||
pub use visibility::VisibilityIndex;
|
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).
|
/// Open encrypted WeChat DB directory directly (no pool, no FTS).
|
||||||
/// For one-shot commands: contacts, sessions, query, search, export.
|
/// For one-shot commands: contacts, sessions, query, search, export.
|
||||||
pub fn open_encrypted_db(account: &AccountContext) -> Result<wx_db::WechatDb, ContextError> {
|
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
|
.raw_key
|
||||||
.ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?;
|
.ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?;
|
||||||
let encrypted_root = account.data_dir.join("db_storage");
|
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)
|
Ok(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,9 +93,11 @@ pub fn open_encrypted_db_with_pool(
|
|||||||
.raw_key
|
.raw_key
|
||||||
.ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?;
|
.ok_or_else(|| ContextError::Cache("raw_key required for encrypted direct open".into()))?;
|
||||||
let encrypted_root = account.data_dir.join("db_storage");
|
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,
|
&encrypted_root,
|
||||||
raw_key,
|
raw_key,
|
||||||
|
&derived_keys,
|
||||||
register_mm_fts_tokenizer,
|
register_mm_fts_tokenizer,
|
||||||
)?;
|
)?;
|
||||||
Ok(db)
|
Ok(db)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ thiserror = "2"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
|
wx-decrypt = { path = "../wx-decrypt" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
insta = { version = "1", features = ["yaml"] }
|
insta = { version = "1", features = ["yaml"] }
|
||||||
|
|||||||
@@ -414,7 +414,8 @@ impl WechatDb {
|
|||||||
)?;
|
)?;
|
||||||
|
|
||||||
for shard in &self.shards {
|
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
|
// List Msg_* tables in this shard
|
||||||
let mut table_stmt = shard_conn.prepare(
|
let mut table_stmt = shard_conn.prepare(
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use crate::model::{
|
|||||||
effective_limit, split_local_type, AnchorMode, Message, MessageQuery, MessageQueryResult,
|
effective_limit, split_local_type, AnchorMode, Message, MessageQuery, MessageQueryResult,
|
||||||
QueryStats, SortOrder,
|
QueryStats, SortOrder,
|
||||||
};
|
};
|
||||||
use crate::open::{MessageShard, WechatDb};
|
use crate::open::{MessageShard, SqlcipherKey, WechatDb};
|
||||||
|
|
||||||
/// Dispatch mode for regular (non-anchor) queries.
|
/// Dispatch mode for regular (non-anchor) queries.
|
||||||
enum RegularQueryMode {
|
enum RegularQueryMode {
|
||||||
@@ -56,13 +56,13 @@ fn prepare_shard_query<'a>(
|
|||||||
table_name: &str,
|
table_name: &str,
|
||||||
warnings: &mut Vec<ShardWarning>,
|
warnings: &mut Vec<ShardWarning>,
|
||||||
pooled_conn: Option<&'a Connection>,
|
pooled_conn: Option<&'a Connection>,
|
||||||
raw_key: Option<&[u8; 32]>,
|
sqlcipher_key: Option<&SqlcipherKey>,
|
||||||
) -> Option<PreparedShard<'a>> {
|
) -> Option<PreparedShard<'a>> {
|
||||||
let shard_path = shard.path.display().to_string();
|
let shard_path = shard.path.display().to_string();
|
||||||
|
|
||||||
let conn = match pooled_conn {
|
let conn = match pooled_conn {
|
||||||
Some(conn) => ShardConnection::Borrowed(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),
|
Ok(c) => ShardConnection::Owned(c),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warnings.push(ShardWarning {
|
warnings.push(ShardWarning {
|
||||||
@@ -176,7 +176,7 @@ impl WechatDb {
|
|||||||
&table_name,
|
&table_name,
|
||||||
&mut shard_warnings,
|
&mut shard_warnings,
|
||||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||||
self.raw_key.as_ref(),
|
self.sqlcipher_key.as_ref(),
|
||||||
) {
|
) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => continue,
|
None => continue,
|
||||||
@@ -296,12 +296,12 @@ impl WechatDb {
|
|||||||
for shard in &shards {
|
for shard in &shards {
|
||||||
let count = if let Some(pool) = self.pool() {
|
let count = if let Some(pool) = self.pool() {
|
||||||
if let Some(conn) = pool.get(&shard.path) {
|
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 {
|
} else {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
} else {
|
} 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) => {
|
Ok(conn) => {
|
||||||
Self::count_shard(&conn, &sql, start_time, end_time, msg_type_filter)
|
Self::count_shard(&conn, &sql, start_time, end_time, msg_type_filter)
|
||||||
}
|
}
|
||||||
@@ -385,7 +385,7 @@ impl WechatDb {
|
|||||||
table_name,
|
table_name,
|
||||||
&mut shard_warnings,
|
&mut shard_warnings,
|
||||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||||
self.raw_key.as_ref(),
|
self.sqlcipher_key.as_ref(),
|
||||||
) {
|
) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => continue,
|
None => continue,
|
||||||
@@ -476,7 +476,7 @@ impl WechatDb {
|
|||||||
table_name,
|
table_name,
|
||||||
&mut shard_warnings,
|
&mut shard_warnings,
|
||||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||||
self.raw_key.as_ref(),
|
self.sqlcipher_key.as_ref(),
|
||||||
) {
|
) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => continue,
|
None => continue,
|
||||||
@@ -607,7 +607,7 @@ impl WechatDb {
|
|||||||
table_name,
|
table_name,
|
||||||
&mut shard_warnings,
|
&mut shard_warnings,
|
||||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||||
self.raw_key.as_ref(),
|
self.sqlcipher_key.as_ref(),
|
||||||
) {
|
) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => continue,
|
None => continue,
|
||||||
@@ -683,7 +683,7 @@ impl WechatDb {
|
|||||||
table_name,
|
table_name,
|
||||||
&mut shard_warnings,
|
&mut shard_warnings,
|
||||||
self.pool().and_then(|pool| pool.get(&shard.path)),
|
self.pool().and_then(|pool| pool.get(&shard.path)),
|
||||||
self.raw_key.as_ref(),
|
self.sqlcipher_key.as_ref(),
|
||||||
) {
|
) {
|
||||||
Some(p) => p,
|
Some(p) => p,
|
||||||
None => continue,
|
None => continue,
|
||||||
@@ -812,16 +812,21 @@ impl WechatDb {
|
|||||||
known_usernames.iter().map(|u| (u.clone(), 0)).collect();
|
known_usernames.iter().map(|u| (u.clone(), 0)).collect();
|
||||||
|
|
||||||
for shard in self.all_shards() {
|
for shard in self.all_shards() {
|
||||||
let conn = match WechatDb::open_shard_with_key(shard, self.raw_key.as_ref()) {
|
let conn = if let Some(conn) = self.pool().and_then(|pool| pool.get(&shard.path)) {
|
||||||
Ok(c) => c,
|
ShardConnection::Borrowed(conn)
|
||||||
Err(e) => {
|
} else {
|
||||||
eprintln!(
|
match WechatDb::open_shard_with_key(shard, self.sqlcipher_key.as_ref()) {
|
||||||
"warn: bulk_max_sort_seq: open shard {} failed: {e}",
|
Ok(conn) => ShardConnection::Owned(conn),
|
||||||
shard.path.display()
|
Err(e) => {
|
||||||
);
|
eprintln!(
|
||||||
continue;
|
"warn: bulk_max_sort_seq: open shard {} failed: {e}",
|
||||||
|
shard.path.display()
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let conn = conn.as_conn();
|
||||||
|
|
||||||
// Discover Msg_* tables in this shard
|
// Discover Msg_* tables in this shard
|
||||||
let mut stmt = match conn
|
let mut stmt = match conn
|
||||||
|
|||||||
+314
-41
@@ -2,7 +2,7 @@ use std::collections::HashMap;
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::os::raw::c_void;
|
use std::os::raw::c_void;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::{Arc, RwLock};
|
use std::sync::{Arc, Mutex, RwLock};
|
||||||
|
|
||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
@@ -18,6 +18,111 @@ pub(crate) struct MessageShard {
|
|||||||
pub end_unix: i64,
|
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.
|
/// Handle to an opened (decrypted) WeChat database directory.
|
||||||
///
|
///
|
||||||
/// Holds connections to contact/session databases and metadata about
|
/// Holds connections to contact/session databases and metadata about
|
||||||
@@ -34,8 +139,8 @@ pub struct WechatDb {
|
|||||||
pub contact_fts_path: Option<PathBuf>,
|
pub contact_fts_path: Option<PathBuf>,
|
||||||
/// Optional pre-opened connection pool for serve mode.
|
/// Optional pre-opened connection pool for serve mode.
|
||||||
pub(crate) pool: Option<ShardPool>,
|
pub(crate) pool: Option<ShardPool>,
|
||||||
/// Raw key for encrypted direct open. Stored for reopen operations.
|
/// Shared raw/derived key state for encrypted direct open and reopen operations.
|
||||||
pub(crate) raw_key: Option<[u8; 32]>,
|
pub(crate) sqlcipher_key: Option<SqlcipherKey>,
|
||||||
/// Lazily initialized cache of label_id -> label_name from contact_label table.
|
/// Lazily initialized cache of label_id -> label_name from contact_label table.
|
||||||
/// Cleared on `reopen_contacts()` so label changes are visible.
|
/// Cleared on `reopen_contacts()` so label changes are visible.
|
||||||
pub(crate) label_cache: RwLock<Option<HashMap<String, String>>>,
|
pub(crate) label_cache: RwLock<Option<HashMap<String, String>>>,
|
||||||
@@ -54,30 +159,59 @@ pub fn open_readonly_connection(
|
|||||||
path: &Path,
|
path: &Path,
|
||||||
raw_key: Option<&[u8; 32]>,
|
raw_key: Option<&[u8; 32]>,
|
||||||
) -> Result<Connection, DbError> {
|
) -> Result<Connection, DbError> {
|
||||||
let conn = Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?;
|
let key = raw_key.copied().map(SqlcipherKey::new);
|
||||||
if let Some(key) = raw_key {
|
open_connection(path, key.as_ref())
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn open_connection(
|
pub(crate) fn open_connection(
|
||||||
path: &Path,
|
path: &Path,
|
||||||
raw_key: Option<&[u8; 32]>,
|
sqlcipher_key: Option<&SqlcipherKey>,
|
||||||
) -> Result<Connection, DbError> {
|
) -> 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 {
|
impl WechatDb {
|
||||||
@@ -87,7 +221,13 @@ impl WechatDb {
|
|||||||
/// does not exist. Message shards are optional here; message queries will
|
/// does not exist. Message shards are optional here; message queries will
|
||||||
/// return `DbError::NoShards` if no numbered shard is available.
|
/// return `DbError::NoShards` if no numbered shard is available.
|
||||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, DbError> {
|
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
|
/// 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()`.
|
/// 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> {
|
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
|
/// Open an encrypted WeChat database directory with a pre-opened
|
||||||
@@ -114,35 +286,53 @@ impl WechatDb {
|
|||||||
raw_key: [u8; 32],
|
raw_key: [u8; 32],
|
||||||
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
|
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
|
||||||
) -> Result<Self, DbError> {
|
) -> 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() {
|
if !path.exists() {
|
||||||
return Err(DbError::NotFound(path.display().to_string()));
|
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
|
// Open contact.db
|
||||||
let contact_path = path.join("contact").join("contact.db");
|
let contact_path = path.join("contact").join("contact.db");
|
||||||
if !contact_path.exists() {
|
if !contact_path.exists() {
|
||||||
return Err(DbError::NotFound(contact_path.display().to_string()));
|
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
|
// Open session.db
|
||||||
let session_path = path.join("session").join("session.db");
|
let session_path = path.join("session").join("session.db");
|
||||||
if !session_path.exists() {
|
if !session_path.exists() {
|
||||||
return Err(DbError::NotFound(session_path.display().to_string()));
|
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
|
// Scan message shards
|
||||||
let msg_dir = path.join("message");
|
let msg_dir = path.join("message");
|
||||||
let mut shards = Vec::new();
|
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)?
|
let mut entries: Vec<PathBuf> = std::fs::read_dir(&msg_dir)?
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
.map(|e| e.path())
|
.map(|e| e.path())
|
||||||
@@ -196,23 +386,23 @@ impl WechatDb {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
pool: None,
|
pool: None,
|
||||||
raw_key,
|
sqlcipher_key,
|
||||||
label_cache: RwLock::new(None),
|
label_cache: RwLock::new(None),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_with_pool_internal(
|
fn open_with_pool_internal(
|
||||||
path: impl AsRef<Path>,
|
path: impl AsRef<Path>,
|
||||||
raw_key: Option<[u8; 32]>,
|
sqlcipher_key: Option<SqlcipherKey>,
|
||||||
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
|
fts_init: impl Fn(&Connection) -> Result<(), String> + Send + Sync + 'static,
|
||||||
) -> Result<Self, DbError> {
|
) -> 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 fts_init_arc: Arc<crate::pool::FtsInitFn> = Arc::new(fts_init);
|
||||||
let pool = ShardPool::open(
|
let pool = ShardPool::open(
|
||||||
&db.shards,
|
&db.shards,
|
||||||
db.message_fts_path.as_deref(),
|
db.message_fts_path.as_deref(),
|
||||||
Some(fts_init_arc),
|
Some(fts_init_arc),
|
||||||
raw_key,
|
sqlcipher_key,
|
||||||
)?;
|
)?;
|
||||||
db.pool = Some(pool);
|
db.pool = Some(pool);
|
||||||
Ok(db)
|
Ok(db)
|
||||||
@@ -220,14 +410,14 @@ impl WechatDb {
|
|||||||
|
|
||||||
/// Re-open the session.db connection to pick up external changes.
|
/// Re-open the session.db connection to pick up external changes.
|
||||||
pub fn reopen_sessions(&mut self) -> Result<(), DbError> {
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-open the contact.db connection to pick up external changes.
|
/// Re-open the contact.db connection to pick up external changes.
|
||||||
/// Also invalidates the label cache so it is reloaded on next query.
|
/// Also invalidates the label cache so it is reloaded on next query.
|
||||||
pub fn reopen_contacts(&mut self) -> Result<(), DbError> {
|
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;
|
*self.label_cache.write().unwrap() = None;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -270,6 +460,13 @@ impl WechatDb {
|
|||||||
self.pool.as_ref()
|
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]`.
|
/// Return shards whose time range overlaps `[start, end]`.
|
||||||
pub(crate) fn shards_for_range(&self, start: i64, end: i64) -> Vec<&MessageShard> {
|
pub(crate) fn shards_for_range(&self, start: i64, end: i64) -> Vec<&MessageShard> {
|
||||||
self.shards
|
self.shards
|
||||||
@@ -307,9 +504,9 @@ impl WechatDb {
|
|||||||
/// Open a SQLite connection to a specific shard, optionally encrypted.
|
/// Open a SQLite connection to a specific shard, optionally encrypted.
|
||||||
pub(crate) fn open_shard_with_key(
|
pub(crate) fn open_shard_with_key(
|
||||||
shard: &MessageShard,
|
shard: &MessageShard,
|
||||||
raw_key: Option<&[u8; 32]>,
|
sqlcipher_key: Option<&SqlcipherKey>,
|
||||||
) -> Result<Connection, DbError> {
|
) -> 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.
|
/// Try to read the timestamp from a message shard's Timestamp table.
|
||||||
/// Returns 0 if the table does not exist or is empty.
|
/// Returns 0 if the table does not exist or is empty.
|
||||||
fn read_shard_timestamp(path: &Path, raw_key: Option<&[u8; 32]>) -> i64 {
|
fn read_shard_timestamp(path: &Path, sqlcipher_key: Option<&SqlcipherKey>) -> i64 {
|
||||||
let conn = match open_connection(path, raw_key) {
|
let conn = match open_connection(path, sqlcipher_key) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(_) => return 0,
|
Err(_) => return 0,
|
||||||
};
|
};
|
||||||
@@ -425,9 +622,35 @@ mod tests {
|
|||||||
build_encrypted_db_storage(&root, &raw_key);
|
build_encrypted_db_storage(&root, &raw_key);
|
||||||
|
|
||||||
let mut db = WechatDb::open_encrypted(&root, raw_key).unwrap();
|
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)
|
// Reopen should succeed (re-applies sqlite3_key)
|
||||||
db.reopen_sessions().unwrap();
|
db.reopen_sessions().unwrap();
|
||||||
db.reopen_contacts().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]
|
#[test]
|
||||||
@@ -457,7 +680,57 @@ mod tests {
|
|||||||
"CREATE TABLE t (id INTEGER); INSERT INTO t VALUES (42);",
|
"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
|
let val: i64 = conn
|
||||||
.query_row("SELECT id FROM t", [], |r| r.get(0))
|
.query_row("SELECT id FROM t", [], |r| r.get(0))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
+10
-10
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
|||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
|
|
||||||
use crate::error::DbError;
|
use crate::error::DbError;
|
||||||
use crate::open::MessageShard;
|
use crate::open::{MessageShard, SqlcipherKey};
|
||||||
|
|
||||||
pub(crate) type FtsInitFn = dyn Fn(&Connection) -> Result<(), String> + Send + Sync;
|
pub(crate) type FtsInitFn = dyn Fn(&Connection) -> Result<(), String> + Send + Sync;
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ pub struct ShardPool {
|
|||||||
fts_conn: Option<Connection>,
|
fts_conn: Option<Connection>,
|
||||||
fts_path: Option<PathBuf>,
|
fts_path: Option<PathBuf>,
|
||||||
fts_init: Option<Arc<FtsInitFn>>,
|
fts_init: Option<Arc<FtsInitFn>>,
|
||||||
raw_key: Option<[u8; 32]>,
|
sqlcipher_key: Option<SqlcipherKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for ShardPool {
|
impl std::fmt::Debug for ShardPool {
|
||||||
@@ -41,22 +41,22 @@ impl ShardPool {
|
|||||||
shards: &[MessageShard],
|
shards: &[MessageShard],
|
||||||
fts_path: Option<&Path>,
|
fts_path: Option<&Path>,
|
||||||
fts_init: Option<Arc<FtsInitFn>>,
|
fts_init: Option<Arc<FtsInitFn>>,
|
||||||
raw_key: Option<[u8; 32]>,
|
sqlcipher_key: Option<SqlcipherKey>,
|
||||||
) -> Result<Self, DbError> {
|
) -> Result<Self, DbError> {
|
||||||
let mut conns = HashMap::with_capacity(shards.len());
|
let mut conns = HashMap::with_capacity(shards.len());
|
||||||
for shard in shards {
|
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);
|
conns.insert(shard.path.clone(), conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
let fts_conn = match (fts_path, &fts_init) {
|
let fts_conn = match (fts_path, &fts_init) {
|
||||||
(Some(path), Some(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)?;
|
init(&conn).map_err(DbError::FtsInit)?;
|
||||||
Some(conn)
|
Some(conn)
|
||||||
}
|
}
|
||||||
(Some(path), None) => {
|
(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)
|
Some(conn)
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -67,7 +67,7 @@ impl ShardPool {
|
|||||||
fts_conn,
|
fts_conn,
|
||||||
fts_path: fts_path.map(|p| p.to_path_buf()),
|
fts_path: fts_path.map(|p| p.to_path_buf()),
|
||||||
fts_init,
|
fts_init,
|
||||||
raw_key,
|
sqlcipher_key,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ impl ShardPool {
|
|||||||
/// Close and reopen one shard connection.
|
/// Close and reopen one shard connection.
|
||||||
pub fn reopen_shard(&mut self, path: &Path) -> Result<(), DbError> {
|
pub fn reopen_shard(&mut self, path: &Path) -> Result<(), DbError> {
|
||||||
if self.conns.contains_key(path) {
|
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);
|
self.conns.insert(path.to_path_buf(), conn);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -93,7 +93,7 @@ impl ShardPool {
|
|||||||
/// Close and reopen the FTS connection, re-registering the tokenizer.
|
/// Close and reopen the FTS connection, re-registering the tokenizer.
|
||||||
pub fn reopen_fts(&mut self) -> Result<(), DbError> {
|
pub fn reopen_fts(&mut self) -> Result<(), DbError> {
|
||||||
if let Some(path) = &self.fts_path {
|
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 {
|
if let Some(init) = &self.fts_init {
|
||||||
init(&conn).map_err(DbError::FtsInit)?;
|
init(&conn).map_err(DbError::FtsInit)?;
|
||||||
}
|
}
|
||||||
@@ -106,7 +106,7 @@ impl ShardPool {
|
|||||||
pub fn reopen_all(&mut self) -> Result<(), DbError> {
|
pub fn reopen_all(&mut self) -> Result<(), DbError> {
|
||||||
let paths: Vec<PathBuf> = self.conns.keys().cloned().collect();
|
let paths: Vec<PathBuf> = self.conns.keys().cloned().collect();
|
||||||
for path in paths {
|
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.conns.insert(path, conn);
|
||||||
}
|
}
|
||||||
self.reopen_fts()?;
|
self.reopen_fts()?;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use futures_core::Stream;
|
use futures_core::Stream;
|
||||||
use wx_db::{SessionQuery, WechatDb};
|
use wx_db::{SessionQuery, WechatDb};
|
||||||
use wx_decrypt::{CryptoParams, KeyMaterial};
|
use wx_decrypt::{CryptoParams, EncKeyPair, KeyMaterial};
|
||||||
|
|
||||||
use crate::cache::{DecryptCache, UpdateKind};
|
use crate::cache::{DecryptCache, UpdateKind};
|
||||||
use crate::error::MonitorError;
|
use crate::error::MonitorError;
|
||||||
@@ -100,7 +100,19 @@ impl WechatMonitor {
|
|||||||
let (db, cache) = if let (Some(raw_key), Some(ref encrypted_root)) =
|
let (db, cache) = if let (Some(raw_key), Some(ref encrypted_root)) =
|
||||||
(config.raw_key, &config.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)
|
(db, None)
|
||||||
} else {
|
} else {
|
||||||
let mut cache = DecryptCache::new(
|
let mut cache = DecryptCache::new(
|
||||||
@@ -109,7 +121,7 @@ impl WechatMonitor {
|
|||||||
config.params,
|
config.params,
|
||||||
)?;
|
)?;
|
||||||
cache.initial_decrypt()?;
|
cache.initial_decrypt()?;
|
||||||
let db = WechatDb::open(cache.decrypted_root())?;
|
let db = WechatDb::open_core(cache.decrypted_root())?;
|
||||||
(db, Some(cache))
|
(db, Some(cache))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user