pull/93/merge
Richard Liu 2026-06-01 12:27:29 +00:00 committed by GitHub
commit 01803e878d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 717 additions and 142 deletions

View File

@ -143,6 +143,30 @@ wx sessions
能看到最近会话即表示一切正常。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 未变则直接复用,无需重解密。
如果使用了 `--profile`,对应状态位于 `~/.wx-cli/profiles/<profile>/`
```
~/.wx-cli/

View File

@ -111,6 +111,29 @@ sudo wx init
初始化完成后,后续所有命令无需 `sudo`daemon 在首次调用时自动启动。
### 可选:多个微信 App / 多账号 profile
大多数用户只运行一个微信,继续使用上面的 `sudo wx init` 和普通命令即可。
只有当用户明确同时运行多个不同的微信 app bundle 时,才使用 `--profile` 隔离配置、密钥、daemon 和缓存。
示例:
```bash
sudo wx --profile main init --app /Applications/WeChat.app
sudo wx --profile second init --app /Applications/WeChat2.app
wx --profile main sessions
wx --profile second sessions
```
也可以按 bundle id 初始化:
```bash
sudo wx --profile second init --bundle-id com.tencent.xinWeChat2
```
`WeChat2.app` 只是双开场景示例;实际 app 路径和 bundle id 取决于用户自己的双开方式。
---
## 命令速查
@ -361,6 +384,8 @@ CHAT 参数支持昵称、备注名、微信 ID模糊匹配。不确定准确
└── cache/ # 解密后的数据库缓存
```
使用 `--profile` 时,数据位于 `~/.wx-cli/profiles/<profile>/`;未传 `--profile` 时仍使用默认的 `~/.wx-cli/`
---
## 常见问题

View File

