fix: 深度 review 修复 10 个 bug/问题

Critical & High:
- daemon 日志:启动时将 stdout/stderr 重定向到 ~/.wx-cli/daemon.log
  而非 /dev/null,使 wx daemon logs 真正可用
- q_history 找不到聊天时改为 bail! 而非 ok:true+error 字段,
  避免 CLI 静默返回空输出
- init 写 config.json 默认路径改为 ~/.wx-cli/config.json,
  避免写入系统 bin 目录(/usr/local/bin/config.json)
- LIKE 通配符:搜索关键词中的 %/_/\ 现在正确转义
- WAL 路径:改用 OsString.push 拼接 "-wal" 后缀,
  避免 display() 在非 UTF-8 路径上失效
- cmd_stop:检查 kill() 返回值,ESRCH 时给出明确提示

Performance & Code quality:
- full_decrypt:改为流式逐页读写,峰值内存从 2×文件大小降为 O(1)
- Regex:msg_table_re() 用 OnceLock 静态编译,避免热路径重复编译
- mtime_nanos:消除 daemon/mod.rs 与 cache.rs 的重复定义
- use super::super::cli::transport → use super::transport
- 删除未使用的 save_config、Request::to_json_line 死代码
pull/2/head
jackwener 2026-04-16 17:07:15 +08:00
parent dfd020a2b9
commit 7f869e7c3b
15 changed files with 104 additions and 74 deletions

View File

@ -1,6 +1,6 @@
use anyhow::Result;
use crate::ipc::Request;
use super::super::cli::transport;
use super::transport;
pub fn cmd_contacts(query: Option<String>, limit: usize, json: bool) -> Result<()> {
let req = Request::Contacts { query, limit };

View File

@ -37,8 +37,17 @@ fn cmd_stop() -> Result<()> {
#[cfg(unix)]
{
unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM); }
println!("已停止 wx-daemon (PID {})", pid);
let ret = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
if ret != 0 {
let errno = unsafe { *libc::__error() };
if errno == libc::ESRCH {
println!("wx-daemon (PID {}) 已不在运行,清理残留文件", pid);
} else {
anyhow::bail!("发送 SIGTERM 失败 (errno {})", errno);
}
} else {
println!("已停止 wx-daemon (PID {})", pid);
}
}
#[cfg(windows)]

View File

