feat: support macOS WeChat profiles

pull/93/head
Richard Liu 2026-06-01 15:16:38 +08:00
parent 08af894594
commit 4028473ec4
9 changed files with 675 additions and 136 deletions

View File

@ -143,6 +143,30 @@ wx sessions
能看到最近会话即表示一切正常。daemon 在首次调用时自动启动。 能看到最近会话即表示一切正常。daemon 在首次调用时自动启动。
### 双开微信 / 多账号 profile
macOS 上如果同时运行 `/Applications/WeChat.app``/Applications/WeChat2.app`
两个主进程都叫 `WeChat`,需要用 profile 把配置、密钥、daemon socket 和缓存隔离开:
```bash
# 主微信com.tencent.xinWeChat
sudo wx --profile main init --app /Applications/WeChat.app
wx --profile main sessions
# 第二个微信com.tencent.xinWeChat2
sudo wx --profile second init --app /Applications/WeChat2.app
wx --profile second sessions
```
也可以直接按 bundle id 初始化:
```bash
sudo wx --profile second init --bundle-id com.tencent.xinWeChat2
```
profile 数据保存在 `~/.wx-cli/profiles/<profile>/`;未传 `--profile` 时仍使用旧的
`~/.wx-cli/`,保持兼容。
--- ---
## 命令 ## 命令
@ -326,6 +350,7 @@ wx (CLI) ──Unix socket──▶ wx-daemon (后台进程)
``` ```
daemon 首次解密后将数据库和 mtime 持久化到 `~/.wx-cli/cache/`。重启后 mtime 未变则直接复用,无需重解密。 daemon 首次解密后将数据库和 mtime 持久化到 `~/.wx-cli/cache/`。重启后 mtime 未变则直接复用,无需重解密。
如果使用了 `--profile`,对应状态位于 `~/.wx-cli/profiles/<profile>/`
``` ```
~/.wx-cli/ ~/.wx-cli/

View File

