USN 日志在本质上就是敏感数据。一台企业工作站上 100 MB 的 $J 文件,一行行记录着该机器近期完整的文件历史:用户碰过的每一个 Word 文档、运行过的每一个可执行程序、浏览器缓存的每一次淘汰。把它上传到一个 SaaS 让它「替你分析」,是任何一位取证专业人士都不该答应、也是我们永远不愿提供的事情。
所以我们把 usnparser.com 反过来构建:解析器在你的浏览器里运行。文件从磁盘读入 JavaScript,交给一个 WebAssembly 模块,记录返回时没有任何一个字节离开过你的设备。
本文是其工作机制的技术演练。
Rust crate
我们没有重新发明轮子:实际的解析逻辑来自 Airbus CERT 对 USN_RECORD_V2 的干净实现 usnrs。它已经暴露了 Read + Seek 接口 — 正是我们需要的。这正好是 std::io::Cursor<Vec<u8>> 实现的 trait,所以我们可以从一个 JS 的 Uint8Array 把原始字节喂进去。
包装 crate 大约 60 行 Rust:
#[wasm_bindgen(js_name = parseUsn)]
pub fn parse_usn(
usn_bytes: &[u8],
mft_bytes: Option<Box<[u8]>>,
) -> Result<JsValue, JsValue> {
let usn = Cursor::new(usn_bytes.to_vec());
let mft = mft_bytes
.map(|b| MftParser::from_buffer(b.into_vec()))
.transpose()?;
let iter = Usn::new(mft, usn, None)?;
let records: Vec<UsnRecord> = iter.map(into_record).collect();
serde_wasm_bindgen::to_value(&records)
}
它接收日志字节,可选地接收 $MFT 字节以做完整路径解析。整体经过 wasm-opt 后约 105 KB 的 .wasm。
Cargo 的三个小坑
要让 usnrs 及其传递依赖干净地交叉编译到 wasm32-unknown-unknown,需要三个小绕过:
getrandom在 wasm32 上需要jsfeature。randcrate (被mft间接拉入) 传递依赖于它,如果没有 JS 后端,wasm 构建会以 "no available getrandom backend" 失败。我们在自己的Cargo.toml中强制开启:[target.'cfg(target_arch = "wasm32")'.dependencies] getrandom = { version = "0.2", features = ["js"] }chrono启用clockfeature 时需要wasmbind,否则它会尝试调用time(2)。我们通过自己的直接声明把它加上。- 关闭 mft 的默认 feature 其实没必要 —
mft_dumpfeature 只额外拉入可选的 CLI 依赖,这些依赖也都能交叉编译。
浏览器端的胶水
构建命令是 wasm-pack build --target web --out-dir public/wasm,它产出一个小的 ES 模块 JS shim 和 .wasm 二进制。两者都位于 /public/wasm/ 下,以稳定 URL 作为静态资源对外提供。
解析器活在一个 Web Worker 里,这样在解析器啃下百万条记录时主线程仍能保持响应:
// public/workers/parse.js
import init, { parseUsn } from "/wasm/usn_wasm.js";
await init();
self.onmessage = (event) => {
const { usnBytes, mftBytes } = event.data;
const records = parseUsn(
new Uint8Array(usnBytes),
mftBytes ? new Uint8Array(mftBytes) : null,
);
self.postMessage({ type: "result", records });
};
这是 wasm 与 worker 唯一相遇的地方。无论是 worker 还是 wasm 都不经过 Next.js 的打包器,这意味着 零 webpack/Turbopack 配置。
数字
来自一台 Windows 11 工作站的代表性 60 MB $J:
- 解析时间: 在较新的 Macbook 上约 1.4 秒
- 内存: 临时占用,worker 结束时释放
- 产生的记录数: 约 720,000 条
- 离开你设备的字节数: 0
UI 接着用 TanStack Virtual 对表格做虚拟化,因此即便是百万行的结果也能感觉到瞬时响应。
那超大的日志怎么办?
对于 500 MB 以上的日志,我们会切换到 流式 API,按批次返回记录而不是把全部塞进一个 Vec。改动很小 — Usn 已经是一个 Iterator,只需要在 wasm 一侧暴露 next_batch(n)。我们没有先发布这个,是因为还没收到有人需要的反馈。如果你需要,开一个 issue。
为什么这件事重要
取证工具历史上一直被一分为二:厚重的桌面套件 (X-Ways、EnCase),以及那些需要你安装、并把敏感数据交给它们的 Python 脚本。还有第三条路:开放、可审视、完全在客户端运行。今天的 WebAssembly 已经足够快,没有任何性能上的借口不去走这条路。