forensic-vfs — Product Requirements (reverse-written)
A specification recovered from the shipped code, not a forward plan. Every coverage
claim in §6 is grounded in a same-session grep of the fleet (2026-07); the trait
signatures in §4 are quoted from crates/core/src. Decisions behind the design live
as ADRs under docs/decisions/.
Executive Summary
forensic-vfs is a set of four positioned-read contracts that let any forensic
evidence container, volume system, encryption layer, and filesystem compose into one
read-only virtual filesystem, without any layer knowing the concrete type of the layer
beneath it. A FileSystem asks its byte source to read_at(offset, buf); whether that
source is a raw .dd, a decompressed E01 chunk, a decrypted BitLocker volume, or a
partition window is invisible to it.
Today the horizontal layers are strong and only encryption remains unwired: 5 container
readers and 13 filesystem/archive readers implement their contracts in production, and the
VolumeSystem (partition) contract is now wired for all three schemes (MBR/GPT/APM via
src/vfs.rs). Only the EncryptionLayer (encryption) contract still has zero leaf
implementations — the reader crates exist (BitLocker/LUKS/FileVault/VeraCrypt) but none
are wired to the contract yet. Closing that last vertical layer is the primary remaining
work (§7).
The contracts are deliberately minimal and hard to misuse: read-only, cursorless,
Send + Sync, opaque node identity, and gated behind an optional vfs cargo feature so
a leaf crate depends on forensic-vfs only when a caller wants VFS composition.
1. Problem
Digital-forensic tooling re-implements the same plumbing per format: seek-and-read over a
container, walk a partition table, decrypt a volume, parse a filesystem. Each reader
invents its own I/O shape (Read + Seek, a bespoke cursor, a whole-image &[u8]), so
they do not compose: mounting NTFS-inside-BitLocker-inside-a-partition-inside-an-E01
means gluing four incompatible APIs by hand, and every glue joint is a place to leak a
cursor, allocate an unbounded buffer, or panic on hostile input.
2. Goals
- One byte-source shape every layer speaks, so containers, volumes, encryption, and filesystems stack by composition, not by bespoke glue.
- Read-only and concurrency-safe by construction — a contract that cannot mutate evidence and can be shared across threads without interior locking leaking into the API.
- Misuse-resistant identity — a filesystem exposes nodes as opaque handles, not inode integers a caller can fabricate or a path a caller can traverse out of bounds.
- Panic-free parsing of hostile input — every field read from evidence goes through bounds-checked helpers; malformed data yields an error or a zero, never a crash.
- Optional dependency — a reader crate builds and ships without forensic-vfs; the
contract impl is behind a
vfsfeature a downstream turns on.
3. Non-goals
- No writing. The contracts are read-only. Evidence integrity is a structural property, not a documented request.
- No mounting mechanism in the contract crate. FUSE/inode adaptation lives in a
separate consumer (
forensic-vfs-mount); the contract knows nothing about inodes. - No format detection in the leaf trait. Probing is a separate concern
(the five
*Openopener traits —ContainerOpen/ArchiveOpen/VolumeSystemOpen/EncryptionOpen/FileSystemOpen), so a leaf impl need not carry a sniffer.
4. The four contracts (as shipped)
Quoted from crates/core/src. All four are Send + Sync; all reads take &self
(cursorless — the offset is an argument, not hidden state).
ImageSource — a flat, addressable byte image (source.rs):
pub trait ImageSource: Send + Sync {
fn len(&self) -> u64;
fn read_at(&self, offset: u64, buf: &mut [u8]) -> VfsResult<usize>;
fn extents(&self) -> Extents { /* default: one dense run */ }
fn view(&self, offset: u64, len: usize) -> Option<SourceView<'_>> { /* default None */ }
fn source_id(&self) -> SourceId { /* default */ }
}
pub type DynSource = Arc<dyn ImageSource>;
FileSystem — a parsed filesystem over some ImageSource (fs.rs). Node identity
is the opaque FileId enum; a data stream is named by StreamId (so NTFS ADS / resource
forks are first-class):
pub trait FileSystem: Send + Sync {
fn root(&self) -> FileId;
fn read_dir(&self, ino: FileId) -> VfsResult<DirStream>;
fn lookup(&self, parent: FileId, name: &[u8]) -> VfsResult<Option<FileId>>;
fn meta(&self, ino: FileId) -> VfsResult<FsMeta>;
fn read_at(&self, ino: FileId, stream: StreamId, off: u64, buf: &mut [u8]) -> VfsResult<usize>;
fn read_link(&self, ino: FileId, cap: usize) -> VfsResult<Vec<u8>>; // capped: hostile symlink can't over-allocate
// + forensic surface: data_streams, extents, timestamps (MACB), run maps …
}
VolumeSystem — a partition/volume scheme that opens sub-sources (volume.rs):
pub trait VolumeSystem: Send + Sync {
fn scheme(&self) -> VolumeScheme;
fn volumes(&self) -> &[VolumeDesc];
fn open_volume(&self, index: usize) -> VfsResult<DynSource>; // -> another ImageSource
fn findings(&self) -> VfsResult<Vec<forensicnomicon::report::Finding>> { /* default empty */ }
}
EncryptionLayer — an encryption layer that opens a decrypted sub-source (encryption.rs):
pub trait EncryptionLayer: Send + Sync {
fn scheme(&self) -> EncryptionScheme;
fn open(&self, creds: &dyn CredentialSource) -> VfsResult<DynSource>; // -> a decrypted ImageSource
fn findings(&self) -> VfsResult<Vec<forensicnomicon::report::Finding>> { /* default empty */ }
}
Composition is by return type: VolumeSystem::open_volume and EncryptionLayer::open
both return DynSource (Arc<dyn ImageSource>), so the output of one layer is the input
of the next. SubRange (adapters.rs) is the concrete adapter that windows a parent
ImageSource into a partition/volume span — it is a struct that impl ImageSource, not a
contract.
5. Requirements (why the shape is what it is)
| # | Requirement | Mechanism | ADR |
|---|---|---|---|
| R1 | Reads never mutate evidence | contracts expose no &mut/write method |
0001 |
| R2 | Cursorless, shareable across threads | read_at(&self, offset, …) + Send + Sync; no interior seek state |
0001 |
| R3 | Layers compose without knowing each other's type | open_* returns DynSource; input of the next layer |
0003 |
| R4 | Node identity can't be forged / traversed OOB | opaque FileId enum + StreamId, not raw inodes/paths |
0002 |
| R5 | forensic-vfs is an optional dependency | impls gated behind vfs = ["dep:forensic-vfs"] |
0004 |
| R6 | Hostile input never panics | field reads via safe-read; capped allocations (read_link cap) |
0005 |
| R7 | Forensic knowledge lives in one place | findings() returns forensicnomicon::report::Finding |
0006 |
6. Coverage matrix (verified 2026-07)
Grounded in a fleet-wide grep for impl (ImageSource|FileSystem|VolumeSystem|EncryptionLayer) for.
ImageSource — 5 production impls (src/vfs.rs): ewf (E01), qcow2, vhdx, vhd, aff4.
- vmdk + dmg have readers but reach the contract via disk-forensic's
container::open, not a direct impl yet.
FileSystem — 13 production impls. Genuine filesystems (10): ntfs, fat, ext4, apfs,
hfsplus, xfs, btrfs, ufs, iso9660, udf. Archive containers (3): zip, ad1, dar.
- zip / ad1 / dar are archive containers exposed as FileSystem impls today; per
ADR 0008 they reclassify to ArchiveOpen →
ArchiveContents at the 0.4 cut (archive member trees stop masquerading as filesystems).
- No crate yet: zfs, refs, exFAT (standalone), legacy (non-plus) HFS.
VolumeSystem — 3 production impls (src/vfs.rs): mbr, gpt, apm — WIRED.
mbr-partition-forensic, gpt-partition-forensic, apm-partition-forensic all implement the
contract.
EncryptionLayer — 0 impls. bitlocker-forensic, luks-forensic, filevault-forensic,
veracrypt-forensic all exist; none implement the contract.
Umbrella at a glance (the README's "The reader fleet" section carries the full clickable roster grouped by layer):
| Layer | Trait | Readers | Wired |
|---|---|---|---|
| Archive | ArchiveOpen |
archive-forensic, ad1, dar, zip (4) | via archive-core; ad1/dar/zip → ArchiveOpen at 0.4 |
| Container | ContainerOpen |
ewf, qcow2, vhdx, vhd, aff4, vmdk, dmg (7) | 5 direct; vmdk + dmg via disk-forensic |
| Volume | VolumeSystemOpen |
mbr, gpt, apm (3) | all 3 wired |
| Encryption | EncryptionOpen |
bitlocker, luks, filevault, veracrypt (4) | none wired yet |
| Filesystem | FileSystemOpen |
ntfs, fat, ext4, xfs, btrfs, apfs, hfsplus, ufs, iso9660, udf (10) | all 10 wired |
7. Remaining work (the "finish the vision" gap)
Ranked by leverage — the two empty vertical layers unlock whole classes of evidence:
VolumeSystemfor MBR/GPT/APM — DONE. All three schemes are now wired viasrc/vfs.rs, so a partitioned image is walked through the contract andSubRangewindows each volume. This unblocked the encryption layer below, now the top gap.EncryptionLayerfor BitLocker/LUKS/FileVault/VeraCrypt — the top remaining gap. With §7.1 done, encrypted volumes compose in. Depends onCredentialSourcewiring.ImageSourcefor vhd, and promote aff4 from test-only to production.FileSystemfor btrfs/zfs/refs (crates exist) and exFAT/UFS (new crates).- Crate topology is settled (0007):
the contract leaf
forensic-vfs+forensic-vfs-resolver(theSourceOpendescent) + a separate publishedforensic-vfs-enginewiring the readers. The in-workspacecrates/engineduplicate an earlier draft favored is retired. - Wire
forensic-vfs-mountinto4n6mount— the FileSystem→inode adapter exists (tests green); it is not yet the mount path.
Deferred items live in the issue tracker, not here — this section states the current frontier, not a task list.
8. Peculiar optimization: block-by-block minimal-temp decode
The positioned-read contract enables a memory property the paper (paper/) develops in
full: because a FileSystem fetches only the byte ranges it needs ($MFT records,
directory runs) via read_at, and an E01 ImageSource decompresses only the 32 KiB
chunks those ranges overlap into a one-chunk scratch page, walking a filesystem over a
compressed image touches ~K chunks for K scattered records — never the whole inflated
image. The contract is what makes this composable rather than a per-reader special case:
the filesystem asks for offsets, the container decides how little to inflate to serve them.
Related: architecture.md (the terse contract tour),
validation.md (evidence tiers), and the paper under ../paper/.