mirror of https://github.com/jackwener/wx-cli.git
feat: support macOS WeChat profiles
parent
08af894594
commit
4028473ec4
25
README.md
25
README.md
|
|
@ -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/
|
||||||
|
|
|
||||||
248
src/cli/init.rs
248
src/cli/init.rs
|
|
@ -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 需要 root,chmod 也只有属主或 root 能改。
|
// 这些必须在 setuid 之前做:chown 需要 root,chmod 也只有属主或 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");
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 时间戳快照
|
||||||
|
|
|
||||||
168
src/config.rs
168
src/config.rs
|
|
@ -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:?}");
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 请求读取权限
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue