fix(init): --force 时停止 daemon 并清空解密缓存

在成功扫描密钥、drop 到用户身份之后,若使用 --force,先调用与
`wx daemon stop` 相同的 stop_daemon 逻辑,再删除并重建
~/.wx-cli/cache,避免 mtime 未变时仍复用旧密钥解出的缓存。

将停止 daemon 的逻辑提取到 transport::stop_daemon,供 init 与
daemon stop 子命令复用。

Co-authored-by: Cursor <cursoragent@cursor.com>
pull/26/head
shadow 2026-05-03 11:22:15 +08:00
parent 6659f48984
commit a179b50227
4 changed files with 154 additions and 65 deletions

View File

@ -125,6 +125,8 @@ sudo wx init
wx init wx init
``` ```
**重新扫描密钥**`sudo wx init --force` 或 `wx init --force`)时,会在写入新密钥前**停止正在运行的 wx-daemon**,并**清空 `~/.wx-cli/cache` 解密缓存**(含 `_mtimes.json`),避免仅因 mtime 未变而继续复用按旧密钥解出的缓存文件。
验证安装: 验证安装:
```bash ```bash

View File

@ -1,7 +1,7 @@
use anyhow::Result;
use crate::config;
use crate::cli::DaemonCommands;
use crate::cli::transport; use crate::cli::transport;
use crate::cli::DaemonCommands;
use crate::config;
use anyhow::Result;
pub fn cmd_daemon(cmd: DaemonCommands) -> Result<()> { pub fn cmd_daemon(cmd: DaemonCommands) -> Result<()> {
match cmd { match cmd {
@ -25,42 +25,13 @@ fn cmd_status() -> Result<()> {
} }
fn cmd_stop() -> Result<()> { fn cmd_stop() -> Result<()> {
let pid_path = config::pid_path(); match transport::stop_daemon()? {
if !pid_path.exists() { transport::StopDaemonOutcome::NoPidFile => println!("daemon 未运行"),
println!("daemon 未运行"); transport::StopDaemonOutcome::Stopped(pid) => println!("已停止 wx-daemon (PID {})", pid),
return Ok(()); transport::StopDaemonOutcome::StalePid(pid) => {
} println!("wx-daemon (PID {}) 已不在运行,清理残留文件", pid);
let pid_str = std::fs::read_to_string(&pid_path)?;
let pid: u32 = pid_str.trim().parse()
.map_err(|_| anyhow::anyhow!("PID 文件格式错误"))?;
#[cfg(unix)]
{
let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
if ret != 0 {
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
if errno == libc::ESRCH {
println!("wx-daemon (PID {}) 已不在运行,清理残留文件", pid);
} else {
anyhow::bail!("发送 SIGTERM 失败 (errno {})", errno);
}
} else {
println!("已停止 wx-daemon (PID {})", pid);
} }
} }
#[cfg(windows)]
{
std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.output()?;
println!("已停止 wx-daemon (PID {})", pid);
}
let _ = std::fs::remove_file(config::sock_path());
let _ = std::fs::remove_file(&pid_path);
Ok(()) Ok(())
} }
@ -89,19 +60,25 @@ fn cmd_logs(follow: bool, lines: usize) -> Result<()> {
file.read_to_string(&mut content)?; file.read_to_string(&mut content)?;
let all_lines: Vec<&str> = content.lines().collect(); let all_lines: Vec<&str> = content.lines().collect();
let show = &all_lines[all_lines.len().saturating_sub(lines)..]; let show = &all_lines[all_lines.len().saturating_sub(lines)..];
for line in show { println!("{}", line); } for line in show {
println!("{}", line);
}
loop { loop {
std::thread::sleep(std::time::Duration::from_millis(500)); std::thread::sleep(std::time::Duration::from_millis(500));
let mut buf = String::new(); let mut buf = String::new();
file.read_to_string(&mut buf)?; file.read_to_string(&mut buf)?;
if !buf.is_empty() { print!("{}", buf); } if !buf.is_empty() {
print!("{}", buf);
}
} }
} }
} else { } else {
let content = std::fs::read_to_string(&log_path)?; let content = std::fs::read_to_string(&log_path)?;
let all_lines: Vec<&str> = content.lines().collect(); let all_lines: Vec<&str> = content.lines().collect();
let show = &all_lines[all_lines.len().saturating_sub(lines)..]; let show = &all_lines[all_lines.len().saturating_sub(lines)..];
for line in show { println!("{}", line); } for line in show {
println!("{}", line);
}
} }
Ok(()) Ok(())

View File