@ -1,52 +1,128 @@
use anyhow::{Context, Result};
use serde_json::json;
use std::collections::HashMap;
use std::path::PathBuf;
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
let config_path = find_or_create_config_path();
let existing_cfg = read_config_map(&config_path);
// 检查是否已初始化
if !force && config_path.exists() {
if let Ok(content) = std::fs::read_to_string(&config_path) {
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&content) {
let db_dir = cfg.get("db_dir").and_then(|v| v.as_str()).unwrap_or("");
let keys_file = cfg.get("keys_file").and_then(|v| v.as_str()).unwrap_or("all_keys.json");
let keys_path = if std::path::Path::new(keys_file).is_absolute() {
std::path::PathBuf::from(keys_file)
} else {
config_path.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 selector_supplied = db_dir_arg.is_some()
|| app_arg.is_some()
|| bundle_id_arg.is_some()
|| wechat_process_arg.is_some();
let target_selector_supplied =
db_dir_arg.is_some() || app_arg.is_some() || bundle_id_arg.is_some();
if !force && !selector_supplied {
if let Some(cfg) = existing_cfg.as_ref() {
let db_dir = cfg.get("db_dir").and_then(|v| v.as_str()).unwrap_or("");
let keys_file = cfg
.get("keys_file")
.and_then(|v| v.as_str())
.unwrap_or("all_keys.json");
let keys_path = if std::path::Path::new(keys_file).is_absolute() {
std::path::PathBuf::from(keys_file)
} else {
config_path
.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
println!("检测微信数据目录...");
let db_dir = config::auto_detect_db_dir().with_context(|| format!(
"未能自动检测到微信数据目录\n\
db_dir :\n \
{}\n\
db_dir : <data_root>\\xwechat_files\\<wxid>\\db_storage",
config_path.display()
))?;
let db_dir = if let Some(db_dir) = db_dir_arg {
let db_dir = normalize_path(db_dir);
if !db_dir.is_dir() {
anyhow::bail!("指定的 db_storage 目录不存在: {}", db_dir.display());
}
db_dir
} 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());
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
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 到调用用户身份,后续文件写入都是用户属主。
@ -62,15 +138,19 @@ pub fn cmd_init(force: bool) -> Result<()> {
}
// 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("."))
.join("all_keys.json");
let mut keys_json = serde_json::Map::new();
for entry in &entries {
keys_json.insert(entry.db_name.clone(), json!({
"enc_key": entry.enc_key,
}));
keys_json.insert(
entry.db_name.clone(),
json!({
"enc_key": entry.enc_key,
}),
);
}
std::fs::write(&keys_file_path, serde_json::to_string_pretty(&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.entry("keys_file".into()).or_insert_with(|| json!("all_keys.json"));
cfg.entry("decrypted_dir".into()).or_insert_with(|| json!("decrypted"));
cfg.entry("keys_file".into())
.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)?)
.context("写入 config.json 失败")?;
@ -118,6 +207,73 @@ pub fn cmd_init(force: bool) -> Result<()> {
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 属主 `~/.wx-cli/`。
///
@ -144,14 +300,16 @@ fn drop_privileges_if_sudo() -> Result<()> {
// 迁移旧版本遗留:如果 ~/.wx-cli/ 已存在且属 root把它 chown 回调用用户,
// 顺便把 raw key 文件的权限也收紧到 0600旧版默认 0644世界可读等于泄露
// 这些必须在 setuid 之前做chown 需要 rootchmod 也只有属主或 root 能改。
let cli_dir = config::cli_dir();
if cli_dir.exists() {
let _ = chown_recursive(&cli_dir, uid, gid);
let _ = tighten_perms(&cli_dir);
let cli_base_dir = config::cli_base_dir();
if cli_base_dir.exists() {
let _ = chown_recursive(&cli_base_dir, uid, gid);
let _ = tighten_perms(&cli_base_dir);
}
// 设置 umask让后续 create 出来的文件/目录默认是 0600 / 0700。
unsafe { libc::umask(0o077); }
unsafe {
libc::umask(0o077);
}
// 必须先 setgid 再 setuid一旦 uid 降下来就没法再改 gid 了。
unsafe {
@ -175,8 +333,9 @@ fn drop_privileges_if_sudo() -> Result<()> {
Ok(())
}
fn chown_one(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> {
let c = CString::new(path.as_os_str().as_bytes())
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL"))?;
let c = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL")
})?;
if unsafe { libc::chown(c.as_ptr(), uid, gid) } != 0 {
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 {
if config::profile_is_active() {
return config::cli_dir().join("config.json");
}
// 如果当前工作目录或可执行文件目录已有 config.json沿用它支持便携模式
if let Ok(cwd) = std::env::current_dir() {
let p = cwd.join("config.json");

View File

@ -22,11 +22,15 @@ pub mod unread;
use self::output::OutputOpts;
use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
/// wx — 微信本地数据 CLI
#[derive(Parser)]
#[command(name = "wx", version = env!("CARGO_PKG_VERSION"), about = "wx — 微信本地数据 CLI")]
pub struct Cli {
/// 使用独立 profile配置、keys、daemon 和缓存均隔离)
#[arg(long, global = true)]
profile: Option<String>,
/// 返回更重的 freshness/source 元数据(如 per-shard latest、cache modes
#[arg(long, global = true)]
with_meta: bool,
@ -44,6 +48,18 @@ enum Commands {
/// 强制重新扫描(覆盖已有配置)
#[arg(long)]
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 {
@ -342,10 +358,17 @@ pub fn run() {
}
fn dispatch(cli: Cli) -> Result<()> {
crate::config::activate_profile(cli.profile.as_deref())?;
let base_with_meta = cli.with_meta;
let base_debug_source = cli.debug_source;
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(
limit,
OutputOpts {

View File

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

View File

@ -257,7 +257,8 @@ fn ping_unix() -> Result<bool> {
fn ping_windows() -> Result<bool> {
use interprocess::local_socket::{prelude::*, GenericNamespaced, Stream};
let name = "wx-cli-daemon".to_ns_name::<GenericNamespaced>()?;
let pipe_name = config::local_socket_name();
let name = pipe_name.as_str().to_ns_name::<GenericNamespaced>()?;
let stream = Stream::connect(name)?;
let mut reader = BufReader::new(stream);
@ -468,7 +469,9 @@ fn send_unix(req: Request) -> Result<Response> {
fn send_windows(req: Request) -> Result<Response> {
use interprocess::local_socket::{prelude::*, GenericNamespaced, Stream};
let name = "wx-cli-daemon"
let pipe_name = config::local_socket_name();
let name = pipe_name
.as_str()
.to_ns_name::<GenericNamespaced>()
.context("构造 pipe name 失败")?;
let stream = Stream::connect(name).context("连接 daemon named pipe 失败")?;

View File

@ -2,6 +2,8 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const PROFILE_ENV: &str = "WX_PROFILE";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub db_dir: PathBuf,
@ -9,6 +11,10 @@ pub struct Config {
pub decrypted_dir: PathBuf,
#[serde(default)]
pub wechat_process: String,
#[serde(default)]
pub bundle_id: Option<String>,
#[serde(default)]
pub app_path: Option<PathBuf>,
}
/// 从当前工作目录 / <exe_dir> / $HOME/.wx-cli 加载配置
@ -58,16 +64,34 @@ pub fn load_config() -> Result<Config> {
.and_then(|v| v.as_str())
.unwrap_or(default_wechat_process())
.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 {
db_dir,
keys_file,
decrypted_dir,
wechat_process,
bundle_id,
app_path,
})
}
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 exe_dir = std::env::current_exe()
.ok()
@ -120,7 +144,59 @@ fn home_config_path(home_dir: &Path) -> PathBuf {
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 {
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")
}
@ -173,6 +249,14 @@ pub fn log_path() -> PathBuf {
cli_dir().join("daemon.log")
}
#[allow(dead_code)]
pub fn local_socket_name() -> String {
match active_profile() {
Some(profile) => format!("wx-cli-daemon-{}", profile),
None => "wx-cli-daemon".to_string(),
}
}
pub fn cache_dir() -> PathBuf {
cli_dir().join("cache")
}
@ -224,15 +308,29 @@ fn default_wechat_process() -> &'static str {
}
/// 自动检测微信 db_storage 目录
#[allow(dead_code)]
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")]
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 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() {
return None;
}
@ -253,8 +351,37 @@ fn detect_db_dir_impl() -> Option<PathBuf> {
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")]
fn detect_db_dir_impl() -> Option<PathBuf> {
fn detect_db_dir_impl(_bundle_id: Option<&str>) -> Option<PathBuf> {
let home = dirs::home_dir()?;
let sudo_home = sudo_user_home_dir();
@ -308,7 +435,7 @@ fn latest_db_mtime(dir: &Path) -> Option<std::time::SystemTime> {
}
#[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 config_dir = PathBuf::from(&appdata).join("Tencent/xwechat/config");
if !config_dir.exists() {
@ -361,9 +488,7 @@ fn detect_db_dir_impl() -> Option<PathBuf> {
fn resolve_windows_data_root(content: &str) -> Option<PathBuf> {
let trimmed = content.trim();
// Strip an optional trailing slash so `MyDocument:\` and `MyDocument:/` also match.
let stripped = trimmed
.strip_suffix(['\\', '/'])
.unwrap_or(trimmed);
let stripped = trimmed.strip_suffix(['\\', '/']).unwrap_or(trimmed);
if stripped.eq_ignore_ascii_case("MyDocument:") {
return known_documents_dir();
}
@ -376,9 +501,7 @@ fn known_documents_dir() -> Option<PathBuf> {
use std::os::windows::ffi::OsStringExt;
use windows::Win32::Foundation::HANDLE;
use windows::Win32::System::Com::CoTaskMemFree;
use windows::Win32::UI::Shell::{
FOLDERID_Documents, SHGetKnownFolderPath, KF_FLAG_DEFAULT,
};
use windows::Win32::UI::Shell::{FOLDERID_Documents, SHGetKnownFolderPath, KF_FLAG_DEFAULT};
// SAFETY: standard Win32 known-folder API. SHGetKnownFolderPath either returns
// a heap-allocated PWSTR that the caller must free with CoTaskMemFree, or an
@ -409,7 +532,7 @@ fn known_documents_dir() -> Option<PathBuf> {
}
#[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
}
@ -480,6 +603,28 @@ mod tests {
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")]
#[test]
fn resolve_windows_data_root_passes_through_absolute_path() {
@ -493,7 +638,12 @@ mod tests {
// Should match the keyword exactly (case-insensitive, with or without trailing slash)
// and resolve to a non-empty Documents path via SHGetKnownFolderPath.
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)
.unwrap_or_else(|| panic!("keyword {keyword:?} should resolve"));
assert_eq!(resolved, docs, "keyword {keyword:?}");

View File

@ -84,13 +84,13 @@ async fn serve_windows(
) -> Result<()> {
use interprocess::local_socket::{tokio::prelude::*, GenericNamespaced, ListenerOptions};
// interprocess 的 GenericNamespaced 在 Windows 上会自动拼接 `\\.\pipe\` 前缀
// 这里必须传相对名client 端用 `\\.\pipe\wx-cli-daemon` 直接打开可以对上
let name = "wx-cli-daemon".to_ns_name::<GenericNamespaced>()?;
// interprocess 的 GenericNamespaced 在 Windows 上会自动拼接 `\\.\pipe\` 前缀
let pipe_name = crate::config::local_socket_name();
let name = pipe_name.as_str().to_ns_name::<GenericNamespaced>()?;
let opts = ListenerOptions::new().name(name);
let listener = opts.create_tokio()?;
eprintln!("[server] 监听 \\\\.\\pipe\\wx-cli-daemon");
eprintln!("[server] 监听 \\\\.\\pipe\\{}", pipe_name);
loop {
let conn = listener.accept().await?;

View File

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

View File

@ -10,9 +10,10 @@
/// 2. WeChat 需要进行 ad-hoc 签名
/// 3. 在内存中搜索 x'<64hex><32hex>' 格式的 SQLCipher 密钥
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 相关常量
const KERN_SUCCESS: i32 = 0;
@ -77,18 +78,146 @@ extern "C" {
) -> kern_return_t;
}
/// 查找 WeChat 进程的 PID
fn find_wechat_pid() -> Option<libc::pid_t> {
// 使用 pgrep -x WeChat 查找(与 C 版本一致)
#[derive(Debug, Clone)]
struct ProcessCandidate {
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")
.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()
.ok()?;
if !output.status.success() {
return None;
}
let s = String::from_utf8_lossy(&output.stdout);
s.trim().parse().ok()
Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
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 十六进制字符
@ -97,10 +226,10 @@ fn is_hex_char(c: u8) -> bool {
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
let pid = find_wechat_pid()
.context("找不到 WeChat 进程,请确认 WeChat 正在运行")?;
let process = find_wechat_process(opts)?;
let pid = process.pid;
eprintln!("WeChat PID: {}", pid);
// 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 kr = task_for_pid(mach_task_self(), pid, &mut task);
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!(
"task_for_pid 失败 (kr={})。请按以下步骤修复:\n\
\n\
1. WeChat \n\
codesign --force --deep --sign - /Applications/WeChat.app\n\
1. WeChat \n\
codesign --force --deep --sign - {}\n\
\n\
2. WeChat\n\
killall WeChat && open /Applications/WeChat.app\n\
2. 退 WeChat\n\
open {}\n\
\n\
3. root\n\
sudo wx init\n\
\n\
codesign \"signature in use\",先执行:\n\
codesign --remove-signature /Applications/WeChat.app/Contents/Frameworks/vlc_plugins/librtp_mpeg4_plugin.dylib\n\
codesign --force --deep --sign - /Applications/WeChat.app",
kr
codesign --remove-signature {}/Contents/Frameworks/vlc_plugins/librtp_mpeg4_plugin.dylib\n\
codesign --force --deep --sign - {}",
kr,
app,
app,
app,
app
);
}
task
@ -171,8 +310,14 @@ fn scan_memory(task: mach_port_t) -> Result<Vec<(String, String)>> {
loop {
let mut size: mach_vm_size_t = 0;
let mut info = VmRegionBasicInfo64 {
protection: 0, max_protection: 0, inheritance: 0,
shared: 0, reserved: 0, _offset: 0, behavior: 0, user_wired_count: 0,
protection: 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 obj_name: mach_port_t = 0;
@ -228,15 +373,11 @@ fn scan_region(
// SAFETY: mach_vm_read 读取目标进程内存到内核缓冲区,
// 返回的 data 指针指向通过 vm_allocate 分配的内存,
// 必须用 mach_vm_deallocate 释放
let kr = unsafe {
mach_vm_read(task, ca, cs, &mut data, &mut dc)
};
let kr = unsafe { mach_vm_read(task, ca, cs, &mut data, &mut dc) };
if kr == KERN_SUCCESS {
// SAFETY: data 是 mach_vm_read 返回的有效指针dc 是字节数
let buf: &[u8] = unsafe {
std::slice::from_raw_parts(data as *const u8, dc as usize)
};
let buf: &[u8] = unsafe { std::slice::from_raw_parts(data as *const u8, dc as usize) };
search_pattern(buf, results);
@ -290,10 +431,8 @@ pub(crate) fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
}
// 提取 key_hex 和 salt_hex统一转小写
let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64])
.to_lowercase();
let salt_hex = String::from_utf8_lossy(&buf[hex_start + 64..hex_start + 96])
.to_lowercase();
let key_hex = String::from_utf8_lossy(&buf[hex_start..hex_start + 64]).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);
@ -308,6 +447,7 @@ pub(crate) fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
/// 构造一条合法的 x'<key><salt>' 模式字节串
fn make_pattern(key: &[u8; 64], salt: &[u8; 32]) -> Vec<u8> {
@ -320,9 +460,15 @@ mod tests {
#[test]
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'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); }
for c in b'0'..=b'9' {
assert!(is_hex_char(c), "digit {}", 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]
@ -334,7 +480,7 @@ mod tests {
#[test]
fn test_search_pattern_basic() {
let key = [b'a'; 64];
let key = [b'a'; 64];
let salt = [b'b'; 32];
let buf = make_pattern(&key, &salt);
let mut results = Vec::new();
@ -347,7 +493,7 @@ mod tests {
#[test]
fn test_search_pattern_uppercase_lowercased() {
// 大写十六进制字符应被统一转为小写
let key = [b'A'; 64];
let key = [b'A'; 64];
let salt = [b'B'; 32];
let buf = make_pattern(&key, &salt);
let mut results = Vec::new();
@ -383,7 +529,7 @@ mod tests {
#[test]
fn test_search_pattern_dedup() {
// 相同模式出现两次 → 只保留一条
let key = [b'1'; 64];
let key = [b'1'; 64];
let salt = [b'2'; 32];
let pattern = make_pattern(&key, &salt);
let mut buf = pattern.clone();
@ -396,8 +542,10 @@ mod tests {
#[test]
fn test_search_pattern_multiple_distinct() {
// 两个不同的合法模式 → 各自独立捕获
let key1 = [b'a'; 64]; let salt1 = [b'b'; 32];
let key2 = [b'c'; 64]; let salt2 = [b'd'; 32];
let key1 = [b'a'; 64];
let salt1 = [b'b'; 32];
let key2 = [b'c'; 64];
let salt2 = [b'd'; 32];
let mut buf = make_pattern(&key1, &salt1);
buf.extend_from_slice(&make_pattern(&key2, &salt2));
let mut results = Vec::new();
@ -412,7 +560,7 @@ mod tests {
fn test_search_pattern_embedded_in_garbage() {
// 模式夹在垃圾字节中间,仍应找到
let mut buf = vec![0xFFu8; 50];
let key = [b'e'; 64];
let key = [b'e'; 64];
let salt = [b'f'; 32];
buf.extend_from_slice(&make_pattern(&key, &salt));
buf.extend_from_slice(&[0x00u8; 50]);
@ -437,12 +585,36 @@ mod tests {
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]
fn test_search_pattern_real_hex_mix() {
// 合法的混合大小写十六进制0-9, a-f, A-F
let mut key = [b'0'; 64];
for (i, c) in b"0123456789abcdefABCDEF0123456789abcdef0123456789abcdef01234567".iter().enumerate() {
if i < 64 { key[i] = *c; }
for (i, c) in b"0123456789abcdefABCDEF0123456789abcdef0123456789abcdef01234567"
.iter()
.enumerate()
{
if i < 64 {
key[i] = *c;
}
}
let salt = [b'9'; 32];
let buf = make_pattern(&key, &salt);
@ -450,6 +622,9 @@ mod tests {
search_pattern(&buf, &mut results);
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 serde::{Deserialize, Serialize};
use std::path::Path;
use std::path::{Path, PathBuf};
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "windows")]
mod windows;
@ -20,18 +20,26 @@ pub struct KeyEntry {
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 密钥
///
/// 需要以 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")]
return macos::scan_keys(db_dir);
return macos::scan_keys(db_dir, opts);
#[cfg(target_os = "linux")]
return linux::scan_keys(db_dir);
return linux::scan_keys(db_dir, opts);
#[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")))]
{
let _ = opts;
anyhow::bail!("当前平台不支持自动密钥扫描")
}
}
@ -92,7 +100,11 @@ mod tests {
fn make_temp_dir(label: &str) -> std::path::PathBuf {
let mut p = std::env::temp_dir();
// 用 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();
p
}
@ -118,8 +130,8 @@ mod tests {
let path = dir.join("enc.db");
// 非 SQLite 头 → 视为加密数据库,取前 16 字节作为 salt
let header: [u8; 16] = [
0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04,
0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
0xde, 0xad, 0xbe, 0xef, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
0x0b, 0x0c,
];
fs::write(&path, &header).unwrap();
@ -214,7 +226,7 @@ mod tests {
fn test_collect_db_salts_ignores_non_db_extensions() {
let dir = make_temp_dir("collect-ext");
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.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 super::{collect_db_salts, KeyEntry};
use super::{collect_db_salts, KeyEntry, ScanOptions};
const HEX_PATTERN_LEN: usize = 96;
const CHUNK_SIZE: usize = 2 * 1024 * 1024;
/// 查找 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
let snap = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()? };
@ -43,7 +46,7 @@ fn find_wechat_pid() -> Option<u32> {
loop {
let name =
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 _ = CloseHandle(snap);
return Some(pid);
@ -57,8 +60,9 @@ fn find_wechat_pid() -> Option<u32> {
None
}
pub fn scan_keys(db_dir: &Path) -> Result<Vec<KeyEntry>> {
let pid = find_wechat_pid().context("找不到 Weixin.exe 进程,请确认微信正在运行")?;
pub fn scan_keys(db_dir: &Path, opts: &ScanOptions) -> Result<Vec<KeyEntry>> {
let pid = find_wechat_pid(opts.process_name.as_deref())
.context("找不到 Weixin.exe 进程,请确认微信正在运行")?;
eprintln!("WeChat PID: {}", pid);
// SAFETY: OpenProcess 请求读取权限