mirror of
https://github.com/jackwener/wx-cli.git
synced 2026-10-08 23:35:45 +00:00
chore: Apache-2.0 license, Windows support, install.ps1
This commit is contained in:
+150
-1
@@ -246,7 +246,7 @@ fn scan_region(
|
||||
/// 在缓冲区中搜索 x'<96个十六进制字符>' 模式
|
||||
///
|
||||
/// 格式:x'<64hex(key)><32hex(salt)>'(总计 99 字节)
|
||||
fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
|
||||
pub(crate) fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
|
||||
let total = HEX_PATTERN_LEN + 3; // x' + 96 hex + '
|
||||
if buf.len() < total {
|
||||
return;
|
||||
@@ -291,3 +291,152 @@ fn search_pattern(buf: &[u8], results: &mut Vec<(String, String)>) {
|
||||
i += total;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 构造一条合法的 x'<key><salt>' 模式字节串
|
||||
fn make_pattern(key: &[u8; 64], salt: &[u8; 32]) -> Vec<u8> {
|
||||
let mut v = vec![b'x', b'\''];
|
||||
v.extend_from_slice(key);
|
||||
v.extend_from_slice(salt);
|
||||
v.push(b'\'');
|
||||
v
|
||||
}
|
||||
|
||||
#[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); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_hex_char_invalid() {
|
||||
for c in [b'g', b'G', b'x', b'\'', b' ', b'\0', b'z', b'Z'] {
|
||||
assert!(!is_hex_char(c), "expected non-hex: {}", c as char);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_pattern_basic() {
|
||||
let key = [b'a'; 64];
|
||||
let salt = [b'b'; 32];
|
||||
let buf = make_pattern(&key, &salt);
|
||||
let mut results = Vec::new();
|
||||
search_pattern(&buf, &mut results);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0, "a".repeat(64));
|
||||
assert_eq!(results[0].1, "b".repeat(32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_pattern_uppercase_lowercased() {
|
||||
// 大写十六进制字符应被统一转为小写
|
||||
let key = [b'A'; 64];
|
||||
let salt = [b'B'; 32];
|
||||
let buf = make_pattern(&key, &salt);
|
||||
let mut results = Vec::new();
|
||||
search_pattern(&buf, &mut results);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].0, "a".repeat(64));
|
||||
assert_eq!(results[0].1, "b".repeat(32));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_pattern_not_all_hex() {
|
||||
// 96 个十六进制字符中有一个非法字符 → 不匹配
|
||||
let mut buf = vec![b'x', b'\''];
|
||||
buf.extend_from_slice(&[b'a'; 95]);
|
||||
buf.push(b'g'); // 'g' 不是合法十六进制字符
|
||||
buf.push(b'\'');
|
||||
let mut results = Vec::new();
|
||||
search_pattern(&buf, &mut results);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_pattern_wrong_closing_quote() {
|
||||
// 结尾引号错误 → 不匹配
|
||||
let mut buf = vec![b'x', b'\''];
|
||||
buf.extend_from_slice(&[b'a'; 96]);
|
||||
buf.push(b'"'); // 应为 b'\''
|
||||
let mut results = Vec::new();
|
||||
search_pattern(&buf, &mut results);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_pattern_dedup() {
|
||||
// 相同模式出现两次 → 只保留一条
|
||||
let key = [b'1'; 64];
|
||||
let salt = [b'2'; 32];
|
||||
let pattern = make_pattern(&key, &salt);
|
||||
let mut buf = pattern.clone();
|
||||
buf.extend_from_slice(&pattern);
|
||||
let mut results = Vec::new();
|
||||
search_pattern(&buf, &mut results);
|
||||
assert_eq!(results.len(), 1);
|
||||
}
|
||||
|
||||
#[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 mut buf = make_pattern(&key1, &salt1);
|
||||
buf.extend_from_slice(&make_pattern(&key2, &salt2));
|
||||
let mut results = Vec::new();
|
||||
search_pattern(&buf, &mut results);
|
||||
assert_eq!(results.len(), 2);
|
||||
let keys: Vec<&str> = results.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert!(keys.contains(&"a".repeat(64).as_str()));
|
||||
assert!(keys.contains(&"c".repeat(64).as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_pattern_embedded_in_garbage() {
|
||||
// 模式夹在垃圾字节中间,仍应找到
|
||||
let mut buf = vec![0xFFu8; 50];
|
||||
let key = [b'e'; 64];
|
||||
let salt = [b'f'; 32];
|
||||
buf.extend_from_slice(&make_pattern(&key, &salt));
|
||||
buf.extend_from_slice(&[0x00u8; 50]);
|
||||
let mut results = Vec::new();
|
||||
search_pattern(&buf, &mut results);
|
||||
assert_eq!(results.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_pattern_too_short() {
|
||||
// 缓冲区太小,无法容纳完整模式
|
||||
let buf = [b'x', b'\'', b'a', b'b'];
|
||||
let mut results = Vec::new();
|
||||
search_pattern(&buf, &mut results);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_pattern_empty_buf() {
|
||||
let mut results = Vec::new();
|
||||
search_pattern(&[], &mut results);
|
||||
assert!(results.is_empty());
|
||||
}
|
||||
|
||||
#[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; }
|
||||
}
|
||||
let salt = [b'9'; 32];
|
||||
let buf = make_pattern(&key, &salt);
|
||||
let mut results = Vec::new();
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,3 +82,161 @@ mod hex {
|
||||
bytes.iter().map(|b| format!("{:02x}", b)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
/// 创建一个进程唯一的临时目录(测试用),返回路径;测试结束后调用方负责删除
|
||||
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()));
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
// ── read_db_salt ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_read_db_salt_plaintext_sqlite() {
|
||||
let dir = make_temp_dir("salt-plain");
|
||||
let path = dir.join("plain.db");
|
||||
// 明文 SQLite 头:前 15 字节是 "SQLite format 3"
|
||||
let mut content = b"SQLite format 3\x00".to_vec();
|
||||
content.extend_from_slice(&[0u8; 100]);
|
||||
fs::write(&path, &content).unwrap();
|
||||
|
||||
assert!(read_db_salt(&path).is_none(), "明文 SQLite 应返回 None");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_db_salt_encrypted() {
|
||||
let dir = make_temp_dir("salt-enc");
|
||||
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,
|
||||
];
|
||||
fs::write(&path, &header).unwrap();
|
||||
|
||||
let salt = read_db_salt(&path).expect("加密 DB 应返回 Some");
|
||||
assert_eq!(salt, "deadbeef0102030405060708090a0b0c");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_db_salt_too_short() {
|
||||
let dir = make_temp_dir("salt-short");
|
||||
let path = dir.join("short.db");
|
||||
fs::write(&path, b"tooshort").unwrap(); // < 16 bytes
|
||||
|
||||
assert!(read_db_salt(&path).is_none(), "文件太短应返回 None");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_db_salt_nonexistent() {
|
||||
assert!(read_db_salt(Path::new("/nonexistent/surely/not/here.db")).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_read_db_salt_exactly_16_bytes() {
|
||||
let dir = make_temp_dir("salt-16");
|
||||
let path = dir.join("exact.db");
|
||||
let header = [0xabu8; 16];
|
||||
fs::write(&path, &header).unwrap();
|
||||
|
||||
let salt = read_db_salt(&path).unwrap();
|
||||
// 0xab × 16 → "ab" × 16 = 32 chars
|
||||
assert_eq!(salt, "ab".repeat(16));
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ── collect_db_salts ──────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_collect_db_salts_empty_dir() {
|
||||
let dir = make_temp_dir("collect-empty");
|
||||
let salts = collect_db_salts(&dir);
|
||||
assert!(salts.is_empty());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_db_salts_skips_plaintext_sqlite() {
|
||||
let dir = make_temp_dir("collect-plain");
|
||||
let mut content = b"SQLite format 3\x00".to_vec();
|
||||
content.extend_from_slice(&[0u8; 100]);
|
||||
fs::write(dir.join("plain.db"), &content).unwrap();
|
||||
|
||||
assert!(collect_db_salts(&dir).is_empty(), "明文 SQLite 应被跳过");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_db_salts_finds_encrypted() {
|
||||
let dir = make_temp_dir("collect-enc");
|
||||
let header = [0x11u8; 16];
|
||||
fs::write(dir.join("msg.db"), &header).unwrap();
|
||||
|
||||
let salts = collect_db_salts(&dir);
|
||||
assert_eq!(salts.len(), 1);
|
||||
assert_eq!(salts[0].0, "11".repeat(16)); // 0x11 × 16 → "11" × 16
|
||||
assert_eq!(salts[0].1, "msg.db");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_db_salts_recursive() {
|
||||
let dir = make_temp_dir("collect-rec");
|
||||
let subdir = dir.join("sub");
|
||||
fs::create_dir_all(&subdir).unwrap();
|
||||
|
||||
let header = [0xaau8; 16];
|
||||
fs::write(dir.join("root.db"), &header).unwrap();
|
||||
fs::write(subdir.join("nested.db"), &header).unwrap();
|
||||
fs::write(dir.join("ignored.txt"), b"text file").unwrap();
|
||||
|
||||
let salts = collect_db_salts(&dir);
|
||||
assert_eq!(salts.len(), 2, "应递归找到 2 个加密 .db");
|
||||
|
||||
let names: Vec<&str> = salts.iter().map(|(_, n)| n.as_str()).collect();
|
||||
assert!(names.contains(&"root.db"));
|
||||
assert!(names.contains(&"sub/nested.db"));
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_db_salts_ignores_non_db_extensions() {
|
||||
let dir = make_temp_dir("collect-ext");
|
||||
let header = [0xbbu8; 16];
|
||||
fs::write(dir.join("data.txt"), &header).unwrap();
|
||||
fs::write(dir.join("data.json"), &header).unwrap();
|
||||
fs::write(dir.join("data.sqlite"), &header).unwrap();
|
||||
|
||||
assert!(collect_db_salts(&dir).is_empty(), "非 .db 文件应被忽略");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_db_salts_multiple_files_unique_salts() {
|
||||
let dir = make_temp_dir("collect-multi");
|
||||
fs::write(dir.join("a.db"), &[0x11u8; 16]).unwrap();
|
||||
fs::write(dir.join("b.db"), &[0x22u8; 16]).unwrap();
|
||||
fs::write(dir.join("c.db"), &[0x33u8; 16]).unwrap();
|
||||
|
||||
let salts = collect_db_salts(&dir);
|
||||
assert_eq!(salts.len(), 3);
|
||||
|
||||
let salt_vals: std::collections::HashSet<&str> =
|
||||
salts.iter().map(|(s, _)| s.as_str()).collect();
|
||||
assert!(salt_vals.contains("11".repeat(16).as_str()));
|
||||
assert!(salt_vals.contains("22".repeat(16).as_str()));
|
||||
assert!(salt_vals.contains("33".repeat(16).as_str()));
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user