@ -2,6 +2,7 @@ use anyhow::{Context, Result};
use serde_json::json; use serde_json::json;
use std::collections::HashMap; use std::collections::HashMap;
use crate::cli::transport::{stop_daemon, StopDaemonOutcome};
use crate::config; use crate::config;
use crate::scanner; use crate::scanner;
@ -14,14 +15,20 @@ pub fn cmd_init(force: bool) -> Result<()> {
if let Ok(content) = std::fs::read_to_string(&config_path) { if let Ok(content) = std::fs::read_to_string(&config_path) {
if let Ok(cfg) = serde_json::from_str::<serde_json::Value>(&content) { 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 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() { let keys_path = if std::path::Path::new(keys_file).is_absolute() {
std::path::PathBuf::from(keys_file) std::path::PathBuf::from(keys_file)
} else { } else {
config_path.parent().unwrap_or(std::path::Path::new(".")) config_path
.parent()
.unwrap_or(std::path::Path::new("."))
.join(keys_file) .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() && std::path::Path::new(db_dir).exists()
&& keys_path.exists() && keys_path.exists()
{ {
@ -50,6 +57,20 @@ pub fn cmd_init(force: bool) -> Result<()> {
#[cfg(unix)] #[cfg(unix)]
drop_privileges_if_sudo()?; drop_privileges_if_sudo()?;
// --force先停 daemon 并清空解密缓存,避免旧缓存与新密钥 mtime 一致仍被复用
if force {
println!("(--force) 停止 wx-daemon 并清空解密缓存…");
match stop_daemon()? {
StopDaemonOutcome::NoPidFile => {}
StopDaemonOutcome::Stopped(pid) => println!("已停止 wx-daemon (PID {})", pid),
StopDaemonOutcome::StalePid(pid) => {
println!("wx-daemon (PID {}) 已不在运行,已清理残留文件", pid);
}
}
clear_decrypt_cache()?;
println!("已清空 {}", config::cache_dir().display());
}
// 确保父目录存在(如 ~/.wx-cli/),必须在任何写入之前 // 确保父目录存在(如 ~/.wx-cli/),必须在任何写入之前
if let Some(parent) = config_path.parent() { if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent) std::fs::create_dir_all(parent)
@ -57,15 +78,19 @@ pub fn cmd_init(force: bool) -> Result<()> {
} }
// Step 3: 保存 all_keys.json // Step 3: 保存 all_keys.json
let keys_file_path = config_path.parent() let keys_file_path = config_path
.parent()
.unwrap_or(std::path::Path::new(".")) .unwrap_or(std::path::Path::new("."))
.join("all_keys.json"); .join("all_keys.json");
let mut keys_json = serde_json::Map::new(); let mut keys_json = serde_json::Map::new();
for entry in &entries { for entry in &entries {
keys_json.insert(entry.db_name.clone(), json!({ keys_json.insert(
"enc_key": entry.enc_key, entry.db_name.clone(),
})); json!({
"enc_key": entry.enc_key,
}),
);
} }
std::fs::write(&keys_file_path, serde_json::to_string_pretty(&keys_json)?) std::fs::write(&keys_file_path, serde_json::to_string_pretty(&keys_json)?)
.context("写入 all_keys.json 失败")?; .context("写入 all_keys.json 失败")?;
@ -85,8 +110,10 @@ pub fn cmd_init(force: bool) -> Result<()> {
} }
} }
cfg.insert("db_dir".into(), json!(db_dir.to_string_lossy())); cfg.insert("db_dir".into(), json!(db_dir.to_string_lossy()));
cfg.entry("keys_file".into()).or_insert_with(|| json!("all_keys.json")); cfg.entry("keys_file".into())
cfg.entry("decrypted_dir".into()).or_insert_with(|| json!("decrypted")); .or_insert_with(|| json!("all_keys.json"));
cfg.entry("decrypted_dir".into())
.or_insert_with(|| json!("decrypted"));
std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?) std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?)
.context("写入 config.json 失败")?; .context("写入 config.json 失败")?;
@ -96,6 +123,18 @@ pub fn cmd_init(force: bool) -> Result<()> {
Ok(()) Ok(())
} }
/// 删除 `~/.wx-cli/cache`(含 `_mtimes.json` 与已解密 DB并重建空目录。
fn clear_decrypt_cache() -> Result<()> {
let dir = config::cache_dir();
if dir.exists() {
std::fs::remove_dir_all(&dir)
.with_context(|| format!("删除解密缓存目录失败: {}", dir.display()))?;
}
std::fs::create_dir_all(&dir)
.with_context(|| format!("创建解密缓存目录失败: {}", dir.display()))?;
Ok(())
}
/// 如果当前以 root 身份运行且是通过 sudo 启动的drop 到调用用户身份, /// 如果当前以 root 身份运行且是通过 sudo 启动的drop 到调用用户身份,
/// 并迁移旧版本遗留的 root 属主 `~/.wx-cli/`。 /// 并迁移旧版本遗留的 root 属主 `~/.wx-cli/`。
/// ///
@ -129,7 +168,9 @@ fn drop_privileges_if_sudo() -> Result<()> {
} }
// 设置 umask让后续 create 出来的文件/目录默认是 0600 / 0700。 // 设置 umask让后续 create 出来的文件/目录默认是 0600 / 0700。
unsafe { libc::umask(0o077); } unsafe {
libc::umask(0o077);
}
// 必须先 setgid 再 setuid一旦 uid 降下来就没法再改 gid 了。 // 必须先 setgid 再 setuid一旦 uid 降下来就没法再改 gid 了。
unsafe { unsafe {
@ -153,8 +194,9 @@ fn drop_privileges_if_sudo() -> Result<()> {
Ok(()) Ok(())
} }
fn chown_one(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> { fn chown_one(path: &Path, uid: u32, gid: u32) -> std::io::Result<()> {
let c = CString::new(path.as_os_str().as_bytes()) let c = CString::new(path.as_os_str().as_bytes()).map_err(|_| {
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL"))?; std::io::Error::new(std::io::ErrorKind::InvalidInput, "path contains NUL")
})?;
if unsafe { libc::chown(c.as_ptr(), uid, gid) } != 0 { if unsafe { libc::chown(c.as_ptr(), uid, gid) } != 0 {
return Err(std::io::Error::last_os_error()); return Err(std::io::Error::last_os_error());
} }

View File

@ -52,6 +52,68 @@ pub fn is_alive() -> bool {
} }
} }
/// [`stop_daemon`] 的返回值,供 CLI 提示或 `wx init --force` 选用。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StopDaemonOutcome {
/// 无 `daemon.pid`
NoPidFile,
/// 已向进程发送停止信号并清理 pid/socket
Stopped(u32),
/// 进程已不存在,仅清理 pid/socket
StalePid(u32),
}
/// 停止 wx-daemon与 `wx daemon stop` 同一套逻辑)。
pub fn stop_daemon() -> Result<StopDaemonOutcome> {
let pid_path = config::pid_path();
if !pid_path.exists() {
return Ok(StopDaemonOutcome::NoPidFile);
}
let pid_str = std::fs::read_to_string(&pid_path)?;
let pid: u32 = pid_str
.trim()
.parse()
.map_err(|_| anyhow::anyhow!("PID 文件格式错误"))?;
#[cfg(unix)]
{
let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
if ret != 0 {
let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0);
if errno == libc::ESRCH {
let _ = std::fs::remove_file(config::sock_path());
let _ = std::fs::remove_file(&pid_path);
return Ok(StopDaemonOutcome::StalePid(pid));
}
anyhow::bail!("发送 SIGTERM 失败 (errno {})", errno);
}
let _ = std::fs::remove_file(config::sock_path());
let _ = std::fs::remove_file(&pid_path);
return Ok(StopDaemonOutcome::Stopped(pid));
}
#[cfg(windows)]
{
let out = std::process::Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.output();
let ok = matches!(&out, Ok(o) if o.status.success());
let _ = std::fs::remove_file(config::sock_path());
let _ = std::fs::remove_file(&pid_path);
return Ok(if ok {
StopDaemonOutcome::Stopped(pid)
} else {
StopDaemonOutcome::StalePid(pid)
});
}
#[cfg(not(any(unix, windows)))]
{
anyhow::bail!("当前平台不支持 stop_daemon");
}
}
/// 确保 daemon 运行,必要时自动启动 /// 确保 daemon 运行,必要时自动启动
pub fn ensure_daemon() -> Result<()> { pub fn ensure_daemon() -> Result<()> {
if is_alive() { if is_alive() {
@ -113,7 +175,8 @@ fn start_daemon() -> Result<()> {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
let (stdout_stdio, stderr_stdio) = std::fs::OpenOptions::new() let (stdout_stdio, stderr_stdio) = std::fs::OpenOptions::new()
.create(true).append(true) .create(true)
.append(true)
.open(&log_path) .open(&log_path)
.and_then(|f| f.try_clone().map(|g| (f, g))) .and_then(|f| f.try_clone().map(|g| (f, g)))
.map(|(f, g)| (std::process::Stdio::from(f), std::process::Stdio::from(g))) .map(|(f, g)| (std::process::Stdio::from(f), std::process::Stdio::from(g)))
@ -124,7 +187,12 @@ fn start_daemon() -> Result<()> {
.stdout(stdout_stdio) .stdout(stdout_stdio)
.stderr(stderr_stdio); .stderr(stderr_stdio);
// SAFETY: setsid() 在 fork 后的子进程中调用,使 daemon 脱离控制终端 // SAFETY: setsid() 在 fork 后的子进程中调用,使 daemon 脱离控制终端
unsafe { cmd.pre_exec(|| { libc::setsid(); Ok(()) }); } unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
let _ = cmd.spawn().context("无法启动 daemon 进程")?; let _ = cmd.spawn().context("无法启动 daemon 进程")?;
} }
@ -136,7 +204,8 @@ fn start_daemon() -> Result<()> {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
} }
let (stdout_stdio, stderr_stdio) = std::fs::OpenOptions::new() let (stdout_stdio, stderr_stdio) = std::fs::OpenOptions::new()
.create(true).append(true) .create(true)
.append(true)
.open(&log_path) .open(&log_path)
.and_then(|f| f.try_clone().map(|g| (f, g))) .and_then(|f| f.try_clone().map(|g| (f, g)))
.map(|(f, g)| (std::process::Stdio::from(f), std::process::Stdio::from(g))) .map(|(f, g)| (std::process::Stdio::from(f), std::process::Stdio::from(g)))
@ -189,10 +258,11 @@ pub fn send(req: Request) -> Result<Response> {
fn send_unix(req: Request) -> Result<Response> { fn send_unix(req: Request) -> Result<Response> {
use std::os::unix::net::UnixStream; use std::os::unix::net::UnixStream;
let sock_path = config::sock_path(); let sock_path = config::sock_path();
let mut stream = UnixStream::connect(&sock_path) let mut stream = UnixStream::connect(&sock_path).context("连接 daemon socket 失败")?;
.context("连接 daemon socket 失败")?;
stream.set_read_timeout(Some(Duration::from_secs(120))).ok(); stream.set_read_timeout(Some(Duration::from_secs(120))).ok();
stream.set_write_timeout(Some(Duration::from_secs(120))).ok(); stream
.set_write_timeout(Some(Duration::from_secs(120)))
.ok();
let req_str = serde_json::to_string(&req)? + "\n"; let req_str = serde_json::to_string(&req)? + "\n";
stream.write_all(req_str.as_bytes())?; stream.write_all(req_str.as_bytes())?;
@ -201,8 +271,7 @@ fn send_unix(req: Request) -> Result<Response> {
let mut reader = BufReader::new(&stream); let mut reader = BufReader::new(&stream);
reader.read_line(&mut line)?; reader.read_line(&mut line)?;
let resp: Response = serde_json::from_str(&line) let resp: Response = serde_json::from_str(&line).context("解析 daemon 响应失败")?;
.context("解析 daemon 响应失败")?;
if !resp.ok { if !resp.ok {
bail!("{}", resp.error.as_deref().unwrap_or("未知错误")); bail!("{}", resp.error.as_deref().unwrap_or("未知错误"));
@ -215,10 +284,10 @@ fn send_unix(req: Request) -> Result<Response> {
fn send_windows(req: Request) -> Result<Response> { fn send_windows(req: Request) -> Result<Response> {
use interprocess::local_socket::{prelude::*, GenericNamespaced, Stream}; use interprocess::local_socket::{prelude::*, GenericNamespaced, Stream};
let name = "wx-cli-daemon".to_ns_name::<GenericNamespaced>() let name = "wx-cli-daemon"
.to_ns_name::<GenericNamespaced>()
.context("构造 pipe name 失败")?; .context("构造 pipe name 失败")?;
let stream = Stream::connect(name) let stream = Stream::connect(name).context("连接 daemon named pipe 失败")?;
.context("连接 daemon named pipe 失败")?;
// interprocess::Stream 同时实现 Read + Write但需要拆分读写端 // interprocess::Stream 同时实现 Read + Write但需要拆分读写端
let mut reader = BufReader::new(stream); let mut reader = BufReader::new(stream);
@ -229,8 +298,7 @@ fn send_windows(req: Request) -> Result<Response> {
let mut line = String::new(); let mut line = String::new();
reader.read_line(&mut line)?; reader.read_line(&mut line)?;
let resp: Response = serde_json::from_str(&line) let resp: Response = serde_json::from_str(&line).context("解析 daemon 响应失败")?;
.context("解析 daemon 响应失败")?;
if !resp.ok { if !resp.ok {
bail!("{}", resp.error.as_deref().unwrap_or("未知错误")); bail!("{}", resp.error.as_deref().unwrap_or("未知错误"));