chore: Apache-2.0 license, Windows support, install.ps1

pull/2/head
jackwener 2026-04-16 22:30:45 +08:00
parent 6d40c7f737
commit 6cdc806642
27 changed files with 1687 additions and 866 deletions

44
Cargo.lock generated
View File

@ -301,6 +301,12 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
@ -377,13 +383,19 @@ dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown",
"hashbrown 0.14.5",
]
[[package]]
@ -425,6 +437,16 @@ dependencies = [
"cc",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.0",
]
[[package]]
name = "inout"
version = "0.1.4"
@ -771,6 +793,19 @@ dependencies = [
"serde",
]
[[package]]
name = "serde_yaml"
version = "0.9.34+deprecated"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
dependencies = [
"indexmap",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]]
name = "sha2"
version = "0.10.9"
@ -897,6 +932,12 @@ version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "unsafe-libyaml"
version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "utf8parse"
version = "0.2.2"
@ -1277,6 +1318,7 @@ dependencies = [
"rusqlite",
"serde",
"serde_json",
"serde_yaml",
"sha2",
"tokio",
"windows",

View File

@ -3,7 +3,7 @@ name = "wx-cli"
version = "0.1.1"
edition = "2021"
description = "WeChat 4.x (macOS/Linux) local data CLI — decrypt SQLCipher DBs, query chat history, watch new messages"
license = "MIT"
license = "Apache-2.0"
repository = "https://github.com/jackwener/wx-cli"
keywords = ["wechat", "sqlcipher", "decrypt", "cli"]
categories = ["command-line-utilities"]
@ -23,6 +23,7 @@ tokio = { version = "1", features = ["full"] }
# 序列化
serde = { version = "1", features = ["derive"] }
serde_json = "=1.0.140"
serde_yaml = "0.9"
# SQLite
rusqlite = { version = "0.31", features = ["bundled"] }

215
LICENSE
View File

@ -1,21 +1,202 @@
MIT License
Copyright (c) 2026 jackwener
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Definitions.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -4,8 +4,8 @@
**从命令行查询本地微信数据**
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux-lightgrey.svg)](#安装)
[![License: Apache-2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-lightgrey.svg)](#安装)
[![Rust](https://img.shields.io/badge/built%20with-Rust-orange.svg)](https://www.rust-lang.org)
会话 · 聊天记录 · 搜索 · 联系人 · 群成员 · 收藏 · 统计 · 导出
@ -25,31 +25,40 @@
## 安装
**macOS / Linux**
```bash
curl -fsSL https://raw.githubusercontent.com/jackwener/wx-cli/main/install.sh | bash
```
> 支持macOS Apple Silicon / IntelLinux x86_64 / arm64
**Windows**PowerShell以管理员身份运行
```powershell
irm https://raw.githubusercontent.com/jackwener/wx-cli/main/install.ps1 | iex
```
<details>
<summary>其他安装方式</summary>
**手动下载**
从 [Releases](https://github.com/jackwener/wx-cli/releases) 下载对应平台文件,重命名为 `wx``chmod +x` 后放入 PATH
从 [Releases](https://github.com/jackwener/wx-cli/releases) 下载对应平台文件:
| 平台 | 文件 |
|------|------|
| macOS Apple Silicon | `wx-macos-arm64` |
| macOS Intel | `wx-macos-x86_64` |
| Linux x86_64 | `wx-linux-x86_64` |
| Windows x86_64 | `wx-windows-x86_64.exe` |
macOS / Linux`chmod +x wx && sudo mv wx /usr/local/bin/`
**从源码构建**
```bash
git clone git@github.com:jackwener/wx-cli.git && cd wx-cli
cargo build --release
# 产物target/release/wx
# 产物target/release/wxWindows: wx.exe
```
</details>
@ -58,18 +67,27 @@ cargo build --release
## 快速开始
微信 4.xmacOS 版)需要先做 ad-hoc 签名才能扫描内存:
保持微信运行,然后初始化(只需一次):
**macOS**(需要先对微信做 ad-hoc 签名,才能扫描其内存)
```bash
sudo codesign --force --deep --sign - /Applications/WeChat.app
sudo wx init
```
保持微信运行,然后初始化(只需一次):
**Linux**
```bash
sudo wx init
```
**Windows**(以管理员身份运行 PowerShell
```powershell
wx init
```
之后直接用daemon 会在首次调用时自动启动:
```bash

View File

@ -1,319 +0,0 @@
/*
* find_all_keys_macos.c - macOS WeChat memory key scanner
*
* Scans WeChat process memory for SQLCipher encryption keys in the
* x'<key_hex><salt_hex>' format used by WeChat 4.x on macOS.
*
* Prerequisites:
* - WeChat must be ad-hoc signed (or SIP disabled)
* - Must run as root (sudo)
*
* Build:
* cc -O2 -o find_all_keys_macos find_all_keys_macos.c -framework Foundation
*
* Usage:
* sudo ./find_all_keys_macos [pid]
* If pid is omitted, automatically finds WeChat PID.
*
* Output: JSON file at ./all_keys.json (compatible with decrypt_db.py)
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <dirent.h>
#include <ftw.h>
#include <pwd.h>
#include <sys/stat.h>
#include <mach/mach.h>
#include <mach/mach_vm.h>
#define MAX_KEYS 256
#define KEY_SIZE 32
#define SALT_SIZE 16
#define HEX_PATTERN_LEN 96 /* 64 hex (key) + 32 hex (salt) */
#define CHUNK_SIZE (2 * 1024 * 1024)
typedef struct {
char key_hex[65];
char salt_hex[33];
char full_pragma[100];
} key_entry_t;
/* Forward declaration */
static int read_db_salt(const char *path, char *salt_hex_out);
/* nftw callback state for collecting DB files */
#define MAX_DBS 256
static char g_db_salts[MAX_DBS][33];
static char g_db_names[MAX_DBS][256];
static int g_db_count = 0;
static int nftw_collect_db(const char *fpath, const struct stat *sb,
int typeflag, struct FTW *ftwbuf) {
(void)sb; (void)ftwbuf;
if (typeflag != FTW_F) return 0;
size_t len = strlen(fpath);
if (len < 3 || strcmp(fpath + len - 3, ".db") != 0) return 0;
if (g_db_count >= MAX_DBS) return 0;
char salt[33];
if (read_db_salt(fpath, salt) != 0) return 0;
strcpy(g_db_salts[g_db_count], salt);
/* Extract relative path from db_storage/ */
const char *rel = strstr(fpath, "db_storage/");
if (rel) rel += strlen("db_storage/");
else {
rel = strrchr(fpath, '/');
rel = rel ? rel + 1 : fpath;
}
strncpy(g_db_names[g_db_count], rel, 255);
g_db_names[g_db_count][255] = '\0';
printf(" %s: salt=%s\n", g_db_names[g_db_count], salt);
g_db_count++;
return 0;
}
static int is_hex_char(unsigned char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}
static pid_t find_wechat_pid(void) {
FILE *fp = popen("pgrep -x WeChat", "r");
if (!fp) return -1;
char buf[64];
pid_t pid = -1;
if (fgets(buf, sizeof(buf), fp))
pid = atoi(buf);
pclose(fp);
return pid;
}
/* Read DB salt (first 16 bytes) and return hex string */
static int read_db_salt(const char *path, char *salt_hex_out) {
FILE *f = fopen(path, "rb");
if (!f) return -1;
unsigned char header[16];
if (fread(header, 1, 16, f) != 16) { fclose(f); return -1; }
fclose(f);
/* Check if unencrypted */
if (memcmp(header, "SQLite format 3", 15) == 0) return -1;
for (int i = 0; i < 16; i++)
sprintf(salt_hex_out + i * 2, "%02x", header[i]);
salt_hex_out[32] = '\0';
return 0;
}
int main(int argc, char *argv[]) {
pid_t pid;
if (argc >= 2)
pid = atoi(argv[1]);
else
pid = find_wechat_pid();
if (pid <= 0) {
fprintf(stderr, "WeChat not running or invalid PID\n");
return 1;
}
printf("============================================================\n");
printf(" macOS WeChat Memory Key Scanner (C version)\n");
printf("============================================================\n");
printf("WeChat PID: %d\n", pid);
/* Get task port */
mach_port_t task;
kern_return_t kr = task_for_pid(mach_task_self(), pid, &task);
if (kr != KERN_SUCCESS) {
fprintf(stderr, "task_for_pid failed: %d\n", kr);
fprintf(stderr, "Make sure: (1) running as root, (2) WeChat is ad-hoc signed\n");
return 1;
}
printf("Got task port: %u\n", task);
/* Resolve real user's HOME (sudo may change HOME to /var/root) */
const char *home = getenv("HOME");
const char *sudo_user = getenv("SUDO_USER");
if (sudo_user) {
struct passwd *pw = getpwnam(sudo_user);
if (pw && pw->pw_dir)
home = pw->pw_dir;
}
if (!home) home = "/root";
printf("User home: %s\n", home);
/* Collect DB salts by recursively walking db_storage directories.
* Note: POSIX glob() does not support ** recursive matching on macOS,
* so we use nftw() to walk the directory tree instead. */
printf("\nScanning for DB files...\n");
char db_base_dir[512];
snprintf(db_base_dir, sizeof(db_base_dir),
"%s/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files",
home);
/* Walk each account's db_storage directory */
DIR *xdir = opendir(db_base_dir);
if (xdir) {
struct dirent *ent;
while ((ent = readdir(xdir)) != NULL) {
if (ent->d_name[0] == '.') continue;
char storage_path[768];
snprintf(storage_path, sizeof(storage_path),
"%s/%s/db_storage", db_base_dir, ent->d_name);
struct stat st;
if (stat(storage_path, &st) == 0 && S_ISDIR(st.st_mode)) {
nftw(storage_path, nftw_collect_db, 20, FTW_PHYS);
}
}
closedir(xdir);
}
printf("Found %d encrypted DBs\n", g_db_count);
/* Scan memory for x' patterns */
printf("\nScanning memory for keys...\n");
key_entry_t keys[MAX_KEYS];
int key_count = 0;
size_t total_scanned = 0;
int region_count = 0;
mach_vm_address_t addr = 0;
while (1) {
mach_vm_size_t size = 0;
vm_region_basic_info_data_64_t info;
mach_msg_type_number_t info_count = VM_REGION_BASIC_INFO_COUNT_64;
mach_port_t obj_name;
kr = mach_vm_region(task, &addr, &size, VM_REGION_BASIC_INFO_64,
(vm_region_info_t)&info, &info_count, &obj_name);
if (kr != KERN_SUCCESS) break;
if (size == 0) { addr++; continue; } /* guard against infinite loop */
if ((info.protection & (VM_PROT_READ | VM_PROT_WRITE)) ==
(VM_PROT_READ | VM_PROT_WRITE)) {
region_count++;
mach_vm_address_t ca = addr;
while (ca < addr + size) {
mach_vm_size_t cs = addr + size - ca;
if (cs > CHUNK_SIZE) cs = CHUNK_SIZE;
vm_offset_t data;
mach_msg_type_number_t dc;
kr = mach_vm_read(task, ca, cs, &data, &dc);
if (kr == KERN_SUCCESS) {
unsigned char *buf = (unsigned char *)data;
total_scanned += dc;
for (size_t i = 0; i + HEX_PATTERN_LEN + 3 < dc; i++) {
if (buf[i] == 'x' && buf[i + 1] == '\'') {
/* Check if followed by 96 hex chars and closing ' */
int valid = 1;
for (int j = 0; j < HEX_PATTERN_LEN; j++) {
if (!is_hex_char(buf[i + 2 + j])) { valid = 0; break; }
}
if (!valid) continue;
if (buf[i + 2 + HEX_PATTERN_LEN] != '\'') continue;
/* Extract key and salt hex */
char key_hex[65], salt_hex[33];
memcpy(key_hex, buf + i + 2, 64);
key_hex[64] = '\0';
memcpy(salt_hex, buf + i + 2 + 64, 32);
salt_hex[32] = '\0';
/* Convert to lowercase for comparison */
for (int j = 0; key_hex[j]; j++)
if (key_hex[j] >= 'A' && key_hex[j] <= 'F')
key_hex[j] += 32;
for (int j = 0; salt_hex[j]; j++)
if (salt_hex[j] >= 'A' && salt_hex[j] <= 'F')
salt_hex[j] += 32;
/* Deduplicate */
int dup = 0;
for (int k = 0; k < key_count; k++) {
if (strcmp(keys[k].key_hex, key_hex) == 0 &&
strcmp(keys[k].salt_hex, salt_hex) == 0) {
dup = 1; break;
}
}
if (dup) continue;
if (key_count < MAX_KEYS) {
strcpy(keys[key_count].key_hex, key_hex);
strcpy(keys[key_count].salt_hex, salt_hex);
snprintf(keys[key_count].full_pragma, sizeof(keys[key_count].full_pragma),
"x'%s%s'", key_hex, salt_hex);
key_count++;
}
}
}
mach_vm_deallocate(mach_task_self(), data, dc);
}
/* Advance with overlap to catch patterns spanning chunk boundaries.
* Pattern is x'<96 hex chars>' = 99 bytes total. */
if (cs > HEX_PATTERN_LEN + 3)
ca += cs - (HEX_PATTERN_LEN + 3);
else
ca += cs;
}
}
addr += size;
}
printf("\nScan complete: %zuMB scanned, %d regions, %d unique keys\n",
total_scanned / 1024 / 1024, region_count, key_count);
/* Match keys to DBs */
printf("\n%-25s %-66s %s\n", "Database", "Key", "Salt");
printf("%-25s %-66s %s\n",
"-------------------------",
"------------------------------------------------------------------",
"--------------------------------");
int matched = 0;
for (int i = 0; i < key_count; i++) {
const char *db = NULL;
for (int j = 0; j < g_db_count; j++) {
if (strcmp(keys[i].salt_hex, g_db_salts[j]) == 0) {
db = g_db_names[j];
matched++;
break;
}
}
printf("%-25s %-66s %s\n",
db ? db : "(unknown)",
keys[i].key_hex,
keys[i].salt_hex);
}
printf("\nMatched %d/%d keys to known DBs\n", matched, key_count);
/* Save JSON: { "rel/path.db": { "enc_key": "hex" }, ... }
* Uses forward slashes (native macOS paths, valid JSON without escaping).
*/
const char *out_path = "all_keys.json";
FILE *fp = fopen(out_path, "w");
if (fp) {
fprintf(fp, "{\n");
int first = 1;
for (int i = 0; i < key_count; i++) {
const char *db = NULL;
for (int j = 0; j < g_db_count; j++) {
if (strcmp(keys[i].salt_hex, g_db_salts[j]) == 0) {
db = g_db_names[j];
break;
}
}
if (!db) continue;
fprintf(fp, "%s \"%s\": {\"enc_key\": \"%s\"}",
first ? "" : ",\n", db, keys[i].key_hex);
first = 0;
}
fprintf(fp, "\n}\n");
fclose(fp);
printf("Saved to %s\n", out_path);
}
return 0;
}

50
install.ps1 100644
View File

@ -0,0 +1,50 @@
# wx-cli Windows installer
# Run with: irm https://raw.githubusercontent.com/jackwener/wx-cli/main/install.ps1 | iex
$ErrorActionPreference = "Stop"
$Repo = "jackwener/wx-cli"
$BinName = "wx.exe"
$Asset = "wx-windows-x86_64.exe"
$InstallDir = "$env:LOCALAPPDATA\wx-cli"
# ── 获取最新版本 ────────────────────────────────────────────
Write-Host "正在获取最新版本..."
$Release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest"
$Tag = $Release.tag_name
if (-not $Tag) {
Write-Error "获取版本失败,请检查网络或访问 https://github.com/$Repo/releases"
exit 1
}
Write-Host "版本: $Tag"
# ── 下载 ────────────────────────────────────────────────────
$Url = "https://github.com/$Repo/releases/download/$Tag/$Asset"
$TmpFile = Join-Path $env:TEMP "wx-cli-download.exe"
Write-Host "下载中: $Url"
Invoke-WebRequest -Uri $Url -OutFile $TmpFile -UseBasicParsing
# ── 安装 ────────────────────────────────────────────────────
if (-not (Test-Path $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir | Out-Null
}
Move-Item -Force $TmpFile (Join-Path $InstallDir $BinName)
# ── 加入 PATH当前用户 ────────────────────────────────────
$UserPath = [Environment]::GetEnvironmentVariable("PATH", "User")
if ($UserPath -notlike "*$InstallDir*") {
[Environment]::SetEnvironmentVariable("PATH", "$UserPath;$InstallDir", "User")
Write-Host "已将 $InstallDir 加入用户 PATH重新打开终端生效"
}
Write-Host ""
Write-Host "✓ wx 已安装到 $InstallDir\$BinName"
Write-Host ""
Write-Host "快速开始(以管理员身份运行):"
Write-Host " wx init # 首次初始化(需要微信正在运行)"
Write-Host " wx sessions # 查看最近会话"
Write-Host " wx --help # 查看所有命令"

View File

@ -1,28 +1,12 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
use super::output::{resolve, print_value};
pub fn cmd_contacts(query: Option<String>, limit: usize, json: bool) -> Result<()> {
let req = Request::Contacts { query, limit };
let resp = transport::send(req)?;
let resp = transport::send(Request::Contacts { query, limit })?;
let contacts = resp.data.get("contacts")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let total = resp.data["total"].as_i64().unwrap_or(contacts.len() as i64);
if json {
println!("{}", serde_json::to_string_pretty(&contacts)?);
return Ok(());
}
println!("{} 个联系人(显示 {} 个):\n", total, contacts.len());
for c in &contacts {
let display = c["display"].as_str().unwrap_or("");
let username = c["username"].as_str().unwrap_or("");
println!(" {:<20} {}", display, username);
}
Ok(())
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&contacts, &resolve(json))
}

View File

@ -20,6 +20,7 @@ pub fn cmd_export(
offset: 0,
since: since_ts,
until: until_ts,
msg_type: None,
};
let resp = transport::send(req)?;

View File

@ -0,0 +1,29 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
use super::output::{resolve, print_value};
fn parse_fav_type(s: &str) -> Option<i64> {
match s {
"text" => Some(1),
"image" => Some(2),
"article" => Some(5),
"card" => Some(19),
"video" => Some(20),
_ => None,
}
}
pub fn cmd_favorites(
limit: usize,
fav_type: Option<String>,
query: Option<String>,
json: bool,
) -> Result<()> {
let type_val = fav_type.as_deref().and_then(parse_fav_type);
let resp = transport::send(Request::Favorites { limit, fav_type: type_val, query })?;
let items = resp.data.get("items")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&items, &resolve(json))
}

View File

@ -1,6 +1,7 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
use super::output::{resolve, print_value};
pub fn cmd_history(
chat: String,
@ -8,73 +9,65 @@ pub fn cmd_history(
offset: usize,
since: Option<String>,
until: Option<String>,
msg_type: Option<String>,
json: bool,
) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(|s| parse_time_end(s)).transpose()?;
let req = Request::History {
chat,
limit,
offset,
since: since_ts,
until: until_ts,
};
let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let type_val = msg_type.as_deref().and_then(parse_msg_type);
let req = Request::History { chat, limit, offset, since: since_ts, until: until_ts, msg_type: type_val };
let resp = transport::send(req)?;
if json {
let msgs = resp.data.get("messages").cloned().unwrap_or(serde_json::Value::Array(vec![]));
println!("{}", serde_json::to_string_pretty(&msgs)?);
return Ok(());
}
let chat_name = resp.data["chat"].as_str().unwrap_or("");
let is_group = resp.data["is_group"].as_bool().unwrap_or(false);
let count = resp.data["count"].as_i64().unwrap_or(0);
let group_str = if is_group { " [群]" } else { "" };
println!("=== {}{} ({} 条) ===\n", chat_name, group_str, count);
if let Some(msgs) = resp.data["messages"].as_array() {
for m in msgs {
let time = m["time"].as_str().unwrap_or("");
let sender = m["sender"].as_str().unwrap_or("");
let content = m["content"].as_str().unwrap_or("");
let sender_str = if !sender.is_empty() {
format!("\x1b[33m{}\x1b[0m: ", sender)
} else {
String::new()
};
println!("\x1b[90m[{}]\x1b[0m {}{}", time, sender_str, content);
}
}
Ok(())
let msgs = resp.data.get("messages")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&msgs, &resolve(json))
}
pub fn parse_time(s: &str) -> Result<i64> {
for fmt in &["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"] {
use chrono::{Local, TimeZone};
for fmt in &["%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M"] {
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
return Ok(dt.and_utc().timestamp());
}
// 尝试仅日期格式
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, fmt) {
let dt = d.and_hms_opt(0, 0, 0).unwrap();
return Ok(dt.and_utc().timestamp());
return Local.from_local_datetime(&dt).single()
.map(|d| d.timestamp())
.ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s));
}
}
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let dt = d.and_hms_opt(0, 0, 0).unwrap();
return Local.from_local_datetime(&dt).single()
.map(|d| d.timestamp())
.ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s));
}
anyhow::bail!("无法解析时间 '{}',支持 YYYY-MM-DD / YYYY-MM-DD HH:MM / YYYY-MM-DD HH:MM:SS", s)
}
pub fn parse_time_end(s: &str) -> Result<i64> {
// 对于仅日期格式,结束时间为当天 23:59:59
use chrono::{Local, TimeZone};
if s.len() == 10 {
if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
let dt = d.and_hms_opt(23, 59, 59).unwrap();
return Ok(dt.and_utc().timestamp());
return Local.from_local_datetime(&dt).single()
.map(|d| d.timestamp())
.ok_or_else(|| anyhow::anyhow!("本地时间歧义: {}", s));
}
}
parse_time(s)
}
/// 将消息类型字符串转为 local_type 整数,未知类型返回 None
pub fn parse_msg_type(s: &str) -> Option<i64> {
match s {
"text" => Some(1),
"image" => Some(3),
"voice" => Some(34),
"video" => Some(43),
"sticker" => Some(47),
"location" => Some(48),
"link" | "file" => Some(49),
"call" => Some(50),
"system" => Some(10000),
_ => None,
}
}

12
src/cli/members.rs 100644
View File

@ -0,0 +1,12 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
use super::output::{resolve, print_value};
pub fn cmd_members(chat: String, json: bool) -> Result<()> {
let resp = transport::send(Request::Members { chat })?;
let members = resp.data.get("members")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&members, &resolve(json))
}

View File

@ -4,9 +4,14 @@ pub mod history;
pub mod search;
pub mod contacts;
pub mod export;
pub mod watch;
pub mod daemon_cmd;
pub mod transport;
pub mod output;
pub mod unread;
pub mod members;
pub mod new_messages;
pub mod stats;
pub mod favorites;
use anyhow::Result;
use clap::{Parser, Subcommand};
@ -32,7 +37,7 @@ enum Commands {
/// 会话数量
#[arg(short = 'n', long, default_value = "20")]
limit: usize,
/// 输出原始 JSON
/// 输出 JSON(默认 YAML
#[arg(long)]
json: bool,
},
@ -52,7 +57,11 @@ enum Commands {
/// 结束时间 YYYY-MM-DD
#[arg(long)]
until: Option<String>,
/// 输出原始 JSON
/// 消息类型过滤 [text|image|voice|video|sticker|location|link|file|call|system]
#[arg(long = "type", value_name = "TYPE",
value_parser = ["text","image","voice","video","sticker","location","link","file","call","system"])]
msg_type: Option<String>,
/// 输出 JSON默认 YAML
#[arg(long)]
json: bool,
},
@ -72,7 +81,11 @@ enum Commands {
/// 结束时间 YYYY-MM-DD
#[arg(long)]
until: Option<String>,
/// 输出原始 JSON
/// 消息类型过滤 [text|image|voice|video|sticker|location|link|file|call|system]
#[arg(long = "type", value_name = "TYPE",
value_parser = ["text","image","voice","video","sticker","location","link","file","call","system"])]
msg_type: Option<String>,
/// 输出 JSON默认 YAML
#[arg(long)]
json: bool,
},
@ -84,7 +97,7 @@ enum Commands {
/// 显示数量
#[arg(short = 'n', long, default_value = "50")]
limit: usize,
/// 输出原始 JSON
/// 输出 JSON(默认 YAML
#[arg(long)]
json: bool,
},
@ -101,19 +114,66 @@ enum Commands {
/// 最多导出条数
#[arg(short = 'n', long, default_value = "500")]
limit: usize,
/// 输出格式 [markdown|txt|json]
#[arg(short = 'f', long, default_value = "markdown", value_parser = ["markdown", "txt", "json"])]
/// 输出格式 [markdown|txt|json|yaml]
#[arg(short = 'f', long, default_value = "markdown", value_parser = ["markdown", "txt", "json", "yaml"])]
format: String,
/// 输出文件(默认 stdout
#[arg(short = 'o', long)]
output: Option<String>,
},
/// 实时监听新消息Ctrl+C 退出)
Watch {
/// 只显示指定聊天的消息
/// 显示有未读消息的会话
Unread {
/// 显示数量
#[arg(short = 'n', long, default_value = "20")]
limit: usize,
/// 输出 JSON默认 YAML
#[arg(long)]
chat: Option<String>,
/// 输出 JSON lines
json: bool,
},
/// 查看群成员
Members {
/// 群聊名称(支持模糊匹配)
chat: String,
/// 输出 JSON默认 YAML
#[arg(long)]
json: bool,
},
/// 获取自上次检查以来的新消息
NewMessages {
/// 显示数量上限
#[arg(short = 'n', long, default_value = "200")]
limit: usize,
/// 输出 JSON默认 YAML
#[arg(long)]
json: bool,
},
/// 聊天统计分析
Stats {
/// 聊天对象名称(支持模糊匹配)
chat: String,
/// 起始时间 YYYY-MM-DD
#[arg(long)]
since: Option<String>,
/// 结束时间 YYYY-MM-DD
#[arg(long)]
until: Option<String>,
/// 输出 JSON默认 YAML
#[arg(long)]
json: bool,
},
/// 查看微信收藏内容
Favorites {
/// 显示数量
#[arg(short = 'n', long, default_value = "50")]
limit: usize,
/// 类型过滤 [text|image|article|card|video]
#[arg(long = "type", value_name = "TYPE",
value_parser = ["text","image","article","card","video"])]
fav_type: Option<String>,
/// 内容关键词搜索
#[arg(short = 'q', long)]
query: Option<String>,
/// 输出 JSON默认 YAML
#[arg(long)]
json: bool,
},
@ -153,17 +213,25 @@ fn dispatch(cli: Cli) -> Result<()> {
match cli.command {
Commands::Init { force } => init::cmd_init(force),
Commands::Sessions { limit, json } => sessions::cmd_sessions(limit, json),
Commands::History { chat, limit, offset, since, until, json } => {
history::cmd_history(chat, limit, offset, since, until, json)
Commands::History { chat, limit, offset, since, until, msg_type, json } => {
history::cmd_history(chat, limit, offset, since, until, msg_type, json)
}
Commands::Search { keyword, chats, limit, since, until, json } => {
search::cmd_search(keyword, chats, limit, since, until, json)
Commands::Search { keyword, chats, limit, since, until, msg_type, json } => {
search::cmd_search(keyword, chats, limit, since, until, msg_type, json)
}
Commands::Contacts { query, limit, json } => contacts::cmd_contacts(query, limit, json),
Commands::Export { chat, since, until, limit, format, output } => {
export::cmd_export(chat, since, until, limit, format, output)
}
Commands::Watch { chat, json } => watch::cmd_watch(chat, json),
Commands::Unread { limit, json } => unread::cmd_unread(limit, json),
Commands::Members { chat, json } => members::cmd_members(chat, json),
Commands::NewMessages { limit, json } => new_messages::cmd_new_messages(limit, json),
Commands::Stats { chat, since, until, json } => {
stats::cmd_stats(chat, since, until, json)
}
Commands::Favorites { limit, fav_type, query, json } => {
favorites::cmd_favorites(limit, fav_type, query, json)
}
Commands::Daemon { cmd } => daemon_cmd::cmd_daemon(cmd),
}
}

View File

@ -0,0 +1,58 @@
use anyhow::Result;
use std::collections::HashMap;
use crate::ipc::Request;
use super::transport;
use super::output::{resolve, print_value};
fn state_file() -> std::path::PathBuf {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".wx-cli")
.join("last_check.json")
}
/// 加载上次的 per-session 时间戳快照
/// 格式:{ "sessions": { "username": timestamp, ... } }
/// 旧格式(只有 timestamp 字段)直接丢弃,重新全量获取
fn load_state() -> Option<HashMap<String, i64>> {
let data = std::fs::read_to_string(state_file()).ok()?;
let v: serde_json::Value = serde_json::from_str(&data).ok()?;
// 旧格式(只有 timestamp 字段)没有 sessions key → 返回 None 触发首次运行逻辑
let map: HashMap<String, i64> = v.get("sessions")?
.as_object()?
.iter()
.filter_map(|(k, v)| v.as_i64().map(|ts| (k.clone(), ts)))
.collect();
// 空 map 也是合法状态(账号无任何会话),返回 Some(empty) 而非 None
// 这样不会误触发全量历史拉取
Some(map)
}
fn save_state(new_state: &HashMap<String, i64>) -> Result<()> {
let path = state_file();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, serde_json::to_string(&serde_json::json!({ "sessions": new_state }))?)?;
Ok(())
}
pub fn cmd_new_messages(limit: usize, json: bool) -> Result<()> {
let state = load_state();
let resp = transport::send(Request::NewMessages { state, limit })?;
// 保存 daemon 返回的 new_state
if let Some(obj) = resp.data.get("new_state").and_then(|v| v.as_object()) {
let map: HashMap<String, i64> = obj.iter()
.filter_map(|(k, v)| v.as_i64().map(|ts| (k.clone(), ts)))
.collect();
if !map.is_empty() {
let _ = save_state(&map);
}
}
let messages = resp.data.get("messages")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&messages, &resolve(json))
}

18
src/cli/output.rs 100644
View File

@ -0,0 +1,18 @@
/// 输出格式
pub enum Fmt {
Yaml,
Json,
}
/// 默认 YAML--json 时输出 JSON
pub fn resolve(json: bool) -> Fmt {
if json { Fmt::Json } else { Fmt::Yaml }
}
pub fn print_value(value: &serde_json::Value, fmt: &Fmt) -> anyhow::Result<()> {
match fmt {
Fmt::Json => println!("{}", serde_json::to_string_pretty(value)?),
Fmt::Yaml => print!("{}", serde_yaml::to_string(value)?),
}
Ok(())
}

View File

@ -1,7 +1,8 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
use super::history::{parse_time, parse_time_end};
use super::history::{parse_time, parse_time_end, parse_msg_type};
use super::output::{resolve, print_value};
pub fn cmd_search(
keyword: String,
@ -9,53 +10,26 @@ pub fn cmd_search(
limit: usize,
since: Option<String>,
until: Option<String>,
msg_type: Option<String>,
json: bool,
) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let type_val = msg_type.as_deref().and_then(parse_msg_type);
let chats_opt = if chats.is_empty() { None } else { Some(chats) };
let req = Request::Search {
keyword: keyword.clone(),
keyword,
chats: chats_opt,
limit,
since: since_ts,
until: until_ts,
msg_type: type_val,
};
let resp = transport::send(req)?;
let results = resp.data.get("results")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let count = resp.data["count"].as_i64().unwrap_or(results.len() as i64);
if json {
println!("{}", serde_json::to_string_pretty(&results)?);
return Ok(());
}
println!("搜索 \"{}\",找到 {} 条:\n", keyword, count);
for r in &results {
let time = r["time"].as_str().unwrap_or("");
let chat = r["chat"].as_str().unwrap_or("");
let sender = r["sender"].as_str().unwrap_or("");
let content = r["content"].as_str().unwrap_or("");
let chat_str = if !chat.is_empty() {
format!("\x1b[36m[{}]\x1b[0m ", chat)
} else {
String::new()
};
let sender_str = if !sender.is_empty() {
format!("\x1b[33m{}\x1b[0m: ", sender)
} else {
String::new()
};
println!("\x1b[90m[{}]\x1b[0m {}{}{}", time, chat_str, sender_str, content);
}
Ok(())
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&results, &resolve(json))
}

View File

@ -1,44 +1,12 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
use super::output::{resolve, print_value};
pub fn cmd_sessions(limit: usize, json: bool) -> Result<()> {
let resp = transport::send(Request::Sessions { limit })?;
let data = resp.data.get("sessions")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
if json {
println!("{}", serde_json::to_string_pretty(&data)?);
return Ok(());
}
for s in &data {
let time = s["time"].as_str().unwrap_or("");
let chat = s["chat"].as_str().unwrap_or("");
let is_group = s["is_group"].as_bool().unwrap_or(false);
let unread = s["unread"].as_i64().unwrap_or(0);
let msg_type = s["last_msg_type"].as_str().unwrap_or("");
let sender = s["last_sender"].as_str().unwrap_or("");
let summary = s["summary"].as_str().unwrap_or("");
let unread_str = if unread > 0 {
format!(" \x1b[31m({}未读)\x1b[0m", unread)
} else {
String::new()
};
let group_str = if is_group { " [群]" } else { "" };
let sender_str = if !sender.is_empty() {
format!("{}: ", sender)
} else {
String::new()
};
println!("\x1b[90m[{}]\x1b[0m \x1b[1m{}\x1b[0m{}{}", time, chat, group_str, unread_str);
println!(" {}: {}{}", msg_type, sender_str, summary);
println!();
}
Ok(())
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&data, &resolve(json))
}

18
src/cli/stats.rs 100644
View File

@ -0,0 +1,18 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
use super::history::{parse_time, parse_time_end};
use super::output::{resolve, print_value};
pub fn cmd_stats(
chat: String,
since: Option<String>,
until: Option<String>,
json: bool,
) -> Result<()> {
let since_ts = since.as_deref().map(parse_time).transpose()?;
let until_ts = until.as_deref().map(parse_time_end).transpose()?;
let resp = transport::send(Request::Stats { chat, since: since_ts, until: until_ts })?;
print_value(&resp.data, &resolve(json))
}

12
src/cli/unread.rs 100644
View File

@ -0,0 +1,12 @@
use anyhow::Result;
use crate::ipc::Request;
use super::transport;
use super::output::{resolve, print_value};
pub fn cmd_unread(limit: usize, json: bool) -> Result<()> {
let resp = transport::send(Request::Unread { limit })?;
let data = resp.data.get("sessions")
.cloned()
.unwrap_or(serde_json::Value::Array(vec![]));
print_value(&data, &resolve(json))
}

View File

@ -1,94 +0,0 @@
use anyhow::Result;
use std::io::BufRead;
use crate::ipc::Request;
use super::transport;
pub fn cmd_watch(chat: Option<String>, json: bool) -> Result<()> {
transport::ensure_daemon()?;
let sock_path = crate::config::sock_path();
// 连接 socket
#[cfg(unix)]
let mut stream = {
use std::os::unix::net::UnixStream;
UnixStream::connect(&sock_path)?
};
// 发送 watch 请求
let req_line = serde_json::to_string(&Request::Watch)? + "\n";
#[cfg(unix)]
{
use std::io::Write;
stream.write_all(req_line.as_bytes())?;
}
if !json {
eprintln!("监听中Ctrl+C 退出)...\n");
}
#[cfg(windows)]
{
anyhow::bail!("watch 命令在 Windows 上暂不支持,请使用 Unix 系统");
}
#[cfg(unix)]
{
let reader = std::io::BufReader::new(stream.try_clone()?);
for line_result in reader.lines() {
let line = match line_result {
Ok(l) => l,
Err(_) => break,
};
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
let event: serde_json::Value = match serde_json::from_str(&line) {
Ok(v) => v,
Err(_) => continue,
};
let evt = event["event"].as_str().unwrap_or("");
if evt == "connected" || evt == "heartbeat" {
continue;
}
// 过滤指定聊天
if let Some(ref filter_chat) = chat {
let event_chat = event["chat"].as_str().unwrap_or("");
let event_user = event["username"].as_str().unwrap_or("");
if event_chat != filter_chat && event_user != filter_chat {
continue;
}
}
if json {
println!("{}", line);
continue;
}
let time_s = event["time"].as_str().unwrap_or("");
let chat_s = event["chat"].as_str().unwrap_or("");
let is_group = event["is_group"].as_bool().unwrap_or(false);
let sender = event["sender"].as_str().unwrap_or("");
let content = event["content"].as_str().unwrap_or("");
let chat_part = if is_group {
format!("\x1b[36m[{}]\x1b[0m ", chat_s)
} else {
format!("\x1b[1m{}\x1b[0m ", chat_s)
};
let sender_part = if !sender.is_empty() {
format!("\x1b[33m{}\x1b[0m: ", sender)
} else {
String::new()
};
println!("\x1b[90m[{}]\x1b[0m {}{}{}", time_s, chat_part, sender_part, content);
}
}
Ok(())
}

View File

@ -7,6 +7,8 @@ use cbc::cipher::{BlockDecryptMut, KeyIvInit};
use std::io::{Read, Write};
use std::path::Path;
type Block = aes::cipher::Block<Aes256>;
pub const PAGE_SZ: usize = 4096;
pub const SALT_SZ: usize = 16;
pub const RESERVE_SZ: usize = 80; // IV(16) + HMAC(64)
@ -62,16 +64,13 @@ 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
// 将 &[u8] 复制为 Block 数组,避免 unsafe from_raw_parts_mut
let mut blocks: Vec<Block> = data.chunks_exact(16)
.map(Block::clone_from_slice)
.collect();
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)
.decrypt_blocks_mut(&mut blocks);
Ok(blocks.iter().flat_map(|b| b.iter().copied()).collect())
}
/// 完整解密一个 SQLCipher 数据库文件(流式,逐页读写避免全量载入内存)

View File

@ -56,8 +56,7 @@ impl DbCache {
fn cache_file_path(&self, rel_key: &str) -> PathBuf {
let hash = format!("{:x}", md5::compute(rel_key.as_bytes()));
let short = &hash[..12];
self.cache_dir.join(format!("{}.db", short))
self.cache_dir.join(format!("{}.db", hash))
}
/// 从持久化文件加载 mtime 记录,复用未过期的解密文件

View File

@ -4,9 +4,7 @@ pub mod server;
use anyhow::Result;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::broadcast;
use crate::config;
@ -78,154 +76,12 @@ async fn async_run() -> Result<()> {
let names_arc = Arc::new(std::sync::RwLock::new(names));
// 启动 WAL watcher
let (watch_tx, _) = broadcast::channel::<crate::ipc::WatchEvent>(500);
let session_wal = cfg.db_dir.join("session").join("session.db-wal");
// SAFETY: 我们确保 db 和 names_arc 在 daemon 生命周期内有效
// 使用 Arc 传递引用避免 'static 问题
let db_arc = Arc::clone(&db);
let names_arc2 = Arc::clone(&names_arc);
let tx_clone = watch_tx.clone();
let session_wal2 = session_wal.clone();
tokio::spawn(async move {
run_watcher(db_arc, names_arc2, tx_clone, session_wal2).await;
});
// 启动 IPC server阻塞
server::serve(Arc::clone(&db), Arc::clone(&names_arc), watch_tx).await?;
server::serve(Arc::clone(&db), Arc::clone(&names_arc)).await?;
Ok(())
}
async fn run_watcher(
db: Arc<cache::DbCache>,
names: Arc<std::sync::RwLock<query::Names>>,
tx: broadcast::Sender<crate::ipc::WatchEvent>,
session_wal: PathBuf,
) {
use std::collections::HashMap;
use std::time::Duration;
use crate::ipc::WatchEvent;
let mut last_mtime = 0u64;
let mut last_ts: HashMap<String, i64> = HashMap::new();
let mut initialized = false;
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
if tx.receiver_count() == 0 {
continue;
}
let wal_mtime = match mtime_nanos(&session_wal) {
0 => continue,
m => m,
};
if wal_mtime == last_mtime {
continue;
}
last_mtime = wal_mtime;
let path = match db.get("session/session.db").await {
Ok(Some(p)) => p,
_ => continue,
};
let path2 = path.clone();
let rows: Vec<(String, Vec<u8>, i64, i64, String)> = match tokio::task::spawn_blocking(move || {
let conn = rusqlite::Connection::open(&path2)?;
let mut stmt = conn.prepare(
"SELECT username, summary, last_timestamp, last_msg_type, last_msg_sender
FROM SessionTable WHERE last_timestamp > 0
ORDER BY last_timestamp DESC LIMIT 50"
)?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Vec<u8>>(1)
.or_else(|_| row.get::<_, String>(1).map(|s| s.into_bytes()))
.unwrap_or_default(),
row.get::<_, i64>(2)?,
row.get::<_, i64>(3).unwrap_or(0),
row.get::<_, String>(4).unwrap_or_default(),
))
})?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok::<_, anyhow::Error>(rows)
}).await {
Ok(Ok(r)) => r,
_ => continue,
};
let names_guard = match names.read() {
Ok(g) => g,
Err(_) => continue,
};
for (username, summary_bytes, ts, msg_type, sender) in &rows {
if !initialized {
last_ts.insert(username.clone(), *ts);
continue;
}
let prev_ts = last_ts.get(username).copied().unwrap_or(0);
if *ts <= prev_ts {
continue;
}
last_ts.insert(username.clone(), *ts);
let display = names_guard.display(username);
let is_group = username.contains("@chatroom");
let summary = decompress_or_str(summary_bytes);
let summary = if summary.contains(":\n") {
summary.splitn(2, ":\n").nth(1).unwrap_or(&summary).to_string()
} else {
summary
};
let sender_display = if !sender.is_empty() {
names_guard.map.get(sender).cloned().unwrap_or_else(|| sender.clone())
} else {
String::new()
};
let event = WatchEvent {
event: "message".into(),
time: Some(fmt_hhmm(*ts)),
chat: Some(display),
username: Some(username.clone()),
is_group: Some(is_group),
sender: Some(sender_display),
content: Some(summary),
msg_type: Some(query::fmt_type(*msg_type)),
timestamp: Some(*ts),
};
let _ = tx.send(event);
}
if !initialized {
initialized = true;
}
}
}
use cache::mtime_nanos;
fn decompress_or_str(data: &[u8]) -> String {
if data.is_empty() { return String::new(); }
if let Ok(dec) = zstd::decode_all(data) {
if let Ok(s) = String::from_utf8(dec) { return s; }
}
String::from_utf8_lossy(data).into_owned()
}
fn fmt_hhmm(ts: i64) -> String {
use chrono::{Local, TimeZone};
Local.timestamp_opt(ts, 0)
.single()
.map(|dt| dt.format("%H:%M").to_string())
.unwrap_or_else(|| ts.to_string())
}
/// 从 all_keys.json 提取 rel_key -> enc_key 映射
///
/// 兼容两种格式:

View File

@ -1,5 +1,5 @@
use anyhow::{Context, Result};
use chrono::{Local, TimeZone};
use chrono::{Local, TimeZone, Timelike};
use regex::Regex;
use rusqlite::Connection;
use serde_json::{json, Value};
@ -140,6 +140,7 @@ pub async fn q_history(
offset: usize,
since: Option<i64>,
until: Option<i64>,
msg_type: Option<i64>,
) -> Result<Value> {
let username = resolve_username(chat, names)
.with_context(|| format!("找不到联系人: {}", chat))?;
@ -164,7 +165,9 @@ pub async fn q_history(
let offset2 = offset;
let msgs: Vec<Value> = tokio::task::spawn_blocking(move || {
query_messages(&path, &tname, &uname, is_group2, &names_map, since2, until2, limit2 + offset2, 0)
// per-DB 软上限offset + limit 已足够全局分页,避免大群全量加载
let per_db_cap = offset2 + limit2;
query_messages(&path, &tname, &uname, is_group2, &names_map, since2, until2, msg_type, per_db_cap, 0)
}).await??;
all_msgs.extend(msgs);
@ -193,6 +196,7 @@ pub async fn q_search(
limit: usize,
since: Option<i64>,
until: Option<i64>,
msg_type: Option<i64>,
) -> Result<Value> {
let mut targets: Vec<(String, String, String, String)> = Vec::new(); // (path, table, display, uname)
@ -277,7 +281,7 @@ pub async fn q_search(
for (tname, display, uname) in &table_list {
let is_group = uname.contains("@chatroom");
match search_in_table(&conn, tname, &uname, is_group,
&names_map2, &kw2, since2, until2, limit2)
&names_map2, &kw2, since2, until2, msg_type, limit2)
{
Ok(rows) => {
for mut row in rows {
@ -343,19 +347,21 @@ fn resolve_username(chat_name: &str, names: &Names) -> Option<String> {
return Some(chat_name.to_string());
}
let low = chat_name.to_lowercase();
// 精确匹配显示名
for (uname, display) in &names.map {
if low == display.to_lowercase() {
return Some(uname.clone());
}
// 精确匹配显示名:排序后取第一个,保证确定性
let mut exact: Vec<&String> = names.map.iter()
.filter(|(_, display)| display.to_lowercase() == low)
.map(|(uname, _)| uname)
.collect();
exact.sort();
if let Some(u) = exact.into_iter().next() {
return Some(u.clone());
}
// 模糊匹配
for (uname, display) in &names.map {
if display.to_lowercase().contains(&low) {
return Some(uname.clone());
}
}
None
// 模糊匹配:取 display name 最短的(最精确),相同长度取字典序最小
let mut candidates: Vec<(&String, &String)> = names.map.iter()
.filter(|(_, display)| display.to_lowercase().contains(&low))
.collect();
candidates.sort_by_key(|(uname, display)| (display.len(), uname.as_str()));
candidates.into_iter().next().map(|(uname, _)| uname.clone())
}
async fn find_msg_tables(
@ -364,8 +370,7 @@ async fn find_msg_tables(
username: &str,
) -> Result<Vec<(std::path::PathBuf, String)>> {
let table_name = format!("Msg_{:x}", md5::compute(username.as_bytes()));
let re = Regex::new(r"^Msg_[0-9a-f]{32}$").unwrap();
if !re.is_match(&table_name) {
if !msg_table_re().is_match(&table_name) {
return Ok(Vec::new());
}
@ -413,6 +418,7 @@ fn query_messages(
names_map: &HashMap<String, String>,
since: Option<i64>,
until: Option<i64>,
msg_type: Option<i64>,
limit: usize,
offset: usize,
) -> Result<Vec<Value>> {
@ -429,6 +435,10 @@ fn query_messages(
clauses.push("create_time <= ?");
params.push(Box::new(u));
}
if let Some(t) = msg_type {
clauses.push("local_type = ?");
params.push(Box::new(t));
}
let where_clause = if clauses.is_empty() {
String::new()
} else {
@ -487,6 +497,7 @@ fn search_in_table(
keyword: &str,
since: Option<i64>,
until: Option<i64>,
msg_type: Option<i64>,
limit: usize,
) -> Result<Vec<Value>> {
let id2u = load_id2u(conn);
@ -502,6 +513,10 @@ fn search_in_table(
clauses.push("create_time <= ?".into());
params.push(Box::new(u));
}
if let Some(t) = msg_type {
clauses.push("local_type = ?".into());
params.push(Box::new(t));
}
let where_clause = format!("WHERE {}", clauses.join(" AND "));
let sql = format!(
"SELECT local_id, local_type, create_time, real_sender_id,
@ -761,3 +776,639 @@ fn fmt_time(ts: i64, fmt: &str) -> String {
.unwrap_or_else(|| ts.to_string())
}
// ─── 新增命令查询函数 ──────────────────────────────────────────────────────────
/// 查询有未读消息的会话
pub async fn q_unread(db: &DbCache, names: &Names, limit: usize) -> Result<Value> {
let path = db.get("session/session.db").await?
.context("无法解密 session.db")?;
let limit_val = limit;
let rows: Vec<(String, i64, Vec<u8>, i64, i64, String, String)> = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&path)?;
let mut stmt = conn.prepare(
"SELECT username, unread_count, summary, last_timestamp,
last_msg_type, last_msg_sender, last_sender_display_name
FROM SessionTable
WHERE unread_count > 0
ORDER BY last_timestamp DESC LIMIT ?"
)?;
let rows = stmt.query_map([limit_val as i64], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, i64>(1).unwrap_or(0),
get_content_bytes(row, 2),
row.get::<_, i64>(3).unwrap_or(0),
row.get::<_, i64>(4).unwrap_or(0),
row.get::<_, String>(5).unwrap_or_default(),
row.get::<_, String>(6).unwrap_or_default(),
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok::<_, anyhow::Error>(rows)
}).await??;
let mut results = Vec::new();
for (username, unread, summary_bytes, ts, msg_type, sender, sender_name) in rows {
let display = names.display(&username);
let is_group = username.contains("@chatroom");
let summary = decompress_or_str(&summary_bytes);
let summary = strip_group_prefix(&summary);
let sender_display = if is_group && !sender.is_empty() {
names.map.get(&sender).cloned().unwrap_or_else(|| {
if !sender_name.is_empty() { sender_name.clone() } else { sender.clone() }
})
} else {
String::new()
};
results.push(json!({
"chat": display,
"username": username,
"is_group": is_group,
"unread": unread,
"last_msg_type": fmt_type(msg_type),
"last_sender": sender_display,
"summary": summary,
"timestamp": ts,
"time": fmt_time(ts, "%m-%d %H:%M"),
}));
}
let total = results.len();
Ok(json!({ "sessions": results, "total": total }))
}
/// 查询群成员:优先从 contact.db 的 chatroom_member/chat_room 表获取完整列表,
/// 若表不存在则退化为从消息记录聚合有发言记录的成员
pub async fn q_members(db: &DbCache, names: &Names, chat: &str) -> Result<Value> {
let username = resolve_username(chat, names)
.with_context(|| format!("找不到联系人: {}", chat))?;
if !username.contains("@chatroom") {
anyhow::bail!("'{}' 不是群聊,无法查看群成员", names.display(&username));
}
let display = names.display(&username);
let names_map = names.map.clone();
// 优先路径contact.db → chatroom_member + chat_room完整成员列表
if let Some(contact_p) = db.get("contact/contact.db").await? {
let uname2 = username.clone();
let display2 = display.clone();
let names_map2 = names_map.clone();
let members_opt: Option<Vec<Value>> = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&contact_p)?;
let has_table: bool = conn.query_row(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='chatroom_member'",
[],
|_| Ok(true),
).unwrap_or(false);
if !has_table {
return Ok::<_, anyhow::Error>(None);
}
// 从 chat_room 表获取整数 room_id 和群主
// WeChat 不同版本列名可能不同username / chat_room_name / name
let (room_id, owner): (i64, String) = [
"SELECT id, owner FROM chat_room WHERE username = ?",
"SELECT id, owner FROM chat_room WHERE chat_room_name = ?",
"SELECT id, owner FROM chat_room WHERE name = ?",
].iter().find_map(|sql| {
conn.query_row(sql, [&uname2], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1).unwrap_or_default()))
}).ok()
}).unwrap_or((0, String::new()));
if room_id == 0 {
return Ok::<_, anyhow::Error>(None);
}
let mut stmt = conn.prepare(
"SELECT c.username, c.nick_name, c.remark
FROM chatroom_member cm
LEFT JOIN contact c ON c.id = cm.member_id
WHERE cm.room_id = ?"
)?;
let raw: Vec<(String, String, String)> = stmt.query_map([room_id], |row| {
Ok((
row.get::<_, String>(0).unwrap_or_default(),
row.get::<_, String>(1).unwrap_or_default(),
row.get::<_, String>(2).unwrap_or_default(),
))
})?
.filter_map(|r| r.ok())
.filter(|(uid, _, _)| !uid.is_empty())
.collect();
if raw.is_empty() {
return Ok(None);
}
let mut members: Vec<Value> = raw.iter().map(|(uid, nick, remark)| {
let disp = if !remark.is_empty() { remark.clone() }
else if !nick.is_empty() { nick.clone() }
else { names_map2.get(uid).cloned().unwrap_or_else(|| uid.clone()) };
let is_owner = uid == &owner && !owner.is_empty();
json!({ "username": uid, "display": disp, "is_owner": is_owner })
}).collect();
// 群主排首位,其余按 display 字典序
members.sort_by(|a, b| {
let ao = a["is_owner"].as_bool().unwrap_or(false);
let bo = b["is_owner"].as_bool().unwrap_or(false);
if ao != bo { return bo.cmp(&ao); }
a["display"].as_str().unwrap_or("").cmp(b["display"].as_str().unwrap_or(""))
});
let _ = display2; // 不在此 closure 内使用
Ok(Some(members))
}).await??;
if let Some(members) = members_opt {
return Ok(json!({
"chat": display,
"username": username,
"count": members.len(),
"members": members,
}));
}
}
// 降级路径:从消息记录中聚合发言过的成员
let tables = find_msg_tables(db, names, &username).await?;
if tables.is_empty() {
return Ok(json!({
"chat": display,
"username": username,
"count": 0,
"members": [],
}));
}
let mut sender_set: std::collections::HashSet<String> = std::collections::HashSet::new();
for (db_path, table_name) in &tables {
let path = db_path.clone();
let tname = table_name.clone();
let uname = username.clone();
let senders: Vec<String> = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&path)?;
let id2u = load_id2u(&conn);
let mut stmt = conn.prepare(&format!(
"SELECT DISTINCT real_sender_id FROM [{}] WHERE real_sender_id > 0", tname
))?;
let ids: Vec<i64> = stmt.query_map([], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
let senders: Vec<String> = ids.iter()
.filter_map(|id| id2u.get(id))
.filter(|u| *u != &uname)
.cloned()
.collect();
Ok::<_, anyhow::Error>(senders)
}).await??;
sender_set.extend(senders);
}
let mut members: Vec<Value> = sender_set.iter().map(|u| {
json!({
"username": u,
"display": names_map.get(u).cloned().unwrap_or_else(|| u.clone()),
"is_owner": false,
})
}).collect();
members.sort_by(|a, b| {
a["display"].as_str().unwrap_or("").cmp(b["display"].as_str().unwrap_or(""))
});
Ok(json!({
"chat": display,
"username": username,
"count": members.len(),
"members": members,
}))
}
/// 查询新消息:以 session.db 的 last_timestamp 作为 inbox 索引,
/// 只查询 last_timestamp > state[username] 的会话,精确且高效
pub async fn q_new_messages(
db: &DbCache,
names: &Names,
state: Option<HashMap<String, i64>>,
limit: usize,
) -> Result<Value> {
// 首次运行state=None或未见过的会话用 24h 前作为起点,
// 避免第一次运行时把全量历史消息涌入
let fallback_ts = chrono::Utc::now().timestamp() - 86400;
// 1. 从 session.db 读取所有会话的当前 last_timestamp
let session_path = db.get("session/session.db").await?
.context("无法解密 session.db")?;
let all_sessions: Vec<(String, i64)> = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&session_path)?;
let mut stmt = conn.prepare(
"SELECT username, last_timestamp FROM SessionTable WHERE last_timestamp > 0"
)?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1).unwrap_or(0)))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok::<_, anyhow::Error>(rows)
}).await??;
// 2. 记录 session.db 的当前快照(用于构建 new_state 基础)
let session_ts_map: HashMap<String, i64> = all_sessions.iter()
.map(|(u, ts)| (u.clone(), *ts))
.collect();
// 3. 找出有新消息的会话
// 不在 state 中的会话(首次运行或新会话)以 fallback_ts 为基准
let changed: Vec<(String, i64)> = all_sessions.into_iter()
.filter(|(uname, ts)| {
let last_known = state.as_ref()
.and_then(|m| m.get(uname))
.copied()
.unwrap_or(fallback_ts);
*ts > last_known
})
.collect();
if changed.is_empty() {
return Ok(json!({
"count": 0,
"messages": [],
"new_state": session_ts_map,
}));
}
// 4. 只查询有新消息的会话的消息表
// per_table_limit 取 limit*5 防止单表截断,最终由全局 truncate 收尾
let per_table_limit = limit.saturating_mul(5).max(200);
let mut all_msgs: Vec<Value> = Vec::new();
for (uname, _) in &changed {
let since_ts = state.as_ref()
.and_then(|m| m.get(uname))
.copied()
.unwrap_or(fallback_ts);
let tables = find_msg_tables(db, names, uname).await?;
if tables.is_empty() { continue; }
let display = names.display(uname);
let is_group = uname.contains("@chatroom");
for (db_path, table_name) in &tables {
let path = db_path.clone();
let tname = table_name.clone();
let uname2 = uname.clone();
let display2 = display.clone();
let names_map = names.map.clone();
let tname_for_log = tname.clone();
let msgs: Vec<Value> = match tokio::task::spawn_blocking(move || {
let conn = Connection::open(&path)?;
let id2u = load_id2u(&conn);
let sql = format!(
"SELECT local_id, local_type, create_time, real_sender_id,
message_content, WCDB_CT_message_content
FROM [{}] WHERE create_time > ? ORDER BY create_time ASC LIMIT ?",
tname
);
let rows: Vec<_> = conn.prepare(&sql)
.and_then(|mut stmt| {
stmt.query_map(
rusqlite::params![since_ts, per_table_limit as i64],
|row| Ok((
row.get::<_, i64>(0)?,
row.get::<_, i64>(1)?,
row.get::<_, i64>(2)?,
row.get::<_, i64>(3)?,
get_content_bytes(row, 4),
row.get::<_, i64>(5).unwrap_or(0),
)),
).map(|it| it.filter_map(|r| r.ok()).collect())
})
.unwrap_or_default();
let mut result = Vec::new();
for (local_id, local_type, ts, real_sender_id, content_bytes, ct) in rows {
let content = decompress_message(&content_bytes, ct);
let sender = sender_label(real_sender_id, &content, is_group, &uname2, &id2u, &names_map);
let text = fmt_content(local_id, local_type, &content, is_group);
result.push(json!({
"chat": display2,
"username": uname2,
"is_group": is_group,
"timestamp": ts,
"time": fmt_time(ts, "%Y-%m-%d %H:%M"),
"sender": sender,
"content": text,
"type": fmt_type(local_type),
}));
}
Ok::<_, anyhow::Error>(result)
}).await {
Ok(Ok(v)) => v,
Ok(Err(e)) => { eprintln!("[new-messages] skip {}: {}", tname_for_log, e); continue; }
Err(e) => { eprintln!("[new-messages] task error: {}", e); continue; }
};
all_msgs.extend(msgs);
}
}
all_msgs.sort_by_key(|m| m["timestamp"].as_i64().unwrap_or(0));
all_msgs.truncate(limit);
// 5. 重建 new_state防止全局 limit 截断导致消息永久丢失:
// - 未变化的会话:沿用 session.db 的 last_timestamp
// - 变化但全被截断(无消息在最终结果中):保留旧 since_ts下次重试
// - 变化且有消息返回:推进到该会话在结果中的最大 timestamp
let mut new_state = session_ts_map;
// 先把 changed 会话重置回旧 since_ts
for (uname, _) in &changed {
let old_ts = state.as_ref()
.and_then(|m| m.get(uname))
.copied()
.unwrap_or(fallback_ts);
new_state.insert(uname.clone(), old_ts);
}
// 再根据实际返回的消息向前推进
for m in &all_msgs {
if let (Some(uname), Some(ts)) = (m["username"].as_str(), m["timestamp"].as_i64()) {
let e = new_state.entry(uname.to_string()).or_insert(0);
if ts > *e { *e = ts; }
}
}
Ok(json!({
"count": all_msgs.len(),
"messages": all_msgs,
"new_state": new_state,
}))
}
/// 查询收藏内容favorite/favorite.db 的 fav_db_item 表)
pub async fn q_favorites(
db: &DbCache,
limit: usize,
fav_type: Option<i64>,
query: Option<String>,
) -> Result<Value> {
let path = db.get("favorite/favorite.db").await?
.context("找不到 favorite.db请确认微信数据目录")?;
let rows: Vec<Value> = tokio::task::spawn_blocking(move || {
let conn = Connection::open(&path)?;
let mut clauses: Vec<&'static str> = Vec::new();
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(t) = fav_type {
clauses.push("type = ?");
params.push(Box::new(t));
}
let like_str: Option<String> = query.map(|q| {
let esc = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
format!("%{}%", esc)
});
if let Some(ref s) = like_str {
clauses.push("content LIKE ? ESCAPE '\\'");
params.push(Box::new(s.clone()));
}
let where_clause = if clauses.is_empty() {
String::new()
} else {
format!("WHERE {}", clauses.join(" AND "))
};
params.push(Box::new(limit as i64));
let sql = format!(
"SELECT local_id, type, update_time, content, fromusr, realchatname
FROM fav_db_item {} ORDER BY update_time DESC LIMIT ?",
where_clause
);
let params_ref: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows: Vec<Value> = stmt.query_map(params_ref.as_slice(), |row| {
Ok((
row.get::<_, i64>(0).unwrap_or(0),
row.get::<_, i64>(1).unwrap_or(0),
row.get::<_, i64>(2).unwrap_or(0),
row.get::<_, String>(3).unwrap_or_default(),
row.get::<_, String>(4).unwrap_or_default(),
row.get::<_, String>(5).unwrap_or_default(),
))
})?
.filter_map(|r| r.ok())
.map(|(local_id, ftype, ts, content, fromusr, chatname)| {
let type_str = match ftype {
1 => "文本",
2 => "图片",
5 => "文章",
19 => "名片",
20 => "视频",
_ => "其他",
};
// 安全截断(按 Unicode 字符而非字节)
let preview: String = content.chars().take(100).collect();
let preview = if content.chars().count() > 100 {
format!("{}...", preview)
} else {
preview
};
// WeChat 部分版本的 update_time 为毫秒10位以上判定为毫秒后转秒
let ts_secs = if ts > 9_999_999_999 { ts / 1000 } else { ts };
json!({
"id": local_id,
"type": type_str,
"type_num": ftype,
"time": fmt_time(ts_secs, "%Y-%m-%d %H:%M"),
"timestamp": ts_secs,
"preview": preview,
"from": fromusr,
"chat": chatname,
})
})
.collect();
Ok::<_, anyhow::Error>(rows)
}).await??;
Ok(json!({
"count": rows.len(),
"items": rows,
}))
}
/// 聊天统计消息总数、类型分布、发言排行、24小时分布
pub async fn q_stats(
db: &DbCache,
names: &Names,
chat: &str,
since: Option<i64>,
until: Option<i64>,
) -> Result<Value> {
let username = resolve_username(chat, names)
.with_context(|| format!("找不到联系人: {}", chat))?;
let display = names.display(&username);
let is_group = username.contains("@chatroom");
let tables = find_msg_tables(db, names, &username).await?;
if tables.is_empty() {
anyhow::bail!("找不到 {} 的消息记录", display);
}
// 跨所有分片 DB 累计统计
let mut total: i64 = 0;
let mut type_counts: HashMap<String, i64> = HashMap::new();
let mut sender_counts: HashMap<String, i64> = HashMap::new();
let mut hour_counts = [0i64; 24];
for (db_path, table_name) in &tables {
let path = db_path.clone();
let tname = table_name.clone();
let uname = username.clone();
let is_group2 = is_group;
let names_map = names.map.clone();
// 用 SQL GROUP BY 在数据库侧聚合,避免把全量消息内容加载进内存
let result: (i64, HashMap<String, i64>, HashMap<String, i64>, [i64; 24]) =
tokio::task::spawn_blocking(move || {
let conn = Connection::open(&path)?;
let id2u = load_id2u(&conn);
let mut clauses = Vec::new();
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(s) = since {
clauses.push("create_time >= ?");
params.push(Box::new(s));
}
if let Some(u) = until {
clauses.push("create_time <= ?");
params.push(Box::new(u));
}
let where_clause = if clauses.is_empty() {
String::new()
} else {
format!("WHERE {}", clauses.join(" AND "))
};
let params_ref: Vec<&dyn rusqlite::types::ToSql> =
params.iter().map(|p| p.as_ref()).collect();
// 1. 总数
let count: i64 = conn.query_row(
&format!("SELECT COUNT(*) FROM [{}] {}", tname, where_clause),
params_ref.as_slice(),
|row| row.get(0),
).unwrap_or(0);
// 2. 类型分布SQL GROUP BY不加载消息内容
let type_sql = format!(
"SELECT (local_type & 0xFFFFFFFF), COUNT(*) FROM [{}] {} GROUP BY (local_type & 0xFFFFFFFF)",
tname, where_clause
);
let mut type_c: HashMap<String, i64> = HashMap::new();
if let Ok(mut stmt) = conn.prepare(&type_sql) {
let _ = stmt.query_map(params_ref.as_slice(), |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
}).map(|rows| {
for r in rows.flatten() {
*type_c.entry(fmt_type(r.0)).or_insert(0) += r.1;
}
});
}
// 3. 小时分布:只取时间戳,不加载消息内容
let hour_sql = format!(
"SELECT create_time FROM [{}] {}",
tname, where_clause
);
let mut hour_c = [0i64; 24];
if let Ok(mut stmt) = conn.prepare(&hour_sql) {
let _ = stmt.query_map(params_ref.as_slice(), |row| row.get::<_, i64>(0))
.map(|rows| {
for ts in rows.flatten() {
if let Some(dt) = Local.timestamp_opt(ts, 0).single() {
let h = dt.hour() as usize;
if h < 24 { hour_c[h] += 1; }
}
}
});
}
// 4. 发言排行:只取 real_sender_id不加载消息内容
// where_clause 可能已含 WHERE用 AND 追加而非重复写 WHERE
let sender_filter = if where_clause.is_empty() {
"WHERE real_sender_id > 0".to_string()
} else {
format!("{} AND real_sender_id > 0", where_clause)
};
let sender_sql = format!(
"SELECT real_sender_id, COUNT(*) FROM [{}] {} GROUP BY real_sender_id",
tname, sender_filter
);
let mut sender_c: HashMap<String, i64> = HashMap::new();
if is_group2 {
if let Ok(mut stmt) = conn.prepare(&sender_sql) {
let _ = stmt.query_map(params_ref.as_slice(), |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
}).map(|rows| {
for (id, cnt) in rows.flatten() {
if let Some(u) = id2u.get(&id) {
if u != &uname {
let name = names_map.get(u).cloned().unwrap_or_else(|| u.clone());
*sender_c.entry(name).or_insert(0) += cnt;
}
}
}
});
}
}
Ok::<_, anyhow::Error>((count, type_c, sender_c, hour_c))
}).await??;
let (count, type_c, sender_c, hour_c) = result;
total += count;
for (k, v) in type_c { *type_counts.entry(k).or_insert(0) += v; }
for (k, v) in sender_c { *sender_counts.entry(k).or_insert(0) += v; }
for i in 0..24 { hour_counts[i] += hour_c[i]; }
}
// 类型分布,按数量降序
let mut by_type: Vec<Value> = type_counts.iter()
.map(|(t, c)| json!({ "type": t, "count": c }))
.collect();
by_type.sort_by_key(|v| std::cmp::Reverse(v["count"].as_i64().unwrap_or(0)));
// 发言排行Top 10
let mut top_senders: Vec<Value> = sender_counts.iter()
.map(|(s, c)| json!({ "sender": s, "count": c }))
.collect();
top_senders.sort_by_key(|v| std::cmp::Reverse(v["count"].as_i64().unwrap_or(0)));
top_senders.truncate(10);
// 24小时分布
let by_hour: Vec<Value> = hour_counts.iter().enumerate()
.map(|(h, c)| json!({ "hour": h, "count": c }))
.collect();
Ok(json!({
"chat": display,
"username": username,
"is_group": is_group,
"total": total,
"by_type": by_type,
"top_senders": top_senders,
"by_hour": by_hour,
}))
}

View File

@ -1,10 +1,8 @@
use anyhow::Result;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::sync::broadcast;
use crate::ipc::{Request, Response, WatchEvent};
use crate::ipc::{Request, Response};
use super::cache::DbCache;
use super::query::Names;
@ -12,12 +10,11 @@ use super::query::Names;
pub async fn serve(
db: Arc<DbCache>,
names: Arc<std::sync::RwLock<Names>>,
watch_tx: broadcast::Sender<WatchEvent>,
) -> Result<()> {
#[cfg(unix)]
serve_unix(db, names, watch_tx).await?;
serve_unix(db, names).await?;
#[cfg(windows)]
serve_windows(db, names, watch_tx).await?;
serve_windows(db, names).await?;
Ok(())
}
@ -25,7 +22,6 @@ pub async fn serve(
async fn serve_unix(
db: Arc<DbCache>,
names: Arc<std::sync::RwLock<Names>>,
watch_tx: broadcast::Sender<WatchEvent>,
) -> Result<()> {
use tokio::net::UnixListener;
let sock_path = crate::config::sock_path();
@ -49,10 +45,9 @@ async fn serve_unix(
let (stream, _) = listener.accept().await?;
let db2 = Arc::clone(&db);
let names2 = Arc::clone(&names);
let tx2 = watch_tx.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection_unix(stream, db2, names2, tx2).await {
if let Err(e) = handle_connection_unix(stream, db2, names2).await {
eprintln!("[server] 连接处理错误: {}", e);
}
});
@ -64,7 +59,6 @@ async fn handle_connection_unix(
stream: tokio::net::UnixStream,
db: Arc<DbCache>,
names: Arc<std::sync::RwLock<Names>>,
watch_tx: broadcast::Sender<WatchEvent>,
) -> Result<()> {
let (reader, mut writer) = stream.into_split();
let mut lines = BufReader::new(reader).lines();
@ -84,41 +78,8 @@ async fn handle_connection_unix(
}
};
match req {
Request::Watch => {
// 流式模式:持续推送事件
let mut rx = watch_tx.subscribe();
let connected = WatchEvent::connected();
writer.write_all(connected.to_json_line()?.as_bytes()).await?;
loop {
tokio::select! {
event = rx.recv() => {
match event {
Ok(e) => {
if writer.write_all(e.to_json_line()?.as_bytes()).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(_) => break,
}
}
_ = tokio::time::sleep(Duration::from_secs(30)) => {
// 心跳
let hb = WatchEvent::heartbeat();
if writer.write_all(hb.to_json_line()?.as_bytes()).await.is_err() {
break;
}
}
}
}
}
other => {
let resp = dispatch(other, &db, &names).await;
writer.write_all(resp.to_json_line()?.as_bytes()).await?;
}
}
let resp = dispatch(req, &db, &names).await;
writer.write_all(resp.to_json_line()?.as_bytes()).await?;
Ok(())
}
@ -126,7 +87,6 @@ async fn handle_connection_unix(
async fn serve_windows(
db: Arc<DbCache>,
names: Arc<std::sync::RwLock<Names>>,
watch_tx: broadcast::Sender<WatchEvent>,
) -> Result<()> {
use interprocess::local_socket::{
tokio::prelude::*, GenericNamespaced, ListenerOptions,
@ -143,10 +103,9 @@ async fn serve_windows(
let conn = listener.accept().await?;
let db2 = Arc::clone(&db);
let names2 = Arc::clone(&names);
let tx2 = watch_tx.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection_generic(conn, db2, names2, tx2).await {
if let Err(e) = handle_connection_generic(conn, db2, names2).await {
eprintln!("[server] 连接处理错误: {}", e);
}
});
@ -164,7 +123,6 @@ async fn dispatch(
match req {
Ping => Response::ok(serde_json::json!({ "pong": true })),
Sessions { limit } => {
// 在 await 前获取并复制所需数据,避免 RwLockGuard 跨 await
let names_snapshot = match clone_names(names) {
Ok(n) => n,
Err(e) => return Response::err(e),
@ -174,22 +132,22 @@ async fn dispatch(
Err(e) => Response::err(e.to_string()),
}
}
History { chat, limit, offset, since, until } => {
History { chat, limit, offset, since, until, msg_type } => {
let names_snapshot = match clone_names(names) {
Ok(n) => n,
Err(e) => return Response::err(e),
};
match query::q_history(db, &names_snapshot, &chat, limit, offset, since, until).await {
match query::q_history(db, &names_snapshot, &chat, limit, offset, since, until, msg_type).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
Search { keyword, chats, limit, since, until } => {
Search { keyword, chats, limit, since, until, msg_type } => {
let names_snapshot = match clone_names(names) {
Ok(n) => n,
Err(e) => return Response::err(e),
};
match query::q_search(db, &names_snapshot, &keyword, chats, limit, since, until).await {
match query::q_search(db, &names_snapshot, &keyword, chats, limit, since, until, msg_type).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
@ -204,7 +162,52 @@ async fn dispatch(
Err(e) => Response::err(e.to_string()),
}
}
Watch => Response::err("Watch 命令不应通过 dispatch 处理"),
Unread { limit } => {
let names_snapshot = match clone_names(names) {
Ok(n) => n,
Err(e) => return Response::err(e),
};
match query::q_unread(db, &names_snapshot, limit).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
Members { chat } => {
let names_snapshot = match clone_names(names) {
Ok(n) => n,
Err(e) => return Response::err(e),
};
match query::q_members(db, &names_snapshot, &chat).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
NewMessages { state, limit } => {
let names_snapshot = match clone_names(names) {
Ok(n) => n,
Err(e) => return Response::err(e),
};
match query::q_new_messages(db, &names_snapshot, state, limit).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
Favorites { limit, fav_type, query } => {
match query::q_favorites(db, limit, fav_type, query).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
Stats { chat, since, until } => {
let names_snapshot = match clone_names(names) {
Ok(n) => n,
Err(e) => return Response::err(e),
};
match query::q_stats(db, &names_snapshot, &chat, since, until).await {
Ok(v) => Response::ok(v),
Err(e) => Response::err(e.to_string()),
}
}
}
}

View File

@ -1,3 +1,4 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
@ -20,6 +21,8 @@ pub enum Request {
since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
msg_type: Option<i64>,
},
Search {
keyword: String,
@ -31,6 +34,8 @@ pub enum Request {
since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
msg_type: Option<i64>,
},
Contacts {
#[serde(skip_serializing_if = "Option::is_none")]
@ -38,7 +43,38 @@ pub enum Request {
#[serde(default = "default_limit_50")]
limit: usize,
},
Watch,
Unread {
#[serde(default = "default_limit_20")]
limit: usize,
},
Members {
chat: String,
},
NewMessages {
/// 上次检查时各会话的 last_timestamp 快照username -> ts
/// None 表示首次运行,会返回 new_state 供下次使用
#[serde(skip_serializing_if = "Option::is_none")]
state: Option<HashMap<String, i64>>,
#[serde(default = "default_limit_200")]
limit: usize,
},
Stats {
chat: String,
#[serde(skip_serializing_if = "Option::is_none")]
since: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
until: Option<i64>,
},
Favorites {
#[serde(default = "default_limit_50")]
limit: usize,
/// 类型过滤1=文本,2=图片,5=文章,19=名片,20=视频
#[serde(skip_serializing_if = "Option::is_none")]
fav_type: Option<i64>,
/// 内容关键词搜索
#[serde(skip_serializing_if = "Option::is_none")]
query: Option<String>,
},
}
@ -69,48 +105,4 @@ impl Response {
fn default_limit_20() -> usize { 20 }
fn default_limit_50() -> usize { 50 }
/// Watch 事件daemon -> CLI 流式推送)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WatchEvent {
pub event: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub time: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub chat: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_group: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sender: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub msg_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<i64>,
}
impl WatchEvent {
pub fn connected() -> Self {
Self {
event: "connected".into(),
time: None, chat: None, username: None, is_group: None,
sender: None, content: None, msg_type: None, timestamp: None,
}
}
pub fn heartbeat() -> Self {
Self {
event: "heartbeat".into(),
time: None, chat: None, username: None, is_group: None,
sender: None, content: None, msg_type: None, timestamp: None,
}
}
pub fn to_json_line(&self) -> anyhow::Result<String> {
let s = serde_json::to_string(self)?;
Ok(s + "\n")
}
}
fn default_limit_200() -> usize { 200 }

View File

@ -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()));
}
}

View File

@ -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();
}
}