One read-only, positioned-read byte edge — ImageSource — that every archive, container, volume, encryption, and filesystem reader in the fleet speaks, so a whole evidence stack (case.zip → E01 → GPT → BitLocker → NTFS) composes as a single Arc<dyn ImageSource> that N workers read in parallel and no code path can write.
Building forensic tooling in Rust? forensic-vfs is the read-only byte-and-filesystem contract every reader in the fleet plugs into. Write a reader once and get parallel reads, recursive container/archive/volume/encryption/filesystem composition, and a serializable evidence locator for free.
cargo add forensic-vfsuse std::sync::Arc;
use forensic_vfs::{FileSource, ImageSource};
let src: Arc<dyn ImageSource> = Arc::new(FileSource::open("evidence.dd")?);
// read_at(&self) has no cursor and no write method — so one source fans out to
// N worker threads with no lock, and a write is uncompilable, not just discouraged.
let mut buf = [0u8; 4096];
src.read_at(0, &mut buf)?;
# Ok::<(), forensic_vfs::VfsError>(())That is the whole promise in one type: evidence is parallel-readable and read-only by construction. Wrap the shipped adapters instead of rolling your own — FileSource (positioned OS reads, no Mutex<File>), SubRange (a byte window that is itself a source), SourceCursor (a Read + Seek bridge).
Every container, archive, volume system, encryption layer, or filesystem plugs in through one of five layer-open traits — each a two-step probe() (recognize) + open() (peel), named for its open() method the way Rust's Read is named for read:
use std::sync::Arc;
use forensic_vfs::{ContainerOpen, Confidence, SniffWindow, DynSource, VfsResult};
struct MyContainer;
impl ContainerOpen for MyContainer {
fn probe(&self, window: &SniffWindow) -> Confidence { // recognize — bounded head, no decode
if window.starts_with(b"MYFMT") { Confidence::Yes } else { Confidence::No }
}
fn open(&self, src: DynSource) -> VfsResult<DynSource> { // peel one layer — the resolver re-sniffs the result
Ok(Arc::new(MyDecodedSource::new(src)?))
}
}Register it in an Openers table and the resolver, the engine, and the CLI all dispatch to it automatically. You wrote the format; the composition came for free.
(New here? The architecture explains the layered model; everything below is the tour.)
Each peels exactly one layer and hands back a source (or member set) the resolver re-enters:
| Trait | open() yields |
Formats |
|---|---|---|
ContainerOpen |
DynSource |
E01/EWF, VMDK, VHD, VHDX, QCOW2, DMG, AFF4, raw |
ArchiveOpen |
ArchiveContents |
gz, bz2, tar, zip, clbx, 7z, AD1, DAR |
VolumeSystemOpen |
Box<dyn VolumeSystem> |
MBR, GPT, APM |
EncryptionOpen |
Box<dyn EncryptionLayer> |
BitLocker, LUKS, FileVault, VeraCrypt |
FileSystemOpen |
DynFs |
NTFS, FAT, ext4, XFS, btrfs, APFS, HFS+, UFS, ISO9660, UDF |
Archives are a first-class layer with their own trait (ADR 0008); ArchiveOpen::open returns either a decoded single stream or a member list:
enum ArchiveContents {
Stream(DynSource), // 1→1: a bare gz/bz2 wrapper — the decoded source re-enters resolution
Members(Vec<Member>), // 1→N: tar/zip/7z — each member re-enters resolution
}Every reader below speaks the forensic-vfs contract; the resolver composes them into one Arc<dyn ImageSource>. Status legend: ✓ wired — implements the contract in src/vfs.rs today · via disk-forensic — reaches the contract through disk_forensic::container::open, not a direct impl · not wired yet — crate exists, no contract impl · → ArchiveOpen at 0.4 — implements FileSystem today, reclassifies to ArchiveOpen at the 0.4 cut. Crates without a link are in-workspace or not yet on GitHub (marked local).
Knowledge / contract — the leaf types every reader depends on.
| Crate | Role |
|---|---|
| forensicnomicon | format magics + the report finding model |
| safe-read | bounded, panic-free positioned reads |
| state-history-forensic | temporal identity [H] |
| forensic-vfs | this crate — ImageSource + the five *Open contracts |
| forensic-vfs-resolver (local) | the SourceOpen orchestrator (recursive descent) |
| forensic-vfs-engine | default_openers() wiring the concrete readers |
1 · Archive (ArchiveOpen)
| Reader | Formats | Status |
|---|---|---|
| archive-forensic (local) | gz, bz2, tar, zip, clbx, 7z | wired via archive-core |
| ad1-forensic | AD1 | → ArchiveOpen at 0.4 |
| dar-forensic | DAR | → ArchiveOpen at 0.4 |
| zip-forensic | ZIP | → ArchiveOpen at 0.4 |
2 · Container (ContainerOpen → ImageSource)
| Reader | Format | Status |
|---|---|---|
| ewf-forensic | E01 / EWF | ✓ wired |
| qcow2-forensic | QCOW2 | ✓ wired |
| vhdx-forensic | VHDX | ✓ wired |
| vhd-forensic | VHD | ✓ wired |
| aff4-forensic | AFF4 | ✓ wired |
| vmdk-forensic | VMDK | via disk-forensic |
| dmg-forensic | DMG | via disk-forensic |
3 · Volume / partition (VolumeSystemOpen)
| Reader | Scheme | Status |
|---|---|---|
| mbr-partition-forensic | MBR | ✓ wired |
| gpt-partition-forensic | GPT | ✓ wired |
| apm-partition-forensic | APM | ✓ wired |
4 · Encryption (EncryptionOpen)
| Reader | Scheme | Status |
|---|---|---|
| bitlocker-forensic | BitLocker | not wired yet |
| luks-forensic | LUKS | not wired yet |
| filevault-forensic | FileVault | not wired yet |
| veracrypt-forensic | VeraCrypt | not wired yet |
5 · Filesystem (FileSystemOpen → FileSystem)
| Reader | Filesystem | Status |
|---|---|---|
| ntfs-forensic | NTFS | ✓ wired |
| fat-forensic | FAT / exFAT | ✓ wired |
| ext4fs-forensic | ext4 | ✓ wired |
| xfs-forensic | XFS | ✓ wired |
| btrfs-forensic | btrfs | ✓ wired |
| apfs-forensic | APFS | ✓ wired |
| hfsplus-forensic | HFS+ | ✓ wired |
| ufs-forensic | UFS | ✓ wired |
| iso9660-forensic | ISO9660 | ✓ wired |
| udf-forensic | UDF | ✓ wired |
Consumers — depend on the abstraction, never on a per-format reader.
| Crate | Role |
|---|---|
| disk-forensic | open-any-image + partition / ISO analysis |
| 4n6mount | FUSE mount of any composed stack |
| issen | fleet orchestrator |
The five *Open traits each peel one layer. The single SourceOpen orchestrator (in the sibling forensic-vfs-resolver crate) peels all of them by delegating: at each node it probes the five layer-opens, follows the match, and re-enters on the result until a filesystem mounts.
use forensic_vfs_resolver::SourceOpen;
let resolved = openers.open(source)?; // recursive descent — container ∘ archive ∘ volume ∘ encryption ∘ filesystemBecause open() re-enters per layer and per member, the depth is discovered by content, not fixed by a lane. Same algorithm, two very different outcomes:
case.7z of loose documents
└─ ArchiveOpen → Members → each member is a leaf file (shallow)
case.zip holding case.E01
└─ ArchiveOpen → Members → member re-enters SourceOpen:
ContainerOpen(EWF) → VolumeSystemOpen(GPT)
→ EncryptionOpen(BitLocker) → FileSystemOpen(NTFS) → file tree (deep)
Real evidence nests in any order — raw → LUKS → LVM → ext4 (encryption before volume), E01 → APFS-container(encrypted) → APFS (encryption is container metadata) — so SourceOpen is a per-node graph, not a fixed stack. The Locator records the full path taken. Composition is exactly five transform kinds — container · archive · volume · encryption · filesystem (ADR 0003).
A Locator is the recursive, self-describing locator a finding cites and a session re-opens. It round-trips byte-for-byte through a canonical URI (a fuzz-enforced invariant):
use forensic_vfs::Locator;
let spec = Locator::from_uri(
"loc:file:%2Fevidence%2FDC01.E01|container:ewf|volume:gpt,1|fs:ntfs,p/Windows/System32/config/SYSTEM",
)?;
assert_eq!(Locator::from_uri(&spec.to_uri())?, spec); // lossless
# Ok::<(), forensic_vfs::VfsError>(())Every byte outside [A-Za-z0-9._-] is percent-encoded, so a Windows path containing / or a non-UTF-8 filename survives intact. Credentials never live in the address — they are supplied out-of-band through a CredentialSource at open time. Legacy fvfs: / os: URIs still decode, so addresses serialized before the rename keep resolving.
| Crate | Role |
|---|---|
forensic-vfs (0.4) |
the contract leaf — ImageSource, the five *Open traits, Openers, Locator, FsMeta, FsKind |
forensic-vfs-resolver (0.1) |
the SourceOpen orchestrator — impl SourceOpen for Openers, recursive descent, walk, snapshot_view |
forensic-vfs-engine (repo) |
default_openers() wiring the ~17 concrete readers + Vfs::open(path) host bootstrap |
A consumer depends on the abstraction (leaf + resolver), never on a per-format reader. Adding a new format benefits every consumer at once.
- Fuzzed. The
LocatorURI parser and the bounded readers are fuzzed — 15.7M + 20.2M executions with no panic, the round-trip invariant holding throughout. - Panic-free by lint.
unsafe_code = forbid;unwrap_used/expect_useddenied; every offset/length read goes through bounded readers that return 0, never panic, out of range. - 100% line coverage, with object-safety of every trait proven by a reader double driven through
Arc<dyn Trait>.
The horizontal layers are strong; the two vertical layers are the frontier — container and filesystem/archive readers implement their contracts in production, while the volume and encryption readers (MBR/GPT/APM, BitLocker/LUKS/FileVault) exist but are not yet wired to VolumeSystemOpen/EncryptionOpen. Full architecture, the exact coverage matrix, and the design decisions:
docs/architecture.md— the layered model, the resolver graph, and the crate topologydocs/PRD.md— reverse-written requirements + coverage matrix + remaining workdocs/decisions/— ADRs (positioned-read, five-layer composition, first-class archives, …)paper/— the academic write-up (universal reader + safe-read + block-by-block E01/MFT decode)
Privacy Policy · Terms of Service · © 2026 Security Ronin Ltd