A USN journal is one of the most sensitive files an analyst will ever touch. A 100 MB $J from a corporate workstation is a complete recent history of that machine: every document the user opened, every executable that ran, every cache eviction, every save-by-rename in their editor. Asking a forensic professional to upload that to a SaaS endpoint so somebody else's server can parse it is the kind of request that should get the SaaS removed from the toolset, not the journal uploaded. We never wanted to be that SaaS.
So this site does the inverse: the parser runs in your browser. The journal is read off disk into JavaScript, handed to a WebAssembly module, and the records come back without a single byte leaving the machine. This post walks through how that actually works, the Rust crate we leaned on, the three Cargo gotchas that cost an evening, and the numbers on a representative file.
The Rust crate
No reinvention. The parsing logic comes from usnrs, Airbus CERT's clean USN_RECORD_V2 implementation. It already exposes a Read + Seek interface, which matches exactly what std::io::Cursor<Vec<u8>> gives you when you have raw bytes in memory — exactly the shape of a Uint8Array arriving from the browser side.
The wrapper crate is around 60 lines of Rust. The entry point is:
#[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)
}
It takes the journal bytes and, optionally, the $MFT bytes for full-path resolution. With wasm-opt the final .wasm artefact is about 105 KB.
Three Cargo gotchas
Compiling usnrs plus its transitive dependencies cleanly for wasm32-unknown-unknown is not free. Three things bit us:
getrandom needs the js feature on wasm32. The rand crate (pulled in by mft) depends on it transitively, and without the JS backend the wasm build fails with "no available getrandom backend". Force it in Cargo.toml:
[target.'cfg(target_arch = "wasm32")'.dependencies]
getrandom = { version = "0.2", features = ["js"] }
chrono needs wasmbind when the clock feature is enabled. Without it, chrono tries to call time(2), which does not exist in wasm32-unknown-unknown. Add features = ["wasmbind"] to the direct dependency declaration.
mft default features. The mft_dump feature pulls in CLI dependencies that turn out to cross-compile fine, but they bloat the wasm artefact. We disable defaults and re-enable only what we need.
None of these required forking anything. Two lines of Cargo.toml solve the lot.
The browser-side glue
The build is wasm-pack build --target web --out-dir public/wasm, which produces a small ES-module JS shim and the .wasm binary. Both live under /public/wasm/ and are served as static assets at known URLs.
The parser runs in a Web Worker so the main thread stays responsive while the wasm module chews through a million records:
// 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 });
};
This is the only place the wasm module and the worker meet. Neither goes through the Next.js bundler. No webpack or Turbopack configuration was harmed.
Numbers from a representative file
A 60 MB $J from a Windows 11 workstation, on a recent Macbook:
- Parse time: ~1.4 seconds.
- Memory: transient, freed when the worker is terminated.
- Records produced: ~720,000.
- Wire bytes leaving the machine: 0. Confirm in the Network tab.
The UI then virtualises the result table with TanStack Virtual, so a million-row table still feels instant when you scroll or filter.
What about really large journals
For journals north of 500 MB we would switch to a streaming API that yields batches of records rather than accumulating them in a Vec. The change is small — Usn is already an Iterator; we would expose next_batch(n) over wasm-bindgen. We have not shipped it because nobody has hit that wall yet. If you do, open an issue.
A second optimisation we deliberately left off the table: parsing directly off a File handle via the browser's stream APIs. The wasm module would have to accept a ReadableStream-like interface, which means losing Cursor<Vec<u8>>. The complexity is not yet worth the gain for journals under 500 MB.
Why this approach matters
Forensic tooling has historically split between two camps. Heavy desktop suites (X-Ways, EnCase, FTK) that you license, install on a workstation, and trust. Python scripts you pip install and run on whatever endpoint happens to be convenient, often with sensitive data crossing IDE temp directories on the way.
WebAssembly opens a third lane: open, inspectable, runs entirely in the browser, no upload. Eric Zimmerman's tools occupy the high-trust offline desktop slot. Plaso and the libyal stack occupy the scriptable-pipeline slot. The browser slot was empty for too long because nobody believed it could match the others on speed. With wasm and a sensible crate underneath, that excuse is gone — for USN, for EVTX, for Prefetch, for every binary Windows artefact a defender actually touches.
Further reading
- The usnrs repo — the upstream Rust crate. Reading
src/lib.rsis the shortest way to understand theUSN_RECORD_V2parser interface. - The wasm-bindgen book — the reference for the FFI between Rust and JavaScript.
- Olaf Hartong's Sysmon-modular — unrelated to wasm, but the kind of artefact that, when paired with a parsed USN journal, gives you the full picture of file activity on a host.