@ -1,52 +1,128 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde_json::json; use serde_json::json;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf;
use crate::config; use crate::config;
use crate::scanner; use crate::scanner::{self, ScanOptions};
pub fn cmd_init(force: bool) -> Result<()> { pub fn cmd_init(
force: bool,
db_dir_arg: Option<PathBuf>,
app_arg: Option<PathBuf>,
bundle_id_arg: Option<String>,
wechat_process_arg: Option<String>,
) -> Result<()> {
// 查找 config.json // 查找 config.json
let config_path = find_or_create_config_path(); let config_path = find_or_create_config_path();
let existing_cfg = read_config_map(&config_path);
// 检查是否已初始化 // 检查是否已初始化
if !force && config_path.exists() { let selector_supplied = db_dir_arg.is_some()
if let Ok(content) = std::fs::read_to_string(&config_path) { || app_arg.is_some()
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&content) { || bundle_id_arg.is_some()
let db_dir = cfg.get("db_dir").and_then(|v| v.as_str()).unwrap_or(""); || wechat_process_arg.is_some();
let keys_file = cfg.get("keys_file").and_then(|v| v.as_str()).unwrap_or("all_keys.json"); let target_selector_supplied =
let keys_path = if std::path::Path::new(keys_file).is_absolute() { db_dir_arg.is_some() || app_arg.is_some() || bundle_id_arg.is_some();
std::path::PathBuf::from(keys_file) if !force && !selector_supplied {
} else { if let Some(cfg) = existing_cfg.as_ref() {
config_path.parent().unwrap_or(std::path::Path::new(".")) let db_dir = cfg.get("db_dir").and_then(|v| v.as_str()).unwrap_or("");
.join(keys_file) let keys_file = cfg
}; .get("keys_file")
if !db_dir.is_empty() && !db_dir.contains("your_wxid") .and_then(|v| v.as_str())
&& std::path::Path::new(db_dir).exists() .unwrap_or("all_keys.json");
&& keys_path.exists() let keys_path = if std::path::Path::new(keys_file).is_absolute() {
{ std::path::PathBuf::from(keys_file)
println!("已初始化,数据目录: {}", db_dir); } else {
println!("如需重新扫描密钥,使用 --force"); config_path
return Ok(()); .parent()
} .unwrap_or(std::path::Path::new("."))
.join(keys_file)
};
if !db_dir.is_empty()
&& !db_dir.contains("your_wxid")
&& std::path::Path::new(db_dir).exists()
&& keys_path.exists()
{
println!("已初始化,数据目录: {}", db_dir);
println!("如需重新扫描密钥,使用 --force");
return Ok(());
} }
} }
} }
let existing_db_dir = if target_selector_supplied {
None
} else {
existing_string(&existing_cfg, "db_dir").map(PathBuf::from)
};
let app_path = match app_arg {
Some(path) => Some(normalize_path(path)),
None if !target_selector_supplied => existing_string(&existing_cfg, "app_path")
.map(PathBuf::from)
.map(normalize_path),
None => None,
};
let bundle_id_arg = match bundle_id_arg {
Some(id) if !id.trim().is_empty() => Some(id),
Some(_) => None,
None if !target_selector_supplied => existing_string(&existing_cfg, "bundle_id"),
None => None,
};
let db_dir_for_bundle = db_dir_arg.as_deref().or(existing_db_dir.as_deref());
let bundle_id = resolve_bundle_id(bundle_id_arg, app_path.as_deref(), db_dir_for_bundle);
if db_dir_arg.is_none()
&& existing_db_dir.is_none()
&& app_path.is_some()
&& bundle_id.is_none()
{
anyhow::bail!("无法从 --app 读取 bundle id请同时传 --bundle-id 或 --db-dir");
}
let wechat_process = wechat_process_arg
.or_else(|| existing_string(&existing_cfg, "wechat_process"))
.unwrap_or_else(default_process_name);
// Step 1: 检测 db_dir // Step 1: 检测 db_dir
println!("检测微信数据目录..."); let db_dir = if let Some(db_dir) = db_dir_arg {
let db_dir = config::auto_detect_db_dir().with_context(|| format!( let db_dir = normalize_path(db_dir);
"未能自动检测到微信数据目录\n\ if !db_dir.is_dir() {
db_dir :\n \ anyhow::bail!("指定的 db_storage 目录不存在: {}", db_dir.display());
{}\n\ }
db_dir : <data_root>\\xwechat_files\\<wxid>\\db_storage", db_dir
config_path.display() } else if let Some(db_dir) = existing_db_dir.filter(|p| p.is_dir()) {
))?; normalize_path(db_dir)
} else {
println!("检测微信数据目录...");
config::auto_detect_db_dir_for_bundle(bundle_id.as_deref()).with_context(|| {
let bundle_hint = bundle_id
.as_deref()
.map(|id| format!("bundle_id: {id}"))
.unwrap_or_default();
format!(
"未能自动检测到微信数据目录{bundle_hint}\n\
db_dir :\n \
{}\n\
: wx init --db-dir <data_root>/xwechat_files/<wxid>/db_storage",
config_path.display()
)
})?
};
println!("找到数据目录: {}", db_dir.display()); println!("找到数据目录: {}", db_dir.display());
if let Some(bundle_id) = bundle_id.as_deref() {
println!("目标 bundle id: {}", bundle_id);
}
if let Some(app_path) = app_path.as_deref() {
println!("目标 App: {}", app_path.display());
}
// Step 2: 扫描密钥(需要 root/sudo // Step 2: 扫描密钥(需要 root/sudo
println!("扫描加密密钥(需要 root 权限)..."); println!("扫描加密密钥(需要 root 权限)...");
let entries = scanner::scan_keys(&db_dir)?; let scan_opts = ScanOptions {
process_name: Some(wechat_process.clone()),
bundle_id: bundle_id.clone(),
app_path: app_path.clone(),
};
let entries = scanner::scan_keys(&db_dir, &scan_opts)?;
// === 权限边界 === // === 权限边界 ===
// 扫描完成后立即 drop 到调用用户身份,后续文件写入都是用户属主。 // 扫描完成后立即 drop 到调用用户身份,后续文件写入都是用户属主。
@ -62,15 +138,19 @@ pub fn cmd_init(force: bool) -> Result<()> {
} }
// Step 3: 保存 all_keys.json // Step 3: 保存 all_keys.json
let keys_file_path = config_path.parent() let keys_file_path = config_path
.parent()
.unwrap_or(std::path::Path::new(".")) .unwrap_or(std::path::Path::new("."))
.join("all_keys.json"); .join("all_keys.json");
let mut keys_json = serde_json::Map::new(); let mut keys_json = serde_json::Map::new();
for entry in &entries { for entry in &entries {
keys_json.insert(entry.db_name.clone(), json!({ keys_json.insert(
"enc_key": entry.enc_key, entry.db_name.clone(),
})); json!({
"enc_key": entry.enc_key,
}),
);
} }
std::fs::write(&keys_file_path, serde_json::to_string_pretty(&keys_json)?) std::fs::write(&keys_file_path, serde_json::to_string_pretty(&keys_json)?)
.context("写入 all_keys.json 失败")?; .context("写入 all_keys.json 失败")?;
@ -90,8 +170,17 @@ pub fn cmd_init(force: bool) -> Result<()> {
} }
} }
cfg.insert("db_dir".into(), json!(db_dir.to_string_lossy())); cfg.insert("db_dir".into(), json!(db_dir.to_string_lossy()));
cfg.entry("keys_file".into()).or_insert_with(|| json!("all_keys.json")); cfg.entry("keys_file".into())
cfg.entry("decrypted_dir".into()).or_insert_with(|| json!("decrypted")); .or_insert_with(|| json!("all_keys.json"));
cfg.entry("decrypted_dir".into())
.or_insert_with(|| json!("decrypted"));
cfg.insert("wechat_process".into(), json!(wechat_process));
if let Some(bundle_id) = bundle_id {
cfg.insert("bundle_id".into(), json!(bundle_id));
}
if let Some(app_path) = app_path {
cfg.insert("app_path".into(), json!(app_path.to_string_lossy()));
}
std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?) std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?)
.context("写入 config.json 失败")?; .context("写入 config.json 失败")?;
@ -118,6 +207,73 @@ pub fn cmd_init(force: bool) -> Result<()> {
Ok(()) Ok(())
} }
fn read_config_map(
config_path: &std::path::Path,
) -> Option<serde_json::Map<String, serde_json::Value>> {
let content = std::fs::read_to_string(config_path).ok()?;
serde_json::from_str::<serde_json::Value>(&content)
.ok()?
.as_object()
.cloned()
}
fn existing_string(
cfg: &Option<serde_json::Map<String, serde_json::Value>>,
key: &str,
) -> Option<String> {
cfg.as_ref()?
.get(key)?
.as_str()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
}
fn normalize_path(path: PathBuf) -> PathBuf {
std::fs::canonicalize(&path).unwrap_or(path)
}
fn default_process_name() -> String {
#[cfg(target_os = "macos")]
{
"WeChat".to_string()
}
#[cfg(target_os = "linux")]
{
"wechat".to_string()
}
#[cfg(target_os = "windows")]
{
"Weixin.exe".to_string()
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
"WeChat".to_string()
}
}
fn resolve_bundle_id(
explicit: Option<String>,
app_path: Option<&std::path::Path>,
db_dir: Option<&std::path::Path>,
) -> Option<String> {
if matches!(explicit.as_deref(), Some(s) if !s.trim().is_empty()) {
return explicit;
}
#[cfg(target_os = "macos")]
{
app_path
.and_then(config::macos_bundle_id_from_app)
.or_else(|| db_dir.and_then(config::macos_bundle_id_from_db_dir))
}
#[cfg(not(target_os = "macos"))]
{
let _ = app_path;
let _ = db_dir;
None
}
}
/// 如果当前以 root 身份运行且是通过 sudo 启动的drop 到调用用户身份, /// 如果当前以 root 身份运行且是通过 sudo 启动的drop 到调用用户身份,
/// 并迁移旧版本遗留的 root 属主 `~/.wx-cli/`。 /// 并迁移旧版本遗留的 root 属主 `~/.wx-cli/`。
/// ///
@ -144,14 +300,16 @@ fn drop_privileges_if_sudo() -> Result<()> {
// 迁移旧版本遗留:如果 ~/.wx-cli/ 已存在且属 root把它 chown 回调用用户, // 迁移旧版本遗留:如果 ~/.wx-cli/ 已存在且属 root把它 chown 回调用用户,
// 顺便把 raw key 文件的权限也收紧到 0600旧版默认 0644世界可读等于泄露 // 顺便把 raw key 文件的权限也收紧到 0600旧版默认 0644世界可读等于泄露
// 这些必须在 setuid 之前做chown 需要 rootchmod 也只有属主或 root 能改。 // 这些必须在 setuid 之前做chown 需要 rootchmod 也只有属主或 root 能改。
let cli_dir = config::cli_dir(); let cli_base_dir = config::cli_base_dir();
if cli_dir.exists() { if cli_base_dir.exists() {
let _ = chown_recursive(&cli_dir, uid, gid); let _ = chown_recursive(&cli_base_dir, uid, gid);
let _ = tighten_perms(&cli_dir); let _ = tighten_perms(&cli_base_dir);
} }
// 设置 umask让后续 create 出来的文件/目录默认是 0600 / 0700。 // 设置 umask让后续 create 出来的文件/目录默认是 0600 / 0700。
unsafe { libc::umask(0o077); } unsafe {
libc::umask(0o077);
}
// 必须先 setgid 再 setuid一旦 uid 降下来就没法再改 gid 了。 // 必须先 setgid 再 setuid一旦 uid 降下来就没法再改 gid 了。
unsafe { unsafe {
@ -175,8 +333,9 @@ fn drop_privileges_if_sudo() -> Result<()> {
Ok(()) Ok(())
} }
fn chown_one(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> { fn chown_one(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> {
let c = CString::new(path.as_os_str().as_bytes()) let c = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL"))?; std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL")
})?;
if unsafe { libc::chown(c.as_ptr(), uid, gid) } != 0 { if unsafe { libc::chown(c.as_ptr(), uid, gid) } != 0 {
return Err(std::io::Error::last_os_error()); return Err(std::io::Error::last_os_error());
} }
@ -201,6 +360,9 @@ fn drop_privileges_if_sudo() -> Result<()> {
} }
fn find_or_create_config_path() -> std::path::PathBuf { fn find_or_create_config_path() -> std::path::PathBuf {
if config::profile_is_active() {
return config::cli_dir().join("config.json");
}
// 如果当前工作目录或可执行文件目录已有 config.json沿用它支持便携模式 // 如果当前工作目录或可执行文件目录已有 config.json沿用它支持便携模式
if let Ok(cwd) = std::env::current_dir() { if let Ok(cwd) = std::env::current_dir() {
let p = cwd.join("config.json"); let p = cwd.join("config.json");

View File

@ -22,11 +22,15 @@ pub mod unread;
use self::output::OutputOpts; use self::output::OutputOpts;
use anyhow::Result; use anyhow::Result;
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use std::path::PathBuf;
/// wx — 微信本地数据 CLI /// wx — 微信本地数据 CLI
#[derive(Parser)] #[derive(Parser)]
#[command(name = "wx", version = env!("CARGO_PKG_VERSION"), about = "wx — 微信本地数据 CLI")] #[command(name = "wx", version = env!("CARGO_PKG_VERSION"), about = "wx — 微信本地数据 CLI")]
pub struct Cli { pub struct Cli {
/// 使用独立 profile配置、keys、daemon 和缓存均隔离)
#[arg(long, global = true)]
profile: Option<String>,
/// 返回更重的 freshness/source 元数据(如 per-shard latest、cache modes /// 返回更重的 freshness/source 元数据(如 per-shard latest、cache modes
#[arg(long, global = true)] #[arg(long, global = true)]
with_meta: bool, with_meta: bool,
@ -44,6 +48,18 @@ enum Commands {
/// 强制重新扫描(覆盖已有配置) /// 强制重新扫描(覆盖已有配置)
#[arg(long)] #[arg(long)]
force: bool, force: bool,
/// 显式指定微信 db_storage 目录
#[arg(long, value_name = "PATH")]
db_dir: Option<PathBuf>,
/// macOS: 指定微信 app bundle 路径,例如 /Applications/WeChat2.app
#[arg(long, value_name = "APP")]
app: Option<PathBuf>,
/// macOS: 指定 bundle id例如 com.tencent.xinWeChat2
#[arg(long, value_name = "ID")]
bundle_id: Option<String>,
/// 指定进程名(默认 macOS: WeChat, Windows: Weixin.exe, Linux: wechat
#[arg(long, value_name = "NAME")]
wechat_process: Option<String>,
}, },
/// 列出最近会话 /// 列出最近会话
Sessions { Sessions {
@ -342,10 +358,17 @@ pub fn run() {
} }
fn dispatch(cli: Cli) -> Result<()> { fn dispatch(cli: Cli) -> Result<()> {
crate::config::activate_profile(cli.profile.as_deref())?;
let base_with_meta = cli.with_meta; let base_with_meta = cli.with_meta;
let base_debug_source = cli.debug_source; let base_debug_source = cli.debug_source;
match cli.command { match cli.command {
Commands::Init { force } => init::cmd_init(force), Commands::Init {
force,
db_dir,
app,
bundle_id,
wechat_process,
} => init::cmd_init(force, db_dir, app, bundle_id, wechat_process),
Commands::Sessions { limit, json } => sessions::cmd_sessions( Commands::Sessions { limit, json } => sessions::cmd_sessions(
limit, limit,
OutputOpts { OutputOpts {

View File

@ -1,14 +1,12 @@
use super::output::{emit_warnings, print_response, OutputOpts}; use super::output::{emit_warnings, print_response, OutputOpts};
use super::transport; use super::transport;
use crate::config;
use crate::ipc::Request; use crate::ipc::Request;
use anyhow::Result; use anyhow::Result;
use std::collections::HashMap; use std::collections::HashMap;
fn state_file() -> std::path::PathBuf { fn state_file() -> std::path::PathBuf {
dirs::home_dir() config::cli_dir().join("last_check.json")
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".wx-cli")
.join("last_check.json")
} }
/// 加载上次的 per-session 时间戳快照 /// 加载上次的 per-session 时间戳快照

View File

@ -2,6 +2,8 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
const PROFILE_ENV: &str = "WX_PROFILE";
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config { pub struct Config {
pub db_dir: PathBuf, pub db_dir: PathBuf,
@ -9,6 +11,10 @@ pub struct Config {
pub decrypted_dir: PathBuf, pub decrypted_dir: PathBuf,
#[serde(default)] #[serde(default)]
pub wechat_process: String, pub wechat_process: String,
#[serde(default)]
pub bundle_id: Option<String>,
#[serde(default)]
pub app_path: Option<PathBuf>,
} }
/// 从当前工作目录 / <exe_dir> / $HOME/.wx-cli 加载配置 /// 从当前工作目录 / <exe_dir> / $HOME/.wx-cli 加载配置
@ -58,16 +64,34 @@ pub fn load_config() -> Result<Config> {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or(default_wechat_process()) .unwrap_or(default_wechat_process())
.to_string(); .to_string();
let bundle_id = raw
.get("bundle_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let app_path = raw.get("app_path").and_then(|v| v.as_str()).map(|s| {
let p = PathBuf::from(s);
if p.is_absolute() {
p
} else {
base_dir.join(p)
}
});
Ok(Config { Ok(Config {
db_dir, db_dir,
keys_file, keys_file,
decrypted_dir, decrypted_dir,
wechat_process, wechat_process,
bundle_id,
app_path,
}) })
} }
fn find_config_file() -> Result<PathBuf> { fn find_config_file() -> Result<PathBuf> {
if active_profile().is_some() {
return Ok(cli_dir().join("config.json"));
}
let cwd_dir = std::env::current_dir().ok(); let cwd_dir = std::env::current_dir().ok();
let exe_dir = std::env::current_exe() let exe_dir = std::env::current_exe()
.ok() .ok()
@ -120,7 +144,59 @@ fn home_config_path(home_dir: &Path) -> PathBuf {
home_dir.join(".wx-cli").join("config.json") home_dir.join(".wx-cli").join("config.json")
} }
pub fn activate_profile(profile: Option<&str>) -> Result<()> {
match profile {
Some(profile) => {
validate_profile_name(profile)?;
std::env::set_var(PROFILE_ENV, profile);
}
None => {
std::env::remove_var(PROFILE_ENV);
}
}
Ok(())
}
pub fn active_profile() -> Option<String> {
std::env::var(PROFILE_ENV)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
pub fn profile_is_active() -> bool {
active_profile().is_some()
}
pub fn validate_profile_name(profile: &str) -> Result<()> {
if is_valid_profile_name(profile) {
Ok(())
} else {
anyhow::bail!(
"profile 只能包含 ASCII 字母、数字、点、下划线和短横线,且不能是 '.' 或 '..': {}",
profile
)
}
}
fn is_valid_profile_name(profile: &str) -> bool {
!profile.is_empty()
&& profile != "."
&& profile != ".."
&& profile
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
}
pub fn cli_dir() -> PathBuf { pub fn cli_dir() -> PathBuf {
let base = cli_base_dir();
match active_profile() {
Some(profile) => base.join("profiles").join(profile),
None => base,
}
}
pub fn cli_base_dir() -> PathBuf {
cli_home_dir().join(".wx-cli") cli_home_dir().join(".wx-cli")
} }
@ -224,15 +300,29 @@ fn default_wechat_process() -> &'static str {
} }
/// 自动检测微信 db_storage 目录 /// 自动检测微信 db_storage 目录
#[allow(dead_code)]
pub fn auto_detect_db_dir() -> Option<PathBuf> { pub fn auto_detect_db_dir() -> Option<PathBuf> {
detect_db_dir_impl() detect_db_dir_impl(None)
}
/// 自动检测指定 macOS bundle id 对应的微信 db_storage 目录。
///
/// 其他平台忽略 bundle_id保持原有自动检测逻辑。
pub fn auto_detect_db_dir_for_bundle(bundle_id: Option<&str>) -> Option<PathBuf> {
detect_db_dir_impl(bundle_id)
} }
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn detect_db_dir_impl() -> Option<PathBuf> { fn detect_db_dir_impl(bundle_id: Option<&str>) -> Option<PathBuf> {
let home = sudo_user_home_dir().or_else(dirs::home_dir)?; let home = sudo_user_home_dir().or_else(dirs::home_dir)?;
let bundle_id = bundle_id
.filter(|s| !s.trim().is_empty())
.unwrap_or("com.tencent.xinWeChat");
let base = home.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files"); let base = home
.join("Library/Containers")
.join(bundle_id)
.join("Data/Documents/xwechat_files");
if !base.exists() { if !base.exists() {
return None; return None;
} }
@ -253,8 +343,37 @@ fn detect_db_dir_impl() -> Option<PathBuf> {
candidates.into_iter().next_back() candidates.into_iter().next_back()
} }
#[cfg(target_os = "macos")]
pub fn macos_bundle_id_from_app(app_path: &Path) -> Option<String> {
let info = app_path.join("Contents/Info.plist");
let output = std::process::Command::new("/usr/libexec/PlistBuddy")
.args(["-c", "Print :CFBundleIdentifier"])
.arg(&info)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let bundle_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!bundle_id.is_empty()).then_some(bundle_id)
}
#[cfg(target_os = "macos")]
pub fn macos_bundle_id_from_db_dir(db_dir: &Path) -> Option<String> {
let parts: Vec<String> = db_dir
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
parts
.windows(5)
.find(|w| {
w[0] == "Containers" && w[2] == "Data" && w[3] == "Documents" && w[4] == "xwechat_files"
})
.map(|w| w[1].clone())
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn detect_db_dir_impl() -> Option<PathBuf> { fn detect_db_dir_impl(_bundle_id: Option<&str>) -> Option<PathBuf> {
let home = dirs::home_dir()?; let home = dirs::home_dir()?;
let sudo_home = sudo_user_home_dir(); let sudo_home = sudo_user_home_dir();
@ -308,7 +427,7 @@ fn latest_db_mtime(dir: &Path) -> Option<std::time::SystemTime> {
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
fn detect_db_dir_impl() -> Option<PathBuf> { fn detect_db_dir_impl(_bundle_id: Option<&str>) -> Option<PathBuf> {
let appdata = std::env::var("APPDATA").ok()?; let appdata = std::env::var("APPDATA").ok()?;
let config_dir = PathBuf::from(&appdata).join("Tencent/xwechat/config"); let config_dir = PathBuf::from(&appdata).join("Tencent/xwechat/config");
if !config_dir.exists() { if !config_dir.exists() {
@ -361,9 +480,7 @@ fn detect_db_dir_impl() -> Option<PathBuf> {
fn resolve_windows_data_root(content: &str) -> Option<PathBuf> { fn resolve_windows_data_root(content: &str) -> Option<PathBuf> {
let trimmed = content.trim(); let trimmed = content.trim();
// Strip an optional trailing slash so `MyDocument:\` and `MyDocument:/` also match. // Strip an optional trailing slash so `MyDocument:\` and `MyDocument:/` also match.
let stripped = trimmed let stripped = trimmed.strip_suffix(['\\', '/']).unwrap_or(trimmed);
.strip_suffix(['\\', '/'])
.unwrap_or(trimmed);
if stripped.eq_ignore_ascii_case("MyDocument:") { if stripped.eq_ignore_ascii_case("MyDocument:") {
return known_documents_dir(); return known_documents_dir();
} }
@ -376,9 +493,7 @@ fn known_documents_dir() -> Option<PathBuf> {
use std::os::windows::ffi::OsStringExt; use std::os::windows::ffi::OsStringExt;
use windows::Win32::Foundation::HANDLE; use windows::Win32::Foundation::HANDLE;
use windows::Win32::System::Com::CoTaskMemFree; use windows::Win32::System::Com::CoTaskMemFree;
use windows::Win32::UI::Shell::{ use windows::Win32::UI::Shell::{FOLDERID_Documents, SHGetKnownFolderPath, KF_FLAG_DEFAULT};
FOLDERID_Documents, SHGetKnownFolderPath, KF_FLAG_DEFAULT,
};
// SAFETY: standard Win32 known-folder API. SHGetKnownFolderPath either returns // SAFETY: standard Win32 known-folder API. SHGetKnownFolderPath either returns
// a heap-allocated PWSTR that the caller must free with CoTaskMemFree, or an // a heap-allocated PWSTR that the caller must free with CoTaskMemFree, or an
@ -409,7 +524,7 @@ fn known_documents_dir() -> Option<PathBuf> {
} }
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn detect_db_dir_impl() -> Option<PathBuf> { fn detect_db_dir_impl(_bundle_id: Option<&str>) -> Option<PathBuf> {
None None
} }
@ -480,6 +595,28 @@ mod tests {
assert_eq!(path, cwd.join("config.json")); assert_eq!(path, cwd.join("config.json"));
} }
#[test]
fn profile_name_validation_allows_safe_names_only() {
for name in ["main", "work-2", "wx_2", "com.tencent.xinWeChat2"] {
assert!(super::is_valid_profile_name(name), "{name}");
}
for name in ["", ".", "..", "../x", "wx/2", "中文", "wx 2"] {
assert!(!super::is_valid_profile_name(name), "{name}");
}
}
#[cfg(target_os = "macos")]
#[test]
fn macos_bundle_id_from_db_dir_reads_container_component() {
let path = PathBuf::from(
"/Users/alice/Library/Containers/com.tencent.xinWeChat2/Data/Documents/xwechat_files/wxid_x/db_storage",
);
assert_eq!(
super::macos_bundle_id_from_db_dir(&path).as_deref(),
Some("com.tencent.xinWeChat2")
);
}
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[test] #[test]
fn resolve_windows_data_root_passes_through_absolute_path() { fn resolve_windows_data_root_passes_through_absolute_path() {
@ -493,7 +630,12 @@ mod tests {
// Should match the keyword exactly (case-insensitive, with or without trailing slash) // Should match the keyword exactly (case-insensitive, with or without trailing slash)
// and resolve to a non-empty Documents path via SHGetKnownFolderPath. // and resolve to a non-empty Documents path via SHGetKnownFolderPath.
let docs = known_documents_dir().expect("Documents known folder must resolve"); let docs = known_documents_dir().expect("Documents known folder must resolve");
for keyword in ["MyDocument:", "mydocument:", "MyDocument:\\", "MyDocument:/"] { for keyword in [
"MyDocument:",
"mydocument:",
"MyDocument:\\",
"MyDocument:/",
] {
let resolved = resolve_windows_data_root(keyword) let resolved = resolve_windows_data_root(keyword)
.unwrap_or_else(|| panic!("keyword {keyword:?} should resolve")); .unwrap_or_else(|| panic!("keyword {keyword:?} should resolve"));
assert_eq!(resolved, docs, "keyword {keyword:?}"); assert_eq!(resolved, docs, "keyword {keyword:?}");

View File

@ -7,13 +7,17 @@ use anyhow::{Context, Result};
use std::io::{Read, Seek, SeekFrom}; use std::io::{Read, Seek, SeekFrom};
use std::path::Path; use std::path::Path;
use super::{collect_db_salts, KeyEntry}; use super::{collect_db_salts, KeyEntry, ScanOptions};
const HEX_PATTERN_LEN: usize = 96; const HEX_PATTERN_LEN: usize = 96;
const CHUNK_SIZE: usize = 2 * 1024 * 1024; const CHUNK_SIZE: usize = 2 * 1024 * 1024;
/// 查找 WeChat 进程 PID /// 查找 WeChat 进程 PID
fn find_wechat_pid() -> Option<u32> { fn find_wechat_pid(process_name: Option<&str>) -> Option<u32> {
let target = process_name
.filter(|s| !s.trim().is_empty())
.unwrap_or("wechat")
.to_lowercase();
let proc_dir = std::fs::read_dir("/proc").ok()?; let proc_dir = std::fs::read_dir("/proc").ok()?;
for entry in proc_dir.flatten() { for entry in proc_dir.flatten() {
let name = entry.file_name(); let name = entry.file_name();
@ -25,7 +29,8 @@ fn find_wechat_pid() -> Option<u32> {
let comm_path = format!("/proc/{}/comm", name_str); let comm_path = format!("/proc/{}/comm", name_str);
if let Ok(comm) = std::fs::read_to_string(&comm_path) { if let Ok(comm) = std::fs::read_to_string(&comm_path) {
let comm = comm.trim().to_lowercase(); let comm = comm.trim().to_lowercase();
if comm == "wechat" || comm == "weixin" { if comm == target || (process_name.is_none() && (comm == "wechat" || comm == "weixin"))
{
if let Ok(pid) = name_str.parse::<u32>() { if let Ok(pid) = name_str.parse::<u32>() {
return Some(pid); return Some(pid);
} }
@ -38,8 +43,8 @@ fn find_wechat_pid() -> Option<u32> {
/// 解析 /proc/<pid>/maps 文件,返回可读的内存区域 (start, end) /// 解析 /proc/<pid>/maps 文件,返回可读的内存区域 (start, end)
fn parse_maps(pid: u32) -> Result<Vec<(u64, u64)>> { fn parse_maps(pid: u32) -> Result<Vec<(u64, u64)>> {
let maps_path = format!("/proc/{}/maps", pid); let maps_path = format!("/proc/{}/maps", pid);
let content = std::fs::read_to_string(&maps_path) let content =
.with_context(|| format!("读取 {} 失败", maps_path))?; std::fs::read_to_string(&maps_path).with_context(|| format!("读取 {} 失败", maps_path))?;
let mut regions = Vec::new(); let mut regions = Vec::new();
for line in content.lines() { for line in content.lines() {
@ -67,8 +72,8 @@ fn parse_maps(pid: u32) -> Result<Vec<(u64, u64)>> {
Ok(regions) Ok(regions)
} }
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> { pub fn scan_keys(db_dir: &Path, opts: &ScanOptions) -> Result<Vec<KeyEntry>> {
let pid = find_wechat_pid() let pid = find_wechat_pid(opts.process_name.as_deref())
.context("找不到 WeChat 进程,请确认 WeChat 正在运行")?; .context("找不到 WeChat 进程,请确认 WeChat 正在运行")?;
eprintln!("WeChat PID: {}", pid); eprintln!("WeChat PID: {}", pid);
@ -107,12 +112,7 @@ pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> {
Ok(entries) Ok(entries)
} }
fn scan_region( fn scan_region(mem: &mut std::fs::File, start: u64, end: u64, results: &mut Vec<(String, String)>) {
mem: &mut std::fs::File,
start: u64,
end: u64,
results: &mut Vec<(String, String)>,
) {
let total_len = (end - start) as usize; let total_len = (end - start) as usize;
let overlap = HEX_PATTERN_LEN + 3; let overlap = HEX_PATTERN_LEN + 3;
let mut offset = 0usize; let mut offset = 0usize;
@ -172,10 +172,8 @@ fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
i += 1; i += 1;
continue; continue;
} }
let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64]) let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64]).to_lowercase();
.to_lowercase(); let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96]).to_lowercase();
let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96])
.to_lowercase();
let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex); let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex);
if !is_dup { if !is_dup {
results.push((key_hex, salt_hex)); results.push((key_hex, salt_hex));

View File

@ -10,9 +10,10 @@
/// 2. WeChat 需要进行 ad-hoc 签名 /// 2. WeChat 需要进行 ad-hoc 签名
/// 3. 在内存中搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥 /// 3. 在内存中搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use std::path::Path; use std::path::{Path, PathBuf};
use super::{collect_db_salts, KeyEntry}; use super::{collect_db_salts, KeyEntry, ScanOptions};
use crate::config;
// Mach 相关常量 // Mach 相关常量
const KERN_SUCCESS: i32 = 0; const KERN_SUCCESS: i32 = 0;
@ -77,18 +78,146 @@ extern "C" {
) -> kern_return_t; ) -> kern_return_t;
} }
/// 查找 WeChat 进程的 PID #[derive(Debug, Clone)]
fn find_wechat_pid() -> Option<libc::pid_t> { struct ProcessCandidate {
// 使用 pgrep -x WeChat 查找(与 C 版本一致) pid: libc::pid_t,
command: String,
app_path: Option<PathBuf>,
bundle_id: Option<String>,
}
/// 查找 WeChat 进程的 PID。
///
/// 双开时两个主进程都叫 `WeChat`,不能只用 `pgrep -x WeChat` 的第一行。
/// 这里用进程实际路径反推出 `.app` bundle再按 `--app` 或 `--bundle-id`
/// 精确选择目标实例。
fn find_wechat_process(opts: &ScanOptions) -> Result<ProcessCandidate> {
let process_name = opts
.process_name
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or("WeChat");
let output = std::process::Command::new("pgrep") let output = std::process::Command::new("pgrep")
.args(["-x", "WeChat"]) .args(["-x", process_name])
.output()
.with_context(|| format!("执行 pgrep -x {} 失败", process_name))?;
if !output.status.success() {
bail!("找不到 {} 进程,请确认微信正在运行", process_name);
}
let mut candidates = Vec::new();
for line in String::from_utf8_lossy(&output.stdout).lines() {
let Ok(pid) = line.trim().parse::<libc::pid_t>() else {
continue;
};
candidates.push(process_candidate(pid));
}
let mut matches: Vec<ProcessCandidate> = candidates
.iter()
.filter(|candidate| matches_filters(candidate, opts))
.cloned()
.collect();
if matches.len() == 1 {
return Ok(matches.remove(0));
}
let listing = describe_candidates(&candidates);
if matches.is_empty() {
bail!(
"找不到匹配目标的 WeChat 进程。\n\
\n{}\n\
--app / --bundle-id ",
listing
);
}
bail!(
"发现多个匹配的 WeChat 进程,无法安全选择。\n\
\n{}\n\
使 `wx init --app /Applications/WeChat2.app` \
`wx init --bundle-id com.tencent.xinWeChat2` ",
listing
)
}
fn process_candidate(pid: libc::pid_t) -> ProcessCandidate {
let command = process_command(pid).unwrap_or_default();
let app_path = app_path_from_command(&command);
let bundle_id = app_path
.as_deref()
.and_then(config::macos_bundle_id_from_app);
ProcessCandidate {
pid,
command,
app_path,
bundle_id,
}
}
fn process_command(pid: libc::pid_t) -> Option<String> {
let output = std::process::Command::new("ps")
.args(["-ww", "-p", &pid.to_string(), "-o", "comm="])
.output() .output()
.ok()?; .ok()?;
if !output.status.success() { if !output.status.success() {
return None; return None;
} }
let s = String::from_utf8_lossy(&output.stdout); Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
s.trim().parse().ok() }
fn app_path_from_command(command: &str) -> Option<PathBuf> {
let marker = ".app/";
let idx = command.find(marker)?;
Some(PathBuf::from(&command[..idx + ".app".len()]))
}
fn matches_filters(candidate: &ProcessCandidate, opts: &ScanOptions) -> bool {
if let Some(app_path) = opts.app_path.as_deref() {
let Some(candidate_app) = candidate.app_path.as_deref() else {
return false;
};
if !same_path(candidate_app, app_path) {
return false;
}
}
if let Some(bundle_id) = opts.bundle_id.as_deref() {
if candidate.bundle_id.as_deref() != Some(bundle_id) {
return false;
}
}
true
}
fn same_path(left: &Path, right: &Path) -> bool {
match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
(Ok(l), Ok(r)) => l == r,
_ => left == right,
}
}
fn describe_candidates(candidates: &[ProcessCandidate]) -> String {
if candidates.is_empty() {
return " (none)".to_string();
}
candidates
.iter()
.map(|c| {
format!(
" pid={} app={} bundle_id={} cmd={}",
c.pid,
c.app_path
.as_ref()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(unknown)".to_string()),
c.bundle_id.as_deref().unwrap_or("(unknown)"),
c.command
)
})
.collect::<Vec<_>>()
.join("\n")
} }
/// 判断字节是否是 ASCII 十六进制字符 /// 判断字节是否是 ASCII 十六进制字符
@ -97,10 +226,10 @@ fn is_hex_char(c: u8) -> bool {
c.is_ascii_hexdigit() c.is_ascii_hexdigit()
} }
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> { pub fn scan_keys(db_dir: &Path, opts: &ScanOptions) -> Result<Vec<KeyEntry>> {
// 1. 查找 WeChat PID // 1. 查找 WeChat PID
let pid = find_wechat_pid() let process = find_wechat_process(opts)?;
.context("找不到 WeChat 进程,请确认 WeChat 正在运行")?; let pid = process.pid;
eprintln!("WeChat PID: {}", pid); eprintln!("WeChat PID: {}", pid);
// 2. 获取 task port // 2. 获取 task port
@ -109,22 +238,32 @@ pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> {
let mut task: mach_port_t = 0; let mut task: mach_port_t = 0;
let kr = task_for_pid(mach_task_self(), pid, &mut task); let kr = task_for_pid(mach_task_self(), pid, &mut task);
if kr != KERN_SUCCESS { if kr != KERN_SUCCESS {
let app = opts
.app_path
.as_ref()
.or(process.app_path.as_ref())
.map(|p| p.display().to_string())
.unwrap_or_else(|| "/Applications/WeChat.app".to_string());
bail!( bail!(
"task_for_pid 失败 (kr={})。请按以下步骤修复:\n\ "task_for_pid 失败 (kr={})。请按以下步骤修复:\n\
\n\ \n\
1. WeChat \n\ 1. WeChat \n\
codesign --force --deep --sign - /Applications/WeChat.app\n\ codesign --force --deep --sign - {}\n\
\n\ \n\
2. WeChat\n\ 2. 退 WeChat\n\
killall WeChat && open /Applications/WeChat.app\n\ open {}\n\
\n\ \n\
3. root\n\ 3. root\n\
sudo wx init\n\ sudo wx init\n\
\n\ \n\
codesign \"signature in use\",先执行:\n\ codesign \"signature in use\",先执行:\n\
codesign --remove-signature /Applications/WeChat.app/Contents/Frameworks/vlc_plugins/librtp_mpeg4_plugin.dylib\n\ codesign --remove-signature {}/Contents/Frameworks/vlc_plugins/librtp_mpeg4_plugin.dylib\n\
codesign --force --deep --sign - /Applications/WeChat.app", codesign --force --deep --sign - {}",
kr kr,
app,
app,
app,
app
); );
} }
task task
@ -171,8 +310,14 @@ fn scan_memory(task: mach_port_t) -> Result<Vec<(String, String)>> {
loop { loop {
let mut size: mach_vm_size_t = 0; let mut size: mach_vm_size_t = 0;
let mut info = VmRegionBasicInfo64 { let mut info = VmRegionBasicInfo64 {
protection: 0, max_protection: 0, inheritance: 0, protection: 0,
shared: 0, reserved: 0, _offset: 0, behavior: 0, user_wired_count: 0, max_protection: 0,
inheritance: 0,
shared: 0,
reserved: 0,
_offset: 0,
behavior: 0,
user_wired_count: 0,
}; };
let mut info_count: mach_msg_type_number_t = info_count_expected; let mut info_count: mach_msg_type_number_t = info_count_expected;
let mut obj_name: mach_port_t = 0; let mut obj_name: mach_port_t = 0;
@ -228,15 +373,11 @@ fn scan_region(
// SAFETY: mach_vm_read 读取目标进程内存到内核缓冲区, // SAFETY: mach_vm_read 读取目标进程内存到内核缓冲区,
// 返回的 data 指针指向通过 vm_allocate 分配的内存, // 返回的 data 指针指向通过 vm_allocate 分配的内存,
// 必须用 mach_vm_deallocate 释放 // 必须用 mach_vm_deallocate 释放
let kr = unsafe { let kr = unsafe { mach_vm_read(task, ca, cs, &mut data, &mut dc) };
mach_vm_read(task, ca, cs, &mut data, &mut dc)
};
if kr == KERN_SUCCESS { if kr == KERN_SUCCESS {
// SAFETY: data 是 mach_vm_read 返回的有效指针dc 是字节数 // SAFETY: data 是 mach_vm_read 返回的有效指针dc 是字节数
let buf: &[u8] = unsafe { let buf: &[u8] = unsafe { std::slice::from_raw_parts(data as *const u8, dc as usize) };
std::slice::from_raw_parts(data as *const u8, dc as usize)
};
search_pattern(buf, results); search_pattern(buf, results);
@ -290,10 +431,8 @@ pub(crate) fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
} }
// 提取 key_hex 和 salt_hex统一转小写 // 提取 key_hex 和 salt_hex统一转小写
let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64]) let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64]).to_lowercase();
.to_lowercase(); let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96]).to_lowercase();
let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96])
.to_lowercase();
// 去重检查 // 去重检查
let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex); let is_dup = results.iter().any(|(k, s)| k == &key_hex && s == &salt_hex);
@ -308,6 +447,7 @@ pub(crate) fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::path::Path;
/// 构造一条合法的 x'<key><salt>' 模式字节串 /// 构造一条合法的 x'<key><salt>' 模式字节串
fn make_pattern(key: &[u8; 64], salt: &[u8; 32]) -> Vec<u8> { fn make_pattern(key: &[u8; 64], salt: &[u8; 32]) -> Vec<u8> {
@ -320,9 +460,15 @@ mod tests {
#[test] #[test]
fn test_is_hex_char_valid() { fn test_is_hex_char_valid() {
for c in b'0'..=b'9' { assert!(is_hex_char(c), "digit {}", c as char); } for c in b'0'..=b'9' {
for c in b'a'..=b'f' { assert!(is_hex_char(c), "lower {}", c as char); } assert!(is_hex_char(c), "digit {}", c as char);
for c in b'A'..=b'F' { assert!(is_hex_char(c), "upper {}", c as char); } }
for c in b'a'..=b'f' {
assert!(is_hex_char(c), "lower {}", c as char);
}
for c in b'A'..=b'F' {
assert!(is_hex_char(c), "upper {}", c as char);
}
} }
#[test] #[test]
@ -334,7 +480,7 @@ mod tests {
#[test] #[test]
fn test_search_pattern_basic() { fn test_search_pattern_basic() {
let key = [b'a'; 64]; let key = [b'a'; 64];
let salt = [b'b'; 32]; let salt = [b'b'; 32];
let buf = make_pattern(&key, &salt); let buf = make_pattern(&key, &salt);
let mut results = Vec::new(); let mut results = Vec::new();
@ -347,7 +493,7 @@ mod tests {
#[test] #[test]
fn test_search_pattern_uppercase_lowercased() { fn test_search_pattern_uppercase_lowercased() {
// 大写十六进制字符应被统一转为小写 // 大写十六进制字符应被统一转为小写
let key = [b'A'; 64]; let key = [b'A'; 64];
let salt = [b'B'; 32]; let salt = [b'B'; 32];
let buf = make_pattern(&key, &salt); let buf = make_pattern(&key, &salt);
let mut results = Vec::new(); let mut results = Vec::new();
@ -383,7 +529,7 @@ mod tests {
#[test] #[test]
fn test_search_pattern_dedup() { fn test_search_pattern_dedup() {
// 相同模式出现两次 → 只保留一条 // 相同模式出现两次 → 只保留一条
let key = [b'1'; 64]; let key = [b'1'; 64];
let salt = [b'2'; 32]; let salt = [b'2'; 32];
let pattern = make_pattern(&key, &salt); let pattern = make_pattern(&key, &salt);
let mut buf = pattern.clone(); let mut buf = pattern.clone();
@ -396,8 +542,10 @@ mod tests {
#[test] #[test]
fn test_search_pattern_multiple_distinct() { fn test_search_pattern_multiple_distinct() {
// 两个不同的合法模式 → 各自独立捕获 // 两个不同的合法模式 → 各自独立捕获
let key1 = [b'a'; 64]; let salt1 = [b'b'; 32]; let key1 = [b'a'; 64];
let key2 = [b'c'; 64]; let salt2 = [b'd'; 32]; let salt1 = [b'b'; 32];
let key2 = [b'c'; 64];
let salt2 = [b'd'; 32];
let mut buf = make_pattern(&key1, &salt1); let mut buf = make_pattern(&key1, &salt1);
buf.extend_from_slice(&make_pattern(&key2, &salt2)); buf.extend_from_slice(&make_pattern(&key2, &salt2));
let mut results = Vec::new(); let mut results = Vec::new();
@ -412,7 +560,7 @@ mod tests {
fn test_search_pattern_embedded_in_garbage() { fn test_search_pattern_embedded_in_garbage() {
// 模式夹在垃圾字节中间,仍应找到 // 模式夹在垃圾字节中间,仍应找到
let mut buf = vec![0xFFu8; 50]; let mut buf = vec![0xFFu8; 50];
let key = [b'e'; 64]; let key = [b'e'; 64];
let salt = [b'f'; 32]; let salt = [b'f'; 32];
buf.extend_from_slice(&make_pattern(&key, &salt)); buf.extend_from_slice(&make_pattern(&key, &salt));
buf.extend_from_slice(&[0x00u8; 50]); buf.extend_from_slice(&[0x00u8; 50]);
@ -437,12 +585,36 @@ mod tests {
assert!(results.is_empty()); assert!(results.is_empty());
} }
#[test]
fn app_path_from_command_extracts_outer_bundle() {
let command = "/Applications/WeChat2.app/Contents/MacOS/WeChat";
assert_eq!(
app_path_from_command(command).as_deref(),
Some(Path::new("/Applications/WeChat2.app"))
);
}
#[test]
fn app_path_from_command_extracts_nested_helper_outer_bundle() {
let command =
"/Applications/WeChat2.app/Contents/MacOS/WeChatAppEx.app/Contents/MacOS/WeChatAppEx";
assert_eq!(
app_path_from_command(command).as_deref(),
Some(Path::new("/Applications/WeChat2.app"))
);
}
#[test] #[test]
fn test_search_pattern_real_hex_mix() { fn test_search_pattern_real_hex_mix() {
// 合法的混合大小写十六进制0-9, a-f, A-F // 合法的混合大小写十六进制0-9, a-f, A-F
let mut key = [b'0'; 64]; let mut key = [b'0'; 64];
for (i, c) in b"0123456789abcdefABCDEF0123456789abcdef0123456789abcdef01234567".iter().enumerate() { for (i, c) in b"0123456789abcdefABCDEF0123456789abcdef0123456789abcdef01234567"
if i < 64 { key[i] = *c; } .iter()
.enumerate()
{
if i < 64 {
key[i] = *c;
}
} }
let salt = [b'9'; 32]; let salt = [b'9'; 32];
let buf = make_pattern(&key, &salt); let buf = make_pattern(&key, &salt);
@ -450,6 +622,9 @@ mod tests {
search_pattern(&buf, &mut results); search_pattern(&buf, &mut results);
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
// 结果应全小写 // 结果应全小写
assert!(results[0].0.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())); assert!(results[0]
.0
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()));
} }
} }

View File

@ -1,11 +1,11 @@
use anyhow::Result; use anyhow::Result;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::Path; use std::path::{Path, PathBuf};
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
mod linux; mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
mod windows; mod windows;
@ -20,18 +20,26 @@ pub struct KeyEntry {
pub salt: String, pub salt: String,
} }
#[derive(Debug, Clone, Default)]
pub struct ScanOptions {
pub process_name: Option<String>,
pub bundle_id: Option<String>,
pub app_path: Option<PathBuf>,
}
/// 从进程内存中扫描所有 SQLCipher 密钥 /// 从进程内存中扫描所有 SQLCipher 密钥
/// ///
/// 需要以 root/Administrator 权限运行 /// 需要以 root/Administrator 权限运行
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> { pub fn scan_keys(db_dir: &Path, opts: &ScanOptions) -> Result<Vec<KeyEntry>> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
return macos::scan_keys(db_dir); return macos::scan_keys(db_dir, opts);
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
return linux::scan_keys(db_dir); return linux::scan_keys(db_dir, opts);
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
return windows::scan_keys(db_dir); return windows::scan_keys(db_dir, opts);
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{ {
let _ = opts;
anyhow::bail!("当前平台不支持自动密钥扫描") anyhow::bail!("当前平台不支持自动密钥扫描")
} }
} }
@ -92,7 +100,11 @@ mod tests {
fn make_temp_dir(label: &str) -> std::path::PathBuf { fn make_temp_dir(label: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir(); let mut p = std::env::temp_dir();
// 用 label + thread id 保证同进程内并发测试不冲突 // 用 label + thread id 保证同进程内并发测试不冲突
p.push(format!("wx-cli-test-{}-{:?}", label, std::thread::current().id())); p.push(format!(
"wx-cli-test-{}-{:?}",
label,
std::thread::current().id()
));
fs::create_dir_all(&p).unwrap(); fs::create_dir_all(&p).unwrap();
p p
} }
@ -118,8 +130,8 @@ mod tests {
let path = dir.join("enc.db"); let path = dir.join("enc.db");
// 非 SQLite 头 → 视为加密数据库,取前 16 字节作为 salt // 非 SQLite 头 → 视为加密数据库,取前 16 字节作为 salt
let header: [u8; 16] = [ let header: [u8; 16] = [
0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04, 0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0b, 0x0c,
]; ];
fs::write(&path, &header).unwrap(); fs::write(&path, &header).unwrap();
@ -214,7 +226,7 @@ mod tests {
fn test_collect_db_salts_ignores_non_db_extensions() { fn test_collect_db_salts_ignores_non_db_extensions() {
let dir = make_temp_dir("collect-ext"); let dir = make_temp_dir("collect-ext");
let header = [0xbbu8; 16]; let header = [0xbbu8; 16];
fs::write(dir.join("data.txt"), &header).unwrap(); fs::write(dir.join("data.txt"), &header).unwrap();
fs::write(dir.join("data.json"), &header).unwrap(); fs::write(dir.join("data.json"), &header).unwrap();
fs::write(dir.join("data.sqlite"), &header).unwrap(); fs::write(dir.join("data.sqlite"), &header).unwrap();

View File

@ -19,13 +19,16 @@ use windows::Win32::System::Memory::{
}; };
use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ}; use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ};
use super::{collect_db_salts, KeyEntry}; use super::{collect_db_salts, KeyEntry, ScanOptions};
const HEX_PATTERN_LEN: usize = 96; const HEX_PATTERN_LEN: usize = 96;
const CHUNK_SIZE: usize = 2 * 1024 * 1024; const CHUNK_SIZE: usize = 2 * 1024 * 1024;
/// 查找 Weixin.exe 进程 PID /// 查找 Weixin.exe 进程 PID
fn find_wechat_pid() -> Option<u32> { fn find_wechat_pid(process_name: Option<&str>) -> Option<u32> {
let target = process_name
.filter(|s| !s.trim().is_empty())
.unwrap_or("Weixin.exe");
// SAFETY: CreateToolhelp32Snapshot 标准 Windows API // SAFETY: CreateToolhelp32Snapshot 标准 Windows API
let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()? }; let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()? };
@ -43,7 +46,7 @@ fn find_wechat_pid() -> Option<u32> {
loop { loop {
let name = let name =
std::ffi::CStr::from_ptr(entry.szExeFile.as_ptr() as *const i8).to_string_lossy(); std::ffi::CStr::from_ptr(entry.szExeFile.as_ptr() as *const i8).to_string_lossy();
if name.eq_ignore_ascii_case("Weixin.exe") { if name.eq_ignore_ascii_case(target) {
let pid = entry.th32ProcessID; let pid = entry.th32ProcessID;
let _ = CloseHandle(snap); let _ = CloseHandle(snap);
return Some(pid); return Some(pid);
@ -57,8 +60,9 @@ fn find_wechat_pid() -> Option<u32> {
None None
} }
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> { pub fn scan_keys(db_dir: &Path, opts: &ScanOptions) -> Result<Vec<KeyEntry>> {
let pid = find_wechat_pid().context("找不到 Weixin.exe 进程,请确认微信正在运行")?; let pid = find_wechat_pid(opts.process_name.as_deref())
.context("找不到 Weixin.exe 进程,请确认微信正在运行")?;
eprintln!("WeChat PID: {}", pid); eprintln!("WeChat PID: {}", pid);
// SAFETY: OpenProcess 请求读取权限 // SAFETY: OpenProcess 请求读取权限