mirror of
https://github.com/pandorafuture/wx-cli.git
synced 2026-08-29 04:00:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2abe708f55 | ||
|
|
6c7fdda7db | ||
|
|
7bed9e767a |
@@ -2,6 +2,23 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Accept WeChat 4.1.7 and newer for key extraction instead of maintaining a fixed version-prefix allowlist.
|
||||||
|
|
||||||
|
## [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
|
## [0.7.3] - 2026-07-10
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -2,6 +2,41 @@
|
|||||||
|
|
||||||
本文件记录项目的所有重要变更。
|
本文件记录项目的所有重要变更。
|
||||||
|
|
||||||
|
## [未发布]
|
||||||
|
|
||||||
|
### 变更
|
||||||
|
|
||||||
|
- 密钥提取改为支持 WeChat 4.1.7 及以上版本,不再维护固定版本前缀白名单。
|
||||||
|
|
||||||
|
## [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
|
## [0.7.2] - 2026-04-06
|
||||||
|
|
||||||
### 功能
|
### 功能
|
||||||
|
|||||||
Generated
+8
-8
@@ -3132,7 +3132,7 @@ checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wx-cli"
|
name = "wx-cli"
|
||||||
version = "0.7.3"
|
version = "0.7.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -3168,7 +3168,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wx-context"
|
name = "wx-context"
|
||||||
version = "0.7.3"
|
version = "0.7.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"cbc",
|
"cbc",
|
||||||
@@ -3192,7 +3192,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wx-db"
|
name = "wx-db"
|
||||||
version = "0.7.3"
|
version = "0.7.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hex",
|
"hex",
|
||||||
"insta",
|
"insta",
|
||||||
@@ -3209,7 +3209,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wx-decrypt"
|
name = "wx-decrypt"
|
||||||
version = "0.7.3"
|
version = "0.7.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"cbc",
|
"cbc",
|
||||||
@@ -3222,7 +3222,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wx-keychain"
|
name = "wx-keychain"
|
||||||
version = "0.7.3"
|
version = "0.7.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"cbc",
|
"cbc",
|
||||||
@@ -3244,7 +3244,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wx-media"
|
name = "wx-media"
|
||||||
version = "0.7.3"
|
version = "0.7.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"base64",
|
"base64",
|
||||||
@@ -3262,7 +3262,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wx-monitor"
|
name = "wx-monitor"
|
||||||
version = "0.7.3"
|
version = "0.7.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"cbc",
|
"cbc",
|
||||||
@@ -3283,7 +3283,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wx-paths"
|
name = "wx-paths"
|
||||||
version = "0.7.3"
|
version = "0.7.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"dirs",
|
"dirs",
|
||||||
"libc",
|
"libc",
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@ members = ["crates/wx-decrypt", "crates/wx-keychain", "crates/wx-cli", "crates/w
|
|||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.7.3"
|
version = "0.7.4"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
repository = "https://github.com/pandorafuture/wx-cli"
|
repository = "https://github.com/pandorafuture/wx-cli"
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ wx-cli 提供了最关键的两样东西:完整的历史上下文,以及持
|
|||||||
## 支持范围
|
## 支持范围
|
||||||
|
|
||||||
- **平台**:macOS(arm64 / Apple Silicon)
|
- **平台**:macOS(arm64 / Apple Silicon)
|
||||||
- **WeChat 版本**:4.1.7.x / 4.1.8.x
|
- **WeChat 版本**:4.1.7 及以上
|
||||||
|
|
||||||
## 前置条件
|
## 前置条件
|
||||||
|
|
||||||
@@ -175,6 +175,10 @@ wx-cli server restart # 重启
|
|||||||
|
|
||||||
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)。
|
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 记忆补全、归档和批处理,避免逐会话反复调用。
|
其中 `/api/v1/timeline?since=<unix>&until=<unix>` 可在一次请求中读取时间范围内所有会话的消息,适合 Agent 记忆补全、归档和批处理,避免逐会话反复调用。
|
||||||
|
|
||||||
所有查询命令加 `--format json` 可获取 JSON 格式输出。
|
所有查询命令加 `--format json` 可获取 JSON 格式输出。
|
||||||
@@ -261,4 +265,4 @@ wx-cli/
|
|||||||
|
|
||||||
- `wx-cli key list` 确认密钥正确
|
- `wx-cli key list` 确认密钥正确
|
||||||
- `wx-cli info <db>` 检查文件是否为加密状态
|
- `wx-cli info <db>` 检查文件是否为加密状态
|
||||||
- 确认 WeChat 版本在 4.1.7.x / 4.1.8.x 范围内
|
- 确认 WeChat 版本不低于 4.1.7
|
||||||
|
|||||||
@@ -263,6 +263,8 @@ wx-cli server status / stop / restart # 管理服务
|
|||||||
| `GET /api/v1/search` | 全文搜索 | `q`(必填), `limit`, `offset` |
|
| `GET /api/v1/search` | 全文搜索 | `q`(必填), `limit`, `offset` |
|
||||||
| `GET /api/v1/events` | SSE 事件流 | 无 |
|
| `GET /api/v1/events` | SSE 事件流 | 无 |
|
||||||
|
|
||||||
|
图片响应会携带 `X-Wechat-Media-Quality: full|thumbnail`。服务会优先返回本机已有的高清/原图文件;若微信只下载过缩略图,则返回 `thumbnail`,调用方不应长期缓存,并可在微信下载原图后重试。
|
||||||
|
|
||||||
**认证:** 带 `--token` 启动时须携带 `Authorization: Bearer <token>`。`--host` 不是本机地址时 `--token` 必填。
|
**认证:** 带 `--token` 启动时须携带 `Authorization: Bearer <token>`。`--host` 不是本机地址时 `--token` 必填。
|
||||||
|
|
||||||
当前 HTTP API 为**只读**,没有 send/reply/webhook 等写接口。
|
当前 HTTP API 为**只读**,没有 send/reply/webhook 等写接口。
|
||||||
@@ -312,14 +314,15 @@ wx-cli sessions # 4. 重试查询
|
|||||||
|
|
||||||
| 命令 | item 关键字段 |
|
| 命令 | 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` |
|
| 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`;统一按时间跨会话排序 |
|
| 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`, `avatar_url?`, `phone`, `labels` |
|
||||||
| search | `server_id`, `talker`, `sender`, `snippet`, `hit_type` |
|
| search | `server_id`, `talker`, `sender`, `snippet`, `hit_type` |
|
||||||
|
|
||||||
**易混淆字段:**
|
**易混淆字段:**
|
||||||
- contacts 用 `user_name`(下划线),sessions 用 `username`(无下划线)
|
- contacts 用 `user_name`(下划线),sessions 用 `username`(无下划线)
|
||||||
|
- `avatar_url` 优先取微信小头像地址,缺失时回退大头像地址;本地没有记录时不输出
|
||||||
- message 的 `sender` 才是消息级 self/other 判断依据
|
- message 的 `sender` 才是消息级 self/other 判断依据
|
||||||
- `/api/v1/health` 的 `current_account.wxid` 是判断"我发的"的主事实源
|
- `/api/v1/health` 的 `current_account.wxid` 是判断"我发的"的主事实源
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ mod tests {
|
|||||||
last_sender_display_name: None,
|
last_sender_display_name: None,
|
||||||
},
|
},
|
||||||
display_name: "wxid_friend".to_string(),
|
display_name: "wxid_friend".to_string(),
|
||||||
|
avatar_url: None,
|
||||||
direction: None,
|
direction: None,
|
||||||
detected_at: None,
|
detected_at: None,
|
||||||
},
|
},
|
||||||
@@ -70,6 +71,7 @@ mod tests {
|
|||||||
last_sender_display_name: None,
|
last_sender_display_name: None,
|
||||||
},
|
},
|
||||||
display_name: "wxid_friend".to_string(),
|
display_name: "wxid_friend".to_string(),
|
||||||
|
avatar_url: None,
|
||||||
direction: None,
|
direction: None,
|
||||||
detected_at: Some(2),
|
detected_at: Some(2),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ enum MediaPayload {
|
|||||||
InlineBytes {
|
InlineBytes {
|
||||||
bytes: Vec<u8>,
|
bytes: Vec<u8>,
|
||||||
content_type: &'static str,
|
content_type: &'static str,
|
||||||
|
quality: Option<&'static str>,
|
||||||
},
|
},
|
||||||
ServePath {
|
ServePath {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
@@ -56,11 +57,17 @@ impl MediaPayload {
|
|||||||
Self::InlineBytes {
|
Self::InlineBytes {
|
||||||
bytes,
|
bytes,
|
||||||
content_type,
|
content_type,
|
||||||
|
quality,
|
||||||
} => {
|
} => {
|
||||||
let mut response = bytes.into_response();
|
let mut response = bytes.into_response();
|
||||||
response
|
response
|
||||||
.headers_mut()
|
.headers_mut()
|
||||||
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
|
.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)
|
Ok(response)
|
||||||
}
|
}
|
||||||
Self::ServePath {
|
Self::ServePath {
|
||||||
@@ -242,6 +249,14 @@ fn resolve_image(
|
|||||||
let dat_path = lookup.recommended.ok_or_else(|| {
|
let dat_path = lookup.recommended.ok_or_else(|| {
|
||||||
ServeError::NotFound(format!("no candidate image file found for md5={md5}"))
|
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)
|
let data = std::fs::read(&dat_path)
|
||||||
.map_err(|e| ServeError::Internal(format!("failed to read {}: {e}", dat_path.display())))?;
|
.map_err(|e| ServeError::Internal(format!("failed to read {}: {e}", dat_path.display())))?;
|
||||||
let decoded = wx_media::decrypt_dat(&data, &dat_decrypt)
|
let decoded = wx_media::decrypt_dat(&data, &dat_decrypt)
|
||||||
@@ -259,12 +274,14 @@ fn resolve_image(
|
|||||||
return Ok(MediaPayload::InlineBytes {
|
return Ok(MediaPayload::InlineBytes {
|
||||||
bytes: transcoded.data,
|
bytes: transcoded.data,
|
||||||
content_type: image_content_type(transcoded.ext),
|
content_type: image_content_type(transcoded.ext),
|
||||||
|
quality: Some(quality),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(MediaPayload::InlineBytes {
|
Ok(MediaPayload::InlineBytes {
|
||||||
bytes: decoded.data,
|
bytes: decoded.data,
|
||||||
content_type: image_content_type(&decoded.ext),
|
content_type: image_content_type(&decoded.ext),
|
||||||
|
quality: Some(quality),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,6 +305,7 @@ fn resolve_voice(
|
|||||||
return Ok(MediaPayload::InlineBytes {
|
return Ok(MediaPayload::InlineBytes {
|
||||||
bytes: cached.bytes.clone(),
|
bytes: cached.bytes.clone(),
|
||||||
content_type: cached.content_type,
|
content_type: cached.content_type,
|
||||||
|
quality: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -331,6 +349,7 @@ fn resolve_voice(
|
|||||||
return Ok(MediaPayload::InlineBytes {
|
return Ok(MediaPayload::InlineBytes {
|
||||||
bytes: result.data,
|
bytes: result.data,
|
||||||
content_type: result.mime,
|
content_type: result.mime,
|
||||||
|
quality: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Err(wx_media::MediaError::LookupMiss(_))
|
Err(wx_media::MediaError::LookupMiss(_))
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ fn format_watch_line(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
#[allow(clippy::items_after_test_module)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ pub struct EnrichedSession {
|
|||||||
pub session: Session,
|
pub session: Session,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub avatar_url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub direction: Option<Direction>,
|
pub direction: Option<Direction>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub detected_at: Option<i64>,
|
pub detected_at: Option<i64>,
|
||||||
@@ -61,10 +63,12 @@ pub fn enrich_session(
|
|||||||
detected_at: Option<i64>,
|
detected_at: Option<i64>,
|
||||||
) -> EnrichedSession {
|
) -> EnrichedSession {
|
||||||
let display_name = resolver.display_with_id(&session.username);
|
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);
|
let direction = derive_session_direction(session.last_msg_sender.as_deref(), self_wxid);
|
||||||
EnrichedSession {
|
EnrichedSession {
|
||||||
session,
|
session,
|
||||||
display_name,
|
display_name,
|
||||||
|
avatar_url,
|
||||||
direction,
|
direction,
|
||||||
detected_at,
|
detected_at,
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,7 @@ pub fn enrich_session_event(
|
|||||||
resolver: &ContactResolver,
|
resolver: &ContactResolver,
|
||||||
) -> EnrichedSession {
|
) -> EnrichedSession {
|
||||||
let display_name = resolver.display_with_id(&ev.username);
|
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 direction = derive_session_direction(ev.last_msg_sender.as_deref(), self_wxid);
|
||||||
let session = Session {
|
let session = Session {
|
||||||
username: ev.username,
|
username: ev.username,
|
||||||
@@ -88,6 +93,7 @@ pub fn enrich_session_event(
|
|||||||
EnrichedSession {
|
EnrichedSession {
|
||||||
session,
|
session,
|
||||||
display_name,
|
display_name,
|
||||||
|
avatar_url,
|
||||||
direction,
|
direction,
|
||||||
detected_at: Some(ev.detected_at),
|
detected_at: Some(ev.detected_at),
|
||||||
}
|
}
|
||||||
@@ -743,6 +749,7 @@ mod tests {
|
|||||||
last_sender_display_name: None,
|
last_sender_display_name: None,
|
||||||
},
|
},
|
||||||
display_name: "Spam".to_string(),
|
display_name: "Spam".to_string(),
|
||||||
|
avatar_url: None,
|
||||||
direction: Some(Direction::Incoming),
|
direction: Some(Direction::Incoming),
|
||||||
detected_at: None,
|
detected_at: None,
|
||||||
};
|
};
|
||||||
@@ -764,6 +771,7 @@ mod tests {
|
|||||||
last_sender_display_name: Some("Spammer".to_string()),
|
last_sender_display_name: Some("Spammer".to_string()),
|
||||||
},
|
},
|
||||||
display_name: "Group".to_string(),
|
display_name: "Group".to_string(),
|
||||||
|
avatar_url: None,
|
||||||
direction: Some(Direction::Incoming),
|
direction: Some(Direction::Incoming),
|
||||||
detected_at: None,
|
detected_at: None,
|
||||||
};
|
};
|
||||||
@@ -787,6 +795,7 @@ mod tests {
|
|||||||
last_sender_display_name: Some("Normal".to_string()),
|
last_sender_display_name: Some("Normal".to_string()),
|
||||||
},
|
},
|
||||||
display_name: "Group".to_string(),
|
display_name: "Group".to_string(),
|
||||||
|
avatar_url: None,
|
||||||
direction: Some(Direction::Incoming),
|
direction: Some(Direction::Incoming),
|
||||||
detected_at: None,
|
detected_at: None,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::io::{Read, Write};
|
|||||||
use std::net::{TcpListener, TcpStream};
|
use std::net::{TcpListener, TcpStream};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
|
use std::sync::Mutex;
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ const MSG_TABLE: &str = "Msg_29a6db07e8bbdb53f5d54cc3c309f3f1";
|
|||||||
const GROUP_TALKER: &str = "test@chatroom";
|
const GROUP_TALKER: &str = "test@chatroom";
|
||||||
const GROUP_MSG_TABLE: &str = "Msg_1d282e28b02b5c9f9522f855de32f9a8";
|
const GROUP_MSG_TABLE: &str = "Msg_1d282e28b02b5c9f9522f855de32f9a8";
|
||||||
const HIDDEN_SENDER: &str = "wxid_spam";
|
const HIDDEN_SENDER: &str = "wxid_spam";
|
||||||
|
static SERVER_START_LOCK: Mutex<()> = Mutex::new(());
|
||||||
|
|
||||||
fn bin() -> &'static str {
|
fn bin() -> &'static str {
|
||||||
env!("CARGO_BIN_EXE_wx-cli")
|
env!("CARGO_BIN_EXE_wx-cli")
|
||||||
@@ -150,9 +152,22 @@ fn serve_media_dispatch_image_returns_png_bytes() {
|
|||||||
);
|
);
|
||||||
assert_eq!(response.status_code, 200, "{response:#?}");
|
assert_eq!(response.status_code, 200, "{response:#?}");
|
||||||
assert_eq!(response.header("content-type"), Some("image/png"));
|
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");
|
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]
|
#[test]
|
||||||
fn serve_media_wxgf_embedded_png_returns_png_without_ffmpeg() {
|
fn serve_media_wxgf_embedded_png_returns_png_without_ffmpeg() {
|
||||||
let server = spawn_test_server_with_env(&[("FFMPEG_PATH", "/definitely-missing-ffmpeg")]);
|
let server = spawn_test_server_with_env(&[("FFMPEG_PATH", "/definitely-missing-ffmpeg")]);
|
||||||
@@ -326,6 +341,10 @@ fn spawn_test_server_with_setup(envs: &[(&str, &str)], hidden_contacts: &[&str])
|
|||||||
}
|
}
|
||||||
let account_dir = fixture.path().join(TEST_ACCOUNT_ID);
|
let account_dir = fixture.path().join(TEST_ACCOUNT_ID);
|
||||||
let runtime_root = fixture.path().join("runtime");
|
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 port = find_open_port();
|
||||||
let mut command = Command::new(bin());
|
let mut command = Command::new(bin());
|
||||||
command
|
command
|
||||||
@@ -712,6 +731,27 @@ fn create_encrypted_message_db(path: &Path, raw_key: &[u8; 32]) {
|
|||||||
)
|
)
|
||||||
.expect("insert image ok message");
|
.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);
|
let image_wxgf_png_info = encode_packed_info_for_test(Some("md5_image_wxgf_png"), None);
|
||||||
conn.execute(
|
conn.execute(
|
||||||
&format!(
|
&format!(
|
||||||
@@ -985,6 +1025,8 @@ fn create_image_fixture(attach_dir: &Path) {
|
|||||||
let encrypted = xor_bytes(&png, xor_key);
|
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_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_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 = sample_wxgf_with_embedded_png();
|
||||||
let wxgf_png_encrypted = xor_bytes(&wxgf_png, xor_key);
|
let wxgf_png_encrypted = xor_bytes(&wxgf_png, xor_key);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ struct ResolvedContact {
|
|||||||
signature: Option<String>,
|
signature: Option<String>,
|
||||||
region: Option<String>,
|
region: Option<String>,
|
||||||
labels: Vec<String>,
|
labels: Vec<String>,
|
||||||
|
avatar_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ContactResolver {
|
pub struct ContactResolver {
|
||||||
@@ -56,6 +57,7 @@ impl ContactResolver {
|
|||||||
signature: c.signature,
|
signature: c.signature,
|
||||||
region: c.region,
|
region: c.region,
|
||||||
labels: c.labels,
|
labels: c.labels,
|
||||||
|
avatar_url: c.avatar_url,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,6 +94,13 @@ impl ContactResolver {
|
|||||||
.unwrap_or(&[])
|
.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.
|
/// Iterate all contacts with their wxid and labels.
|
||||||
/// Used by VisibilityIndex to expand ignore_tags.
|
/// Used by VisibilityIndex to expand ignore_tags.
|
||||||
pub fn all_labels(&self) -> impl Iterator<Item = (&String, &[String])> {
|
pub fn all_labels(&self) -> impl Iterator<Item = (&String, &[String])> {
|
||||||
@@ -200,6 +209,7 @@ mod tests {
|
|||||||
signature: None,
|
signature: None,
|
||||||
region: None,
|
region: None,
|
||||||
labels: Vec::new(),
|
labels: Vec::new(),
|
||||||
|
avatar_url: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -235,6 +245,7 @@ mod tests {
|
|||||||
signature: None,
|
signature: None,
|
||||||
region: None,
|
region: None,
|
||||||
labels: labels.iter().map(|s| s.to_string()).collect(),
|
labels: labels.iter().map(|s| s.to_string()).collect(),
|
||||||
|
avatar_url: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -43,16 +43,19 @@ impl WechatDb {
|
|||||||
limit: usize,
|
limit: usize,
|
||||||
label_map: &HashMap<String, String>,
|
label_map: &HashMap<String, String>,
|
||||||
) -> Result<QueryResult<Contact>, DbError> {
|
) -> Result<QueryResult<Contact>, DbError> {
|
||||||
|
let avatar_expr = self.contact_avatar_select_expr()?;
|
||||||
let total_rows: usize =
|
let total_rows: usize =
|
||||||
self.contact_conn
|
self.contact_conn
|
||||||
.query_row("SELECT COUNT(*) FROM contact", [], |row| {
|
.query_row("SELECT COUNT(*) FROM contact", [], |row| {
|
||||||
row.get::<_, i64>(0)
|
row.get::<_, i64>(0)
|
||||||
})? as usize;
|
})? as usize;
|
||||||
|
|
||||||
let mut stmt = self.contact_conn.prepare(
|
let sql = format!(
|
||||||
"SELECT username, alias, remark, nick_name, description, extra_buffer \
|
"SELECT username, alias, remark, nick_name, description, extra_buffer, \
|
||||||
FROM contact ORDER BY username ASC LIMIT ?1 OFFSET ?2",
|
{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(
|
let rows = stmt.query_map(
|
||||||
[
|
[
|
||||||
Value::Integer(limit as i64),
|
Value::Integer(limit as i64),
|
||||||
@@ -81,6 +84,7 @@ impl WechatDb {
|
|||||||
label_map: &HashMap<String, String>,
|
label_map: &HashMap<String, String>,
|
||||||
) -> Result<QueryResult<Contact>, DbError> {
|
) -> Result<QueryResult<Contact>, DbError> {
|
||||||
let kw_lower = query.keyword.as_ref().unwrap().to_lowercase();
|
let kw_lower = query.keyword.as_ref().unwrap().to_lowercase();
|
||||||
|
let avatar_expr = self.contact_avatar_select_expr()?;
|
||||||
|
|
||||||
let total_rows: usize =
|
let total_rows: usize =
|
||||||
self.contact_conn
|
self.contact_conn
|
||||||
@@ -88,10 +92,12 @@ impl WechatDb {
|
|||||||
row.get::<_, i64>(0)
|
row.get::<_, i64>(0)
|
||||||
})? as usize;
|
})? as usize;
|
||||||
|
|
||||||
let mut stmt = self.contact_conn.prepare(
|
let sql = format!(
|
||||||
"SELECT username, alias, remark, nick_name, description, extra_buffer \
|
"SELECT username, alias, remark, nick_name, description, extra_buffer, \
|
||||||
FROM contact ORDER BY username ASC",
|
{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 rows = stmt.query_map([], |row| self.map_contact_row(row, label_map))?;
|
||||||
|
|
||||||
let all_contacts: Vec<Contact> = rows.filter_map(|r| r.ok()).collect();
|
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 nick_name: String = row.get::<_, String>(3).unwrap_or_default();
|
||||||
let memo: Option<String> = row.get::<_, Option<String>>(4).unwrap_or(None);
|
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 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);
|
let extra = contact_proto::decode_extra_buffer(&extra_buffer);
|
||||||
|
|
||||||
@@ -147,6 +157,22 @@ impl WechatDb {
|
|||||||
source_scene: extra.source_scene,
|
source_scene: extra.source_scene,
|
||||||
phone: extra.phone,
|
phone: extra.phone,
|
||||||
labels,
|
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",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -380,6 +380,9 @@ pub struct Contact {
|
|||||||
pub phone: Option<String>,
|
pub phone: Option<String>,
|
||||||
/// Resolved label names from extra_buffer + contact_label table.
|
/// Resolved label names from extra_buffer + contact_label table.
|
||||||
pub labels: Vec<String>,
|
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.
|
/// A WeChat chatroom (group chat) entry with its member list.
|
||||||
|
|||||||
@@ -282,6 +282,55 @@ fn contacts_sessions_query_contacts_limit() {
|
|||||||
assert_eq!(result.items.len(), 1);
|
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 ----
|
// ---- sessions tests ----
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ pub enum KeychainError {
|
|||||||
#[error("WeChat is not running")]
|
#[error("WeChat is not running")]
|
||||||
WeChatNotRunning,
|
WeChatNotRunning,
|
||||||
|
|
||||||
#[error("WeChat version {version} is not supported for key extraction (requires 4.1.7.x or 4.1.8.x)")]
|
#[error(
|
||||||
|
"WeChat version {version} is not supported for key extraction (requires 4.1.7 or newer)"
|
||||||
|
)]
|
||||||
UnsupportedVersion { version: String },
|
UnsupportedVersion { version: String },
|
||||||
|
|
||||||
#[error("could not detect WeChat account directory")]
|
#[error("could not detect WeChat account directory")]
|
||||||
|
|||||||
@@ -3,17 +3,30 @@ use std::process::Command;
|
|||||||
|
|
||||||
use crate::error::KeychainError;
|
use crate::error::KeychainError;
|
||||||
|
|
||||||
pub const SUPPORTED_VERSION: &str = "4.1.8.21";
|
pub const SUPPORTED_VERSION: &str = "4.1.7+";
|
||||||
|
|
||||||
/// Version prefixes accepted for LLDB key extraction.
|
/// Minimum version accepted for key extraction.
|
||||||
/// Encryption params (PBKDF2-HMAC-SHA512, 256K iterations) are identical across these versions.
|
/// Encryption params (PBKDF2-HMAC-SHA512, 256K iterations) are identical from this version onward.
|
||||||
const EXTRACTION_VERSION_PREFIXES: &[&str] = &["4.1.7", "4.1.8"];
|
const MIN_EXTRACTION_VERSION: (u64, u64, u64) = (4, 1, 7);
|
||||||
|
|
||||||
/// Check whether a version string is compatible with our LLDB key extraction.
|
/// Check whether a version string is compatible with our LLDB key extraction.
|
||||||
fn is_extraction_compatible(version: &str) -> bool {
|
fn is_extraction_compatible(version: &str) -> bool {
|
||||||
EXTRACTION_VERSION_PREFIXES
|
let mut components = version.split('.');
|
||||||
.iter()
|
let Some(major) = components.next().and_then(|value| value.parse().ok()) else {
|
||||||
.any(|prefix| version == *prefix || version.starts_with(&format!("{prefix}.")))
|
return false;
|
||||||
|
};
|
||||||
|
let Some(minor) = components.next().and_then(|value| value.parse().ok()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(patch) = components.next().and_then(|value| value.parse().ok()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
if components.any(|value| value.is_empty() || value.parse::<u64>().is_err()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
(major, minor, patch) >= MIN_EXTRACTION_VERSION
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -354,6 +367,28 @@ fn tiebreak_by_wal_mtime<'a>(accounts: &[&'a AccountDirInfo]) -> Option<&'a Acco
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extraction_version_accepts_4_1_7_and_newer() {
|
||||||
|
for version in [
|
||||||
|
"4.1.7", "4.1.7.31", "4.1.8", "4.1.9", "4.1.13", "4.2.0", "5.0.0",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
is_extraction_compatible(version),
|
||||||
|
"expected {version} to be supported"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extraction_version_rejects_older_and_invalid_versions() {
|
||||||
|
for version in ["4.1.6.99", "4.0.99", "3.9.9", "4.1", "4.1.beta", ""] {
|
||||||
|
assert!(
|
||||||
|
!is_extraction_compatible(version),
|
||||||
|
"expected {version:?} to be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_base_wxid_with_suffix() {
|
fn test_extract_base_wxid_with_suffix() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -199,8 +199,8 @@ fn aes_ecb_decrypt(ciphertext: &[u8], key: &[u8; 16]) -> Result<Vec<u8>, MediaEr
|
|||||||
let cipher = Aes128::new(key.into());
|
let cipher = Aes128::new(key.into());
|
||||||
let mut decrypted = ciphertext.to_vec();
|
let mut decrypted = ciphertext.to_vec();
|
||||||
|
|
||||||
for chunk in decrypted.chunks_exact_mut(16) {
|
for chunk in decrypted.as_chunks_mut::<16>().0 {
|
||||||
cipher.decrypt_block(chunk.try_into().expect("chunk length mismatch"));
|
cipher.decrypt_block(chunk.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// PKCS7 unpadding
|
// PKCS7 unpadding
|
||||||
|
|||||||
@@ -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
|
/// 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.
|
/// Then AES-ECB encrypted payload with fixed key, then raw, then XOR tail.
|
||||||
fn make_v1_dat() -> Vec<u8> {
|
fn make_v1_dat() -> Vec<u8> {
|
||||||
use aes::cipher::{BlockCipherEncrypt, KeyInit};
|
use aes::cipher::{Array, BlockCipherEncrypt, KeyInit};
|
||||||
use aes::Aes128;
|
use aes::Aes128;
|
||||||
|
|
||||||
let key = b"cfcd208495d565ef"; // md5("0")[:16]
|
let key = b"cfcd208495d565ef"; // md5("0")[:16]
|
||||||
let cipher = Aes128::new(key.into());
|
let cipher = Aes128::new(key.into());
|
||||||
|
|
||||||
// Plaintext: JPEG header (16 bytes = 1 AES block) with PKCS7 padding
|
// 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,
|
0xFFu8, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00,
|
||||||
0x01,
|
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(&mut block1);
|
||||||
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block2));
|
cipher.encrypt_block(&mut block2);
|
||||||
|
|
||||||
let aes_size: u32 = 16; // original plaintext size
|
let aes_size: u32 = 16; // original plaintext size
|
||||||
let xor_size: u32 = 0;
|
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.
|
/// 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> {
|
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;
|
use aes::Aes128;
|
||||||
|
|
||||||
let cipher = Aes128::new(aes_key.into());
|
let cipher = Aes128::new(aes_key.into());
|
||||||
|
|
||||||
// Plaintext: PNG header (16 bytes = 1 block)
|
// 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,
|
0x89u8, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
|
||||||
0x52,
|
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(&mut block1);
|
||||||
cipher.encrypt_block(aes::cipher::Array::from_mut_slice(&mut block2));
|
cipher.encrypt_block(&mut block2);
|
||||||
|
|
||||||
let aes_size: u32 = 16;
|
let aes_size: u32 = 16;
|
||||||
// Tail: 4 bytes XOR-encrypted
|
// Tail: 4 bytes XOR-encrypted
|
||||||
|
|||||||
@@ -369,21 +369,21 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn server_lock_file_under_server_state() {
|
fn server_lock_file_under_server_state() {
|
||||||
let ap = AppPaths::new().unwrap();
|
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"));
|
assert!(ap.server_lock_file().ends_with("manager.lock"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_config_file_under_server_state() {
|
fn server_config_file_under_server_state() {
|
||||||
let ap = AppPaths::new().unwrap();
|
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"));
|
assert!(ap.server_config_file().ends_with("config.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_state_file_under_server_state() {
|
fn server_state_file_under_server_state() {
|
||||||
let ap = AppPaths::new().unwrap();
|
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"));
|
assert!(ap.server_state_file().ends_with("state.json"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user