mirror of https://github.com/jackwener/wx-cli.git
Merge f6b5d74332 into 08af894594
commit
01803e878d
25
README.md
25
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>/`;未传 `--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/
|
||||
|
|
|
|||
25
SKILL.md
25
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>/`;未传 `--profile` 时仍使用默认的 `~/.wx-cli/`。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
|
|
|||
212
src/cli/init.rs
212
src/cli/init.rs
|
|
@ -1,27 +1,46 @@
|
|||
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 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_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("."))
|
||||
config_path
|
||||
.parent()
|
||||
.unwrap_or(std::path::Path::new("."))
|
||||
.join(keys_file)
|
||||
};
|
||||
if !db_dir.is_empty() && !db_dir.contains("your_wxid")
|
||||
if !db_dir.is_empty()
|
||||
&& !db_dir.contains("your_wxid")
|
||||
&& std::path::Path::new(db_dir).exists()
|
||||
&& keys_path.exists()
|
||||
{
|
||||
|
|
@ -31,22 +50,79 @@ pub fn cmd_init(force: bool) -> Result<()> {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
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!("检测微信数据目录...");
|
||||
let db_dir = config::auto_detect_db_dir().with_context(|| format!(
|
||||
"未能自动检测到微信数据目录\n\
|
||||
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\
|
||||
(文件不存在则首次保存后自动创建;db_dir 示例: <data_root>\\xwechat_files\\<wxid>\\db_storage)",
|
||||
或运行: 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!({
|
||||
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 需要 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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 时间戳快照
|
||||
|
|
|
|||
|
|
@ -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 失败")?;
|
||||
|
|
|
|||
176
src/config.rs
176
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<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:?}");
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -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();
|
||||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 请求读取权限
|
||||
|
|
|
|||
Loading…
Reference in New Issue