feat: Rust 完整重写 wx-cli(单一二进制,支持 macOS/Linux/Windows)

实现所有核心模块:
- src/crypto/: SQLCipher 4 页解密 + WAL 应用(AES-256-CBC)
- src/scanner/: 三平台内存扫描(macOS Mach VM / Linux /proc/mem / Windows ReadProcessMemory)
- src/daemon/: tokio 异步 daemon,Unix socket IPC,mtime-aware DB 缓存,WAL 监听推送
- src/cli/: clap CLI,自动启动 daemon,完整命令实现
- src/config.rs: 跨平台配置加载,兼容 Python 版 config.json 格式
- src/ipc.rs: 换行符分隔 JSON 协议,与 Python 版兼容
- .github/workflows/release.yml: 四平台自动构建发布

cargo build --release 验证通过,生成 4.8MB macOS arm64 单一二进制
This commit is contained in:
jackwener
2026-04-16 14:37:10 +08:00
parent 0d5ac82349
commit d475f6219b
27 changed files with 5294 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
pub mod wal;
use anyhow::{bail, Result};
use aes::Aes256;
use cbc::Decryptor;
use cbc::cipher::{BlockDecryptMut, KeyIvInit};
use std::path::Path;
pub const PAGE_SZ: usize = 4096;
pub const SALT_SZ: usize = 16;
pub const RESERVE_SZ: usize = 80; // IV(16) + HMAC(64)
/// SQLite 文件头魔数(16字节)
pub const SQLITE_HDR: &[u8] = b"SQLite format 3\x00";
type Aes256CbcDec = Decryptor<Aes256>;
/// 解密单个 SQLCipher 4 页
///
/// - `enc_key`: 32字节 AES 密钥
/// - `page_data`: 原始加密页面数据(PAGE_SZ 字节)
/// - `pgno`: 页码(从1开始)
///
/// 返回解密后的完整页面(PAGE_SZ 字节)
pub fn decrypt_page(enc_key: &[u8; 32], page_data: &[u8], pgno: u32) -> Result<Vec<u8>> {
if page_data.len() < PAGE_SZ {
bail!("页面数据不足 {} 字节", PAGE_SZ);
}
// IV 位于页面末尾 RESERVE_SZ 区域的前16字节
let iv_offset = PAGE_SZ - RESERVE_SZ;
let iv: &[u8; 16] = page_data[iv_offset..iv_offset + 16]
.try_into()
.expect("IV 长度固定为 16");
let mut result = vec![0u8; PAGE_SZ];
if pgno == 1 {
// 第一页:跳过 salt(16字节),解密 [SALT_SZ..PAGE_SZ-RESERVE_SZ]
let enc = &page_data[SALT_SZ..PAGE_SZ - RESERVE_SZ];
let dec = aes_cbc_decrypt(enc_key, iv, enc)?;
// 写入 SQLite 文件头
result[..16].copy_from_slice(SQLITE_HDR);
// 写入解密数据(从第16字节开始)
result[16..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec);
// 末尾 RESERVE_SZ 字节补零
// (已经是零,无需显式操作)
} else {
// 其他页:解密 [0..PAGE_SZ-RESERVE_SZ]
let enc = &page_data[..PAGE_SZ - RESERVE_SZ];
let dec = aes_cbc_decrypt(enc_key, iv, enc)?;
result[..PAGE_SZ - RESERVE_SZ].copy_from_slice(&dec);
// 末尾 RESERVE_SZ 字节补零
}
Ok(result)
}
/// AES-256-CBC 解密(不去除 padding,SQLCipher 不使用 PKCS#7 padding)
fn aes_cbc_decrypt(key: &[u8; 32], iv: &[u8; 16], data: &[u8]) -> Result<Vec<u8>> {
if data.is_empty() || data.len() % 16 != 0 {
bail!("密文长度不是 AES 块大小的倍数: {}", data.len());
}
let mut buf = data.to_vec();
// 使用 raw 模式不处理 padding
Aes256CbcDec::new(key.into(), iv.into())
.decrypt_blocks_mut(unsafe {
std::slice::from_raw_parts_mut(
buf.as_mut_ptr() as *mut aes::cipher::Block<Aes256>,
buf.len() / 16,
)
});
Ok(buf)
}
/// 完整解密一个 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);
}
std::fs::write(out_path, &out)?;
Ok(())
}
+71
View File
@@ -0,0 +1,71 @@
use anyhow::Result;
use std::io::{SeekFrom, Seek, Write};
use std::path::Path;
use super::{decrypt_page, PAGE_SZ};
pub const WAL_HDR_SZ: usize = 32;
pub const WAL_FRAME_HDR: usize = 24;
/// 将 WAL 文件中的变更应用到已解密的数据库文件
///
/// WAL 格式(SQLite 标准,SQLCipher 4 的 WAL 帧也被加密):
/// - WAL header (32 bytes): magic(4) + format(4) + page_sz(4) + ckpt_seq(4) + salt1(4) + salt2(4) + cksum1(4) + cksum2(4)
/// - 每帧:frame_header(24 bytes) + page_data(PAGE_SZ bytes)
/// - frame_header: pgno(4) + commit_pgcnt(4) + salt1(4) + salt2(4) + cksum1(4) + cksum2(4)
pub fn apply_wal(wal_path: &Path, out_path: &Path, enc_key: &[u8; 32]) -> Result<()> {
if !wal_path.exists() {
return Ok(());
}
let wal_data = std::fs::read(wal_path)?;
if wal_data.len() <= WAL_HDR_SZ {
return Ok(());
}
// 读取 WAL 头中的 salt1 / salt2
let s1 = u32::from_be_bytes(wal_data[16..20].try_into().unwrap());
let s2 = u32::from_be_bytes(wal_data[20..24].try_into().unwrap());
let frame_size = WAL_FRAME_HDR + PAGE_SZ;
let frame_area = &wal_data[WAL_HDR_SZ..];
// 打开输出文件做随机写
let mut db_file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(out_path)?;
let mut pos = 0usize;
while pos + frame_size <= frame_area.len() {
let fh = &frame_area[pos..pos + WAL_FRAME_HDR];
let page_data = &frame_area[pos + WAL_FRAME_HDR..pos + frame_size];
let pgno = u32::from_be_bytes(fh[0..4].try_into().unwrap());
let fs1 = u32::from_be_bytes(fh[8..12].try_into().unwrap());
let fs2 = u32::from_be_bytes(fh[12..16].try_into().unwrap());
pos += frame_size;
// 跳过无效页码
if pgno == 0 || pgno > 1_000_000 {
continue;
}
// salt 不匹配的帧属于已检查点或旧事务
if fs1 != s1 || fs2 != s2 {
continue;
}
let mut page_buf = page_data.to_vec();
if page_buf.len() < PAGE_SZ {
page_buf.resize(PAGE_SZ, 0);
}
let dec = decrypt_page(enc_key, &page_buf, pgno)?;
let file_offset = (pgno as u64 - 1) * PAGE_SZ as u64;
db_file.seek(SeekFrom::Start(file_offset))?;
db_file.write_all(&dec)?;
}
Ok(())
}