@ -1,6 +1,6 @@
use anyhow::Result;
use crate::ipc::Request;
use super::super::cli::transport;
use super::transport;
use super::history::{parse_time, parse_time_end};
pub fn cmd_export(

View File

@ -1,6 +1,6 @@
use anyhow::Result;
use crate::ipc::Request;
use super::super::cli::transport;
use super::transport;
pub fn cmd_history(
chat: String,

View File

@ -75,6 +75,11 @@ pub fn cmd_init(force: bool) -> Result<()> {
cfg.entry("keys_file".into()).or_insert_with(|| json!("all_keys.json"));
cfg.entry("decrypted_dir".into()).or_insert_with(|| json!("decrypted"));
// 确保父目录存在(如 ~/.wx-cli/
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("创建目录失败: {}", parent.display()))?;
}
std::fs::write(&config_path, serde_json::to_string_pretty(&cfg)?)
.context("写入 config.json 失败")?;
println!("配置已保存: {}", config_path.display());
@ -84,13 +89,21 @@ pub fn cmd_init(force: bool) -> Result<()> {
}
fn find_or_create_config_path() -> std::path::PathBuf {
// 优先使用可执行文件同目录
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
return dir.join("config.json");
// 如果当前工作目录或可执行文件目录已有 config.json沿用它支持便携模式
if let Ok(cwd) = std::env::current_dir() {
let p = cwd.join("config.json");
if p.exists() {
return p;
}
}
std::env::current_dir()
.unwrap_or_default()
.join("config.json")
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")
}

View File

@ -1,6 +1,6 @@
use anyhow::Result;
use crate::ipc::Request;
use super::super::cli::transport;
use super::transport;
use super::history::{parse_time, parse_time_end};
pub fn cmd_search(

View File

@ -1,6 +1,6 @@
use anyhow::Result;
use crate::ipc::Request;
use super::super::cli::transport;
use super::transport;
pub fn cmd_sessions(limit: usize, json: bool) -> Result<()> {
let resp = transport::send(Request::Sessions { limit })?;

View File

@ -67,11 +67,23 @@ fn start_daemon() -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
// 日志文件:~/.wx-cli/daemon.log
let log_path = config::log_path();
// 确保父目录存在
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let (stdout_stdio, stderr_stdio) = std::fs::OpenOptions::new()
.create(true).append(true)
.open(&log_path)
.and_then(|f| f.try_clone().map(|g| (f, g)))
.map(|(f, g)| (std::process::Stdio::from(f), std::process::Stdio::from(g)))
.unwrap_or_else(|_| (std::process::Stdio::null(), std::process::Stdio::null()));
let mut cmd = std::process::Command::new(&exe);
cmd.env("WX_DAEMON_MODE", "1")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
.stdout(stdout_stdio)
.stderr(stderr_stdio);
// SAFETY: setsid() 在 fork 后的子进程中调用,使 daemon 脱离控制终端
unsafe { cmd.pre_exec(|| { libc::setsid(); Ok(()) }); }
let _ = cmd.spawn().context("无法启动 daemon 进程")?;
@ -79,8 +91,15 @@ fn start_daemon() -> Result<()> {
#[cfg(windows)]
{
let log_file = std::fs::OpenOptions::new()
.create(true).append(true)
.open(config::log_path())
.ok()
.map(std::process::Stdio::from)
.unwrap_or_else(std::process::Stdio::null);
let _ = std::process::Command::new(&exe)
.env("WX_DAEMON_MODE", "1")
.stdout(log_file)
.creation_flags(0x00000008) // DETACHED_PROCESS
.spawn()
.context("无法启动 daemon 进程")?;

View File

@ -2,7 +2,7 @@ use anyhow::Result;
use std::io::BufRead;
use crate::ipc::Request;
use super::super::cli::transport;
use super::transport;
pub fn cmd_watch(chat: Option<String>, json: bool) -> Result<()> {
transport::ensure_daemon()?;

View File

@ -55,21 +55,6 @@ pub fn load_config() -> Result<Config> {
})
}
/// 保存配置到文件
pub fn save_config(config: &Config) -> Result<()> {
let config_path = find_config_file().unwrap_or_else(|_| {
std::env::current_exe()
.unwrap_or_default()
.parent()
.unwrap_or(Path::new("."))
.join("config.json")
});
let content = serde_json::to_string_pretty(config)?;
std::fs::write(&config_path, content)
.with_context(|| format!("写入 config.json 失败: {}", config_path.display()))?;
Ok(())
}
fn find_config_file() -> Result<PathBuf> {
// 1. 优先查找可执行文件同目录
if let Ok(exe) = std::env::current_exe() {

View File

@ -4,6 +4,7 @@ use anyhow::{bail, Result};
use aes::Aes256;
use cbc::Decryptor;
use cbc::cipher::{BlockDecryptMut, KeyIvInit};
use std::io::{Read, Write};
use std::path::Path;
pub const PAGE_SZ: usize = 4096;
@ -73,34 +74,34 @@ fn aes_cbc_decrypt(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Result<Vec<u8>
Ok(buf)
}
/// 完整解密一个 SQLCipher 数据库文件
/// 完整解密一个 SQLCipher 数据库文件(流式,逐页读写避免全量载入内存)
///
/// 读取 `db_path`,按 PAGE_SZ 分页解密,写入 `out_path`
pub fn full_decrypt(db_path: &Path, out_path: &Path, enc_key: &[u8; 32]) -> Result<()> {
let data = std::fs::read(db_path)?;
if data.is_empty() {
bail!("数据库文件为空: {}", db_path.display());
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let total_pages = (data.len() + PAGE_SZ - 1) / PAGE_SZ;
let mut out = Vec::with_capacity(data.len());
for pgno in 1..=total_pages {
let offset = (pgno - 1) * PAGE_SZ;
let end = std::cmp::min(offset + PAGE_SZ, data.len());
let mut page = data[offset..end].to_vec();
// 不足一页则补零
if page.len() < PAGE_SZ {
page.resize(PAGE_SZ, 0);
}
let dec = decrypt_page(enc_key, &page, pgno as u32)?;
out.extend_from_slice(&dec);
let mut input = std::fs::File::open(db_path)?;
let file_size = input.metadata()?.len() as usize;
if file_size == 0 {
bail!("数据库文件为空: {}", db_path.display());
}
let mut output = std::fs::File::create(out_path)?;
let total_pages = (file_size + PAGE_SZ - 1) / PAGE_SZ;
let mut page_buf = vec![0u8; PAGE_SZ];
for pgno in 1..=total_pages {
let n = input.read(&mut page_buf)?;
if n == 0 { break; }
// 不足一页则补零
if n < PAGE_SZ {
page_buf[n..].fill(0);
}
let dec = decrypt_page(enc_key, &page_buf, pgno as u32)?;
output.write_all(&dec)?;
}
std::fs::write(out_path, &out)?;
Ok(())
}

View File

@ -80,11 +80,10 @@ impl DbCache {
continue;
}
let db_path = self.db_dir.join(rel_key.replace('\\', std::path::MAIN_SEPARATOR_STR).replace('/', std::path::MAIN_SEPARATOR_STR));
let wal_path_str = format!("{}-wal", db_path.display());
let wal_path = Path::new(&wal_path_str);
let wal_path = wal_path_for(&db_path);
let db_mt = mtime_nanos(&db_path);
let wal_mt = if wal_path.exists() { mtime_nanos(wal_path) } else { 0 };
let wal_mt = if wal_path.exists() { mtime_nanos(&wal_path) } else { 0 };
if db_mt == entry.db_mt && wal_mt == entry.wal_mt {
inner.insert(rel_key.clone(), CacheEntry {
@ -135,8 +134,7 @@ impl DbCache {
return Ok(None);
}
let wal_path_str = format!("{}-wal", db_path.display());
let wal_path = Path::new(&wal_path_str).to_path_buf();
let wal_path = wal_path_for(&db_path);
let db_mt = mtime_nanos(&db_path);
let wal_mt = if wal_path.exists() { mtime_nanos(&wal_path) } else { 0 };
@ -195,13 +193,20 @@ impl DbCache {
}
}
fn mtime_nanos(path: &Path) -> u64 {
pub(super) fn mtime_nanos(path: &Path) -> u64 {
std::fs::metadata(path)
.and_then(|m| m.modified())
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() as u64)
.unwrap_or(0)
}
/// `foo/bar.db` → `foo/bar.db-wal`(用 OsString 拼接,避免 display() 的 UTF-8 问题)
fn wal_path_for(db_path: &Path) -> PathBuf {
let mut name = db_path.file_name().unwrap_or_default().to_os_string();
name.push("-wal");
db_path.with_file_name(name)
}
fn hex_to_32bytes(s: &str) -> Result<[u8; 32]> {
if s.len() != 64 {
anyhow::bail!("密钥 hex 长度应为 64实际为 {}", s.len());

View File

@ -208,12 +208,7 @@ async fn run_watcher(
}
}
fn mtime_nanos(path: &std::path::Path) -> u64 {
std::fs::metadata(path)
.and_then(|m| m.modified())
.map(|t| t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos() as u64)
.unwrap_or(0)
}
use cache::mtime_nanos;
fn decompress_or_str(data: &[u8]) -> String {
if data.is_empty() { return String::new(); }

View File

@ -4,9 +4,16 @@ use regex::Regex;
use rusqlite::Connection;
use serde_json::{json, Value};
use std::collections::HashMap;
use std::sync::OnceLock;
use super::cache::DbCache;
/// 静态编译的 Msg 表名正则,避免在热路径中重复编译
fn msg_table_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap())
}
/// 联系人名称缓存
#[derive(Clone)]
pub struct Names {
@ -141,7 +148,7 @@ pub async fn q_history(
let tables = find_msg_tables(db, names, &username).await?;
if tables.is_empty() {
return Ok(json!({ "error": format!("找不到 {} 的消息记录", display) }));
anyhow::bail!("找不到 {} 的消息记录", display);
}
let mut all_msgs: Vec<Value> = Vec::new();
@ -218,7 +225,7 @@ pub async fn q_search(
.filter_map(|r| r.ok())
.collect();
let re = Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap();
let re = msg_table_re();
let mut result = Vec::new();
for tname in table_names {
if !re.is_match(&tname) {
@ -483,8 +490,10 @@ fn search_in_table(
limit: usize,
) -> Result<Vec<Value>> {
let id2u = load_id2u(conn);
let mut clauses = vec!["message_content LIKE ?".to_string()];
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(format!("%{}%", keyword))];
// 转义 LIKE 通配符,使用 '\' 作为 ESCAPE 字符
let escaped_kw = keyword.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
let mut clauses = vec!["message_content LIKE ? ESCAPE '\\'".to_string()];
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![Box::new(format!("%{}%", escaped_kw))];
if let Some(s) = since {
clauses.push("create_time >= ?".into());
params.push(Box::new(s));

View File

@ -41,12 +41,6 @@ pub enum Request {
Watch,
}
impl Request {
pub fn to_json_line(&self) -> anyhow::Result<String> {
let s = serde_json::to_string(self)?;
Ok(s + "\n")
}
}
/// daemon 的响应
#[derive(Debug, Clone, Serialize, Deserialize)]