Files
wx-cli/src/cli/init.rs
T
jakevin 12740afb53 docs(macos): document codesign side-effect popup (#64)
* docs(macos): document codesign side-effect popup ("微信" 想访问其他 App 的数据)

After `codesign --force --deep --sign - /Applications/WeChat.app`, macOS
treats the re-signed WeChat as a different code identity from the
original. When WeChat then accesses its own container / cache / app-group
data (notably triggered when opening 公众号 articles), macOS fires the
"'微信' 想访问其他 App 的数据" popup.

This is a known side-effect of the current macOS invasive init path,
not a "wx-cli is reading other apps' data" issue and not a 公众号-only
problem — 公众号 is just a high-frequency trigger surface because of
WebView / cache access.

Document this in 3 places per agreed scope:
- README.md macOS init: add "副作用提示" callout linking to the guide
- docs/macos-permission-guide.md: new §六 with first-principles
  explanation, mitigation options, and long-term direction
- src/cli/init.rs: print a short macOS-only warning at the end of
  `wx init` so users see it right when they finish the invasive setup

* review: stop overstating the trade-off and condition the init warning

Per codex review on PR #64:

1. src/cli/init.rs warning was unconditional but the wording presumed
   the user had taken the ad-hoc re-sign path. If init goes through the
   tier 2 path (Apple-signed WeChat + GUI Terminal + Developer Tools TCC
   authorization), the warning would mis-fire. Reword conditionally and
   point to the GitHub URL of the doc instead of a relative path that
   release-binary / npm-installed users won't have on disk.

2. docs/macos-permission-guide.md §六 and the matching README callout
   said "restoring official WeChat = giving up macOS memory-scan". This
   contradicts the same guide's §一 实测表 which shows
   "Apple 签名 + 本机 Terminal sudo = ✅". Restoring the official
   signature only gives up the default re-sign path; the local-Terminal
   + Developer-Tools route still works on Apple-signed WeChat. Only
   SSH + Apple-signed WeChat actually requires re-signing.

* review (round 2): caveat empirical gap + drop emoji

Self-review found two issues both LGTMs missed:

1. The "tier 2 仍走通" claim (README + §六) leans on §一 实测表 row
   "Apple 签名 + 本机 Terminal sudo = ✅". But that data only covers
   macOS 10.15 (Catalina) and 11.1 (Big Sur). macOS 14/15 — the exact
   versions where the popup behavior originates — were never tested
   for that path in this project. Add an explicit caveat instead of
   silently extrapolating across major macOS versions.

2. `init.rs` warning used a ⚠️ emoji prefix, which violates the
   project + global "no emojis in files unless requested" rule. README
   and the rest of init.rs have no emoji. Replace with `[macOS]`.
2026-05-15 15:47:15 +08:00

217 lines
8.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use anyhow::{Context, Result};
use serde_json::json;
use std::collections::HashMap;
use crate::config;
use crate::scanner;
pub fn cmd_init(force: bool) -> Result<()> {
// 查找 config.json
let config_path = find_or_create_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(());
}
}
}
}
// Step 1: 检测 db_dir
println!("检测微信数据目录...");
let db_dir = config::auto_detect_db_dir()
.context("未能自动检测到微信数据目录\n请手动编辑 config.json 中的 db_dir 字段")?;
println!("找到数据目录: {}", db_dir.display());
// Step 2: 扫描密钥(需要 root/sudo)
println!("扫描加密密钥(需要 root 权限)...");
let entries = scanner::scan_keys(&db_dir)?;
// === 权限边界 ===
// 扫描完成后立即 drop 到调用用户身份,后续文件写入都是用户属主。
// 未来 daemon(由 `wx sessions` 以用户身份 fork)才能往 ~/.wx-cli/
// 写 socket/log/pid。
#[cfg(unix)]
drop_privileges_if_sudo()?;
// 确保父目录存在(如 ~/.wx-cli/),必须在任何写入之前
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
}
// Step 3: 保存 all_keys.json
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,
}));
}
std::fs::write(&keys_file_path, serde_json::to_string_pretty(&keys_json)?)
.context("写入 all_keys.json 失败")?;
println!("成功提取 {} 个数据库密钥", entries.len());
println!("密钥已保存: {}", keys_file_path.display());
// Step 4: 保存 config.json
let mut cfg = HashMap::new();
// 读取已有配置
if config_path.exists() {
if let Ok(c) = std::fs::read_to_string(&config_path) {
if let Ok(v) = serde_json::from_str::<HashMap<String, serde_json::Value>>(&c) {
for (k, val) in v {
cfg.insert(k, val);
}
}
}
}
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"));
std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?)
.context("写入 config.json 失败")?;
println!("配置已保存: {}", config_path.display());
// init 之后必须停掉旧 daemon(它用的是旧 config),下次调用会自动重启
let _ = crate::cli::transport::stop_daemon();
println!("初始化完成,可以使用 wx sessions / wx history 等命令了");
#[cfg(target_os = "macos")]
{
eprintln!();
eprintln!("[macOS] 副作用提示:");
eprintln!(" 如果你是通过对 /Applications/WeChat.app 做 ad-hoc 重签来让 init 走通的,");
eprintln!(" 之后 macOS 可能弹 \"微信\" 想访问其他 App 的数据(在微信里打开公众号文章");
eprintln!(" 时尤其常见)。这是 ad-hoc 重签后 WeChat 的 code identity 变了导致的,");
eprintln!(" 不是 wx-cli 在读其他 App 数据。");
eprintln!(" 完整说明:https://github.com/jackwener/wx-cli/blob/main/docs/macos-permission-guide.md#六微信-想访问其他-app-的数据-弹窗");
eprintln!(" (如果你的 WeChat 仍是 Apple 官方签名、init 是靠 GUI Terminal + 开发者工具");
eprintln!(" 授权走通的,则不会出现这个弹窗,可以忽略本提示。)");
}
Ok(())
}
/// 如果当前以 root 身份运行且是通过 sudo 启动的,drop 到调用用户身份,
/// 并迁移旧版本遗留的 root 属主 `~/.wx-cli/`。
///
/// 只影响本进程;daemon(后续 fork)会继承调用用户身份。
#[cfg(unix)]
fn drop_privileges_if_sudo() -> Result<()> {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
// 当前不是 root(用户直接以非 root 跑的 `wx init`)→ 什么都不做
if unsafe { libc::geteuid() } != 0 {
return Ok(());
}
let sudo_uid: Option<u32> = std::env::var("SUDO_UID").ok().and_then(|s| s.parse().ok());
let sudo_gid: Option<u32> = std::env::var("SUDO_GID").ok().and_then(|s| s.parse().ok());
let (uid, gid) = match (sudo_uid, sudo_gid) {
(Some(u), Some(g)) if u != 0 => (u, g),
// 直接以 root 登陆(非 sudo),没有"调用用户"可还原 → 保持 root
_ => return Ok(()),
};
// 迁移旧版本遗留:如果 ~/.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);
}
// 设置 umask,让后续 create 出来的文件/目录默认是 0600 / 0700。
unsafe { libc::umask(0o077); }
// 必须先 setgid 再 setuid:一旦 uid 降下来就没法再改 gid 了。
unsafe {
if libc::setgid(gid) != 0 {
anyhow::bail!("setgid({}) 失败: {}", gid, std::io::Error::last_os_error());
}
if libc::setuid(uid) != 0 {
anyhow::bail!("setuid({}) 失败: {}", uid, std::io::Error::last_os_error());
}
}
// chown 递归实现
fn chown_recursive(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> {
chown_one(path, uid, gid)?;
let md = std::fs::symlink_metadata(path)?;
if md.is_dir() {
for entry in std::fs::read_dir(path)? {
chown_recursive(&entry?.path(), uid, gid)?;
}
}
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"))?;
if unsafe { libc::chown(c.as_ptr(), uid, gid) } != 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
/// 目录收紧到 0700,所有 *.json 文件(含 all_keys.json 这类 raw key)收紧到 0600。
fn tighten_perms(cli_dir: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(cli_dir, std::fs::Permissions::from_mode(0o700))?;
for entry in std::fs::read_dir(cli_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
}
Ok(())
}
Ok(())
}
fn find_or_create_config_path() -> std::path::PathBuf {
// 如果当前工作目录或可执行文件目录已有 config.json,沿用它(支持便携模式)
if let Ok(cwd) = std::env::current_dir() {
let p = cwd.join("config.json");
if p.exists() {
return p;
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let p = dir.join("config.json");
if p.exists() {
return p;
}
}
}
// 默认写入 ~/.wx-cli/config.json(与 load_config 的最终查找路径保持一致)
config::cli_dir().join("config.json")
}