diff --git a/README.md b/README.md index 1c1c7b5..c0722e0 100644 --- a/README.md +++ b/README.md @@ -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` 时仍使用旧的 +`~/.wx-cli/`,保持兼容。 + --- ## 命令 @@ -326,6 +350,7 @@ wx (CLI) ──Unix socket──▶ wx-daemon (后台进程) ``` daemon 首次解密后将数据库和 mtime 持久化到 `~/.wx-cli/cache/`。重启后 mtime 未变则直接复用,无需重解密。 +如果使用了 `--profile`,对应状态位于 `~/.wx-cli/profiles//`。 ``` ~/.wx-cli/ diff --git a/SKILL.md b/SKILL.md index 61082fe..c06fc1c 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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` 时仍使用默认的 `~/.wx-cli/`。 + --- ## 常见问题 diff --git a/src/cli/init.rs b/src/cli/init.rs index ba406ab..b1cef45 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -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, + app_arg: Option, + bundle_id_arg: Option, + wechat_process_arg: Option, +) -> 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::(&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 示例: \\xwechat_files\\\\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 /xwechat_files//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> { + let content = std::fs::read_to_string(config_path).ok()?; + serde_json::from_str::(&content) + .ok()? + .as_object() + .cloned() +} + +fn existing_string( + cfg: &Option>, + key: &str, +) -> Option { + 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, + app_path: Option<&std::path::Path>, + db_dir: Option<&std::path::Path>, +) -> Option { + 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 需要 root,chmod 也只有属主或 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"); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b4d6cf4..2e47624 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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, /// 返回更重的 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, + /// macOS: 指定微信 app bundle 路径,例如 /Applications/WeChat2.app + #[arg(long, value_name = "APP")] + app: Option, + /// macOS: 指定 bundle id,例如 com.tencent.xinWeChat2 + #[arg(long, value_name = "ID")] + bundle_id: Option, + /// 指定进程名(默认 macOS: WeChat, Windows: Weixin.exe, Linux: wechat) + #[arg(long, value_name = "NAME")] + wechat_process: Option, }, /// 列出最近会话 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 { diff --git a/src/cli/new_messages.rs b/src/cli/new_messages.rs index 5d73e4d..a6dfab0 100644 --- a/src/cli/new_messages.rs +++ b/src/cli/new_messages.rs @@ -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 时间戳快照 diff --git a/src/cli/transport.rs b/src/cli/transport.rs index 23c3e18..593da6a 100644 --- a/src/cli/transport.rs +++ b/src/cli/transport.rs @@ -257,7 +257,8 @@ fn ping_unix() -> Result { fn ping_windows() -> Result { use interprocess::local_socket::{prelude::*, GenericNamespaced, Stream}; - let name = "wx-cli-daemon".to_ns_name::()?; + let pipe_name = config::local_socket_name(); + let name = pipe_name.as_str().to_ns_name::()?; let stream = Stream::connect(name)?; let mut reader = BufReader::new(stream); @@ -468,7 +469,9 @@ fn send_unix(req: Request) -> Result { fn send_windows(req: Request) -> Result { 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::() .context("构造 pipe name 失败")?; let stream = Stream::connect(name).context("连接 daemon named pipe 失败")?; diff --git a/src/config.rs b/src/config.rs index ed91059..5e38bab 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, + #[serde(default)] + pub app_path: Option, } /// 从当前工作目录 / / $HOME/.wx-cli 加载配置 @@ -58,16 +64,34 @@ pub fn load_config() -> Result { .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 { + 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 { + 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 { - 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 { + detect_db_dir_impl(bundle_id) } #[cfg(target_os = "macos")] -fn detect_db_dir_impl() -> Option { +fn detect_db_dir_impl(bundle_id: Option<&str>) -> Option { let home = sudo_user_home_dir().or_else(dirs::home_dir)?; - - let base = home.join("Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files"); + let bundle_id = bundle_id + .filter(|s| !s.trim().is_empty()) + .unwrap_or("com.tencent.xinWeChat"); + + 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 { candidates.into_iter().next_back() } +#[cfg(target_os = "macos")] +pub fn macos_bundle_id_from_app(app_path: &Path) -> Option { + 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 { + let parts: Vec = 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 { +fn detect_db_dir_impl(_bundle_id: Option<&str>) -> Option { let home = dirs::home_dir()?; let sudo_home = sudo_user_home_dir(); @@ -308,7 +435,7 @@ fn latest_db_mtime(dir: &Path) -> Option { } #[cfg(target_os = "windows")] -fn detect_db_dir_impl() -> Option { +fn detect_db_dir_impl(_bundle_id: Option<&str>) -> Option { 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 { fn resolve_windows_data_root(content: &str) -> Option { 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 { 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 { } #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] -fn detect_db_dir_impl() -> Option { +fn detect_db_dir_impl(_bundle_id: Option<&str>) -> Option { 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:?}"); diff --git a/src/daemon/server.rs b/src/daemon/server.rs index 242edc1..1a4d440 100644 --- a/src/daemon/server.rs +++ b/src/daemon/server.rs @@ -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::()?; + // interprocess 的 GenericNamespaced 在 Windows 上会自动拼接 `\\.\pipe\` 前缀。 + let pipe_name = crate::config::local_socket_name(); + let name = pipe_name.as_str().to_ns_name::()?; 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?; diff --git a/src/scanner/linux.rs b/src/scanner/linux.rs index d6f4ee9..77c4342 100644 --- a/src/scanner/linux.rs +++ b/src/scanner/linux.rs @@ -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 { +fn find_wechat_pid(process_name: Option<&str>) -> Option { + 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 { 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::() { return Some(pid); } @@ -38,8 +43,8 @@ fn find_wechat_pid() -> Option { /// 解析 /proc//maps 文件,返回可读的内存区域 (start, end) fn parse_maps(pid: u32) -> Result> { 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> { Ok(regions) } -pub fn scan_keys(db_dir: &Path) -> Result> { - let pid = find_wechat_pid() +pub fn scan_keys(db_dir: &Path, opts: &ScanOptions) -> Result> { + 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> { 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)); diff --git a/src/scanner/macos.rs b/src/scanner/macos.rs index c22d3bb..76b4409 100644 --- a/src/scanner/macos.rs +++ b/src/scanner/macos.rs @@ -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 { - // 使用 pgrep -x WeChat 查找(与 C 版本一致) +#[derive(Debug, Clone)] +struct ProcessCandidate { + pid: libc::pid_t, + command: String, + app_path: Option, + bundle_id: Option, +} + +/// 查找 WeChat 进程的 PID。 +/// +/// 双开时两个主进程都叫 `WeChat`,不能只用 `pgrep -x WeChat` 的第一行。 +/// 这里用进程实际路径反推出 `.app` bundle,再按 `--app` 或 `--bundle-id` +/// 精确选择目标实例。 +fn find_wechat_process(opts: &ScanOptions) -> Result { + 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::() else { + continue; + }; + candidates.push(process_candidate(pid)); + } + + let mut matches: Vec = 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 { + 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 { + 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::>() + .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> { +pub fn scan_keys(db_dir: &Path, opts: &ScanOptions) -> Result> { // 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> { 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> { 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'' 模式字节串 fn make_pattern(key: &[u8; 64], salt: &[u8; 32]) -> Vec { @@ -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())); } } diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index 0d76a50..cfaca2f 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -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, + pub bundle_id: Option, + pub app_path: Option, +} + /// 从进程内存中扫描所有 SQLCipher 密钥 /// /// 需要以 root/Administrator 权限运行 -pub fn scan_keys(db_dir: &Path) -> Result> { +pub fn scan_keys(db_dir: &Path, opts: &ScanOptions) -> Result> { #[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(); diff --git a/src/scanner/windows.rs b/src/scanner/windows.rs index 391ba33..8ba5eae 100644 --- a/src/scanner/windows.rs +++ b/src/scanner/windows.rs @@ -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 { +fn find_wechat_pid(process_name: Option<&str>) -> Option { + 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 { 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 { None } -pub fn scan_keys(db_dir: &Path) -> Result> { - let pid = find_wechat_pid().context("找不到 Weixin.exe 进程,请确认微信正在运行")?; +pub fn scan_keys(db_dir: &Path, opts: &ScanOptions) -> Result> { + let pid = find_wechat_pid(opts.process_name.as_deref()) + .context("找不到 Weixin.exe 进程,请确认微信正在运行")?; eprintln!("WeChat PID: {}", pid); // SAFETY: OpenProcess 请求读取权限