forensic_vfs_resolver/lib.rs
1//! # forensic-vfs-resolver
2//!
3//! The generic layer resolver: sniff a byte source, match a registered prober,
4//! and descend container/volume/filesystem layers until a filesystem mounts.
5//!
6//! This is the reader-independent core of detection — it touches only the
7//! [`forensic_vfs::Openers`] prober traits and the layered
8//! [`Locator`]/[`Layer`] model, never a concrete reader. It is deliberately
9//! split out of the [`forensic-vfs`](https://docs.rs/forensic-vfs) contract leaf
10//! so the *evolving detection behavior* (this crate) is firewalled from the
11//! *frozen contract* the fleet's reader crates pin.
12//!
13//! The orchestration layer (`forensic-vfs-engine`) wires concrete probers into a
14//! [`Openers`], resolves a base [`DynSource`] from a path (EWF-by-path vs a raw
15//! file), and offers the by-path `open`/snapshot API; everything below that — the
16//! recursion, the sniff windows, [`walk`], and the snapshot *view* — lives here so
17//! any tool or test can drive it openers-first.
18//!
19//! Because `open` cannot be an inherent method on the leaf's [`Openers`] from
20//! another crate (the orphan rule), it is exposed as the [`SourceOpen`] extension
21//! trait; bring it into scope and call `openers.open(source, spec, 0)`.
22
23// Tests may unwrap/expect freely; production code may not.
24#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
25
26use std::collections::HashSet;
27
28use state_history_forensic::epoch::EpochTag;
29
30use forensic_vfs::{
31 ArchiveContents, Confidence, CredentialSource, DynSource, FileId, FileSystem, FsMeta, Layer,
32 Locator, NoCredentials, NodeAddr, NodeKind, Openers, SnapshotRef, SniffWindow, VfsResult,
33};
34
35/// Depth cap on the recursive resolve (container/volume nesting) — a bomb guard.
36const MAX_DEPTH: usize = 8;
37
38/// Bytes read into the sniff window. Sized so a prober can see multi-offset
39/// magics — notably the ISO 9660 Primary Volume Descriptor (`CD001` at byte
40/// offset 32769, LBA 16). NTFS/ext4/MBR/GPT/container magics all sit in the
41/// first few KiB, so the larger window is a strict superset. One bounded read.
42const SNIFF_CAP: u64 = 128 * 1024;
43
44/// Bytes read into the *tail* sniff window from the end of the source. Sized to
45/// cover trailer signatures like the DMG `koly` footer (at `total_len - 512`).
46const TAIL_CAP: u64 = 4096;
47
48/// Cap on directory recursion depth in [`walk`] — a filesystem-loop guard.
49const WALK_MAX_DEPTH: usize = 256;
50
51/// One resolved piece of evidence: its locator plus the mounted filesystem, when
52/// the resolver detected one (`None` for a source no registered prober recognized).
53pub struct Evidence {
54 /// The locator this evidence was opened from.
55 pub root: Locator,
56 /// The mounted read-only filesystem, if detected.
57 pub fs: Option<forensic_vfs::DynFs>,
58}
59
60/// One resolved layer stack: the mounted filesystem, its locator, and the byte
61/// source it was mounted from (plus that source's pre-filesystem locator, the
62/// base a snapshot layer is pushed onto).
63pub struct Resolved {
64 /// The mounted read-only filesystem.
65 pub fs: forensic_vfs::DynFs,
66 /// The full locator, topped by the `fs:` layer.
67 pub spec: Locator,
68 /// The byte source the filesystem was mounted from.
69 pub source: DynSource,
70 /// That source's pre-filesystem locator (the base a snapshot layer sits on).
71 pub source_spec: Locator,
72}
73
74/// One fully-peeled raw byte edge: the innermost [`DynSource`] no further
75/// *packaging* layer (archive/compression or bare container) claims, plus its
76/// locator (the archive-member / container layers it was unwrapped through). This
77/// is the medium-agnostic terminal of [`SourceOpen::resolve_to_source`] — the raw
78/// bytes a memory/log reader interprets, symmetric to the disk terminal's mounted
79/// filesystem (ADR 0011).
80pub struct ResolvedSource {
81 /// The fully-unwrapped raw byte edge.
82 pub source: DynSource,
83 /// Its locator, topped by the last packaging layer it was peeled through.
84 pub spec: Locator,
85}
86
87/// The generic layer resolver, exposed as an extension trait on the leaf's
88/// [`Openers`]. Bring it into scope (`use forensic_vfs_resolver::SourceOpen;`) to
89/// call `openers.open(source, spec, 0)`.
90pub trait SourceOpen {
91 /// Recursively resolve a source to a filesystem, supplying no credentials.
92 /// A convenience wrapper over [`SourceOpen::open_with_credentials`] with the
93 /// leaf's [`NoCredentials`] context: a signature-detected encryption layer then
94 /// surfaces `NeedCredentials` loudly and a credential-attempt scheme falls
95 /// through, so an encrypted volume is never silently skipped nor guessed.
96 ///
97 /// # Errors
98 /// Propagates a source read error, or a prober `open`/decode failure raised
99 /// after a positive probe verdict.
100 fn open(&self, source: DynSource, spec: Locator, depth: usize) -> VfsResult<Option<Resolved>> {
101 self.open_with_credentials(source, spec, depth, &NoCredentials)
102 }
103
104 /// Recursively resolve a source to a filesystem: sniff its head (and a tail
105 /// window for trailer magics); if a filesystem prober recognizes it, mount it;
106 /// otherwise if a volume-system prober recognizes it, descend into each volume
107 /// and resolve that; otherwise attempt a signature-detected encryption layer,
108 /// then containers, then archives; finally, as a last resort, attempt each
109 /// signature-less credential-attempt encryption scheme so a wrong VeraCrypt
110 /// guess can never shadow a real filesystem (ADR 0010). `Ok(None)` when nothing
111 /// recognizes it — a genuinely clean unknown, not an error.
112 ///
113 /// Encryption failure semantics follow the probe verdict (ADR 0010): a `Yes`
114 /// (BitLocker/LUKS/FileVault) whose decrypt fails propagates loud — the source
115 /// *is* identified encryption and a wrong/absent key is a nameable condition —
116 /// while a `Maybe` (VeraCrypt) whose decrypt fails falls through, because a
117 /// failed decrypt of a signature-less scheme is indistinguishable from random
118 /// data and must not break the empty-source contract.
119 ///
120 /// `creds` supplies keys/passphrases to any encryption layer reached.
121 /// `depth` is the current nesting level; callers start at `0`. The recursion is
122 /// depth-capped against a self-referential container/volume bomb.
123 ///
124 /// # Errors
125 /// Propagates a source read error, or a prober `open`/decode failure raised
126 /// after a positive (`Yes`) probe verdict.
127 fn open_with_credentials(
128 &self,
129 source: DynSource,
130 spec: Locator,
131 depth: usize,
132 creds: &dyn CredentialSource,
133 ) -> VfsResult<Option<Resolved>>;
134
135 /// Resolve a source to its innermost raw byte edge by peeling **only** the
136 /// medium-agnostic packaging layers — archive/compression (ADR 0008) plus a
137 /// bare container decode — and returning that [`ResolvedSource`] instead of
138 /// requiring a filesystem mount (ADR 0011).
139 ///
140 /// This is the terminal a memory- or log-dump reader wants: `memf` receives
141 /// the raw physical-page stream unwrapped from `memory.raw.gz` /
142 /// `memory.zip` / `dump.7z` without re-implementing archive detection, then
143 /// runs its own format detection over the bytes. It is orthogonal to the
144 /// downstream medium — the same peel the disk [`SourceOpen::open`] path uses,
145 /// stopping one step earlier.
146 ///
147 /// **Where it stops:** after archive and bare-container peeling only. It does
148 /// **not** run the filesystem, volume-system, or encryption descent — those
149 /// are disk *interpretation*, not packaging unwrap, and a memory dump is a
150 /// single flat stream, not a partitioned/encrypted disk. A source that no
151 /// packaging prober claims is its own terminal and is returned as-is (a bare
152 /// `memory.raw` / `.dd`). Container decode is included because a container is a
153 /// packaging wrapper over a flat stream; volume/encryption/filesystem are not.
154 ///
155 /// For a multi-member archive each member is tried in order and the first that
156 /// peels to a terminal wins (the single-dump case); a caller wanting every
157 /// member enumerates at the front-end.
158 ///
159 /// `depth` is the current nesting level; callers start at `0`. Past the
160 /// packaging depth cap it yields `Ok(None)` (a self-referential-container bomb
161 /// guard), symmetric with [`SourceOpen::open`].
162 ///
163 /// # Errors
164 /// Propagates a source read error, or a container/archive `open`/decode failure
165 /// raised after a positive probe verdict.
166 fn resolve_to_source(
167 &self,
168 source: DynSource,
169 spec: Locator,
170 depth: usize,
171 ) -> VfsResult<Option<ResolvedSource>>;
172}
173
174impl SourceOpen for Openers {
175 fn open_with_credentials(
176 &self,
177 source: DynSource,
178 spec: Locator,
179 depth: usize,
180 creds: &dyn CredentialSource,
181 ) -> VfsResult<Option<Resolved>> {
182 if depth > MAX_DEPTH {
183 return Ok(None);
184 }
185 let (head, n, tail, tn, total) = read_sniff_buffers(&source)?;
186 let window = SniffWindow::with_tail(
187 0,
188 head.get(..n).unwrap_or(&[]),
189 total,
190 tail.get(..tn).unwrap_or(&[]),
191 );
192
193 for probe in self.filesystems() {
194 if probe.probe(&window).is_candidate() {
195 let fs = probe.open(source.clone())?;
196 let fs_spec = spec.clone().push(Layer::Fs {
197 kind: probe.kind(),
198 at: NodeAddr::Path(Vec::new()),
199 });
200 return Ok(Some(Resolved {
201 fs,
202 spec: fs_spec,
203 source,
204 source_spec: spec,
205 }));
206 }
207 }
208 for vsp in self.volume_systems() {
209 if vsp.probe(&window).is_candidate() {
210 let vs = vsp.open(source.clone())?;
211 for index in 0..vs.volumes().len() {
212 let sub = vs.open_volume(index)?;
213 let child = spec.clone().push(Layer::Volume {
214 scheme: vsp.scheme(),
215 index,
216 guid: None,
217 });
218 if let Some(found) = self.open_with_credentials(sub, child, depth + 1, creds)? {
219 return Ok(Some(found));
220 }
221 }
222 }
223 }
224 // Encryption descent (ADR 0010), eager signature pass. `Maybe` schemes are
225 // recorded for the last-resort pass below, so they never shadow a real fs.
226 let mut credential_attempt: Vec<usize> = Vec::new();
227 if let Some(found) = descend_signature_encryption(
228 self,
229 &window,
230 &source,
231 &spec,
232 depth,
233 creds,
234 &mut credential_attempt,
235 )? {
236 return Ok(Some(found));
237 }
238 // Container + archive (ADR 0008) descent — the medium-agnostic packaging
239 // peel, shared with `resolve_to_source` (ADR 0011). Here the continuation
240 // is the FULL disk descent over each peeled child, so a container/archive
241 // that wraps a filesystem still mounts it.
242 if let Some(found) =
243 descend_packaging(self, &window, &source, &spec, depth, &mut |src, sp, d| {
244 self.open_with_credentials(src, sp, d, creds)
245 })?
246 {
247 return Ok(Some(found));
248 }
249 // Encryption descent (ADR 0010), credential-attempt last resort — only
250 // reached when nothing above claimed the source.
251 descend_credential_attempt_encryption(
252 self,
253 &source,
254 &spec,
255 depth,
256 creds,
257 &credential_attempt,
258 )
259 }
260
261 fn resolve_to_source(
262 &self,
263 source: DynSource,
264 spec: Locator,
265 depth: usize,
266 ) -> VfsResult<Option<ResolvedSource>> {
267 if depth > MAX_DEPTH {
268 return Ok(None);
269 }
270 let (head, n, tail, tn, total) = read_sniff_buffers(&source)?;
271 let window = SniffWindow::with_tail(
272 0,
273 head.get(..n).unwrap_or(&[]),
274 total,
275 tail.get(..tn).unwrap_or(&[]),
276 );
277 // Peel only the medium-agnostic packaging layers, re-entering
278 // `resolve_to_source` (NOT the disk descent) on each peeled child.
279 if let Some(found) =
280 descend_packaging(self, &window, &source, &spec, depth, &mut |src, sp, d| {
281 self.resolve_to_source(src, sp, d)
282 })?
283 {
284 return Ok(Some(found));
285 }
286 // No packaging layer claimed it: this source is the raw terminal.
287 Ok(Some(ResolvedSource { source, spec }))
288 }
289}
290
291/// Try each container decoder, then each archive peeler, against `window`; for the
292/// first prober that claims `source`, invoke `recurse` on the peeled child
293/// source(s) with the layer-extended locator, returning its first `Some`. The
294/// container/archive descent lives here once (ADR 0008/0011) so both the disk
295/// filesystem terminal ([`SourceOpen::open_with_credentials`]) and the raw-source
296/// terminal ([`SourceOpen::resolve_to_source`]) share it — `recurse` is the
297/// caller's continuation (a full disk descent, or a packaging-only re-entry).
298///
299/// A bare gz/bz2 wrapper peels to one decoded [`ArchiveContents::Stream`] (1→1); a
300/// multi-member archive peels to a [`ArchiveContents::Members`] table (1→N) whose
301/// members are each tried in order (first that resolves wins). The selected member
302/// index is recorded in the locator via `Layer::Archive { member }`, mirroring the
303/// volume-system multi-volume descent. A free function, not a method, for the same
304/// orphan-rule reason as [`SourceOpen`].
305fn descend_packaging<T>(
306 openers: &Openers,
307 window: &SniffWindow,
308 source: &DynSource,
309 spec: &Locator,
310 depth: usize,
311 recurse: &mut dyn FnMut(DynSource, Locator, usize) -> VfsResult<Option<T>>,
312) -> VfsResult<Option<T>> {
313 for cd in openers.containers() {
314 if cd.probe(window).is_candidate() {
315 let decoded = cd.open(source.clone())?;
316 let child = spec.clone().push(Layer::Container {
317 format: cd.format(),
318 });
319 if let Some(found) = recurse(decoded, child, depth + 1)? {
320 return Ok(Some(found));
321 }
322 }
323 }
324 for ar in openers.archives() {
325 if ar.probe(window).is_candidate() {
326 match ar.open(source.clone())? {
327 ArchiveContents::Stream(inner) => {
328 let child = spec.clone().push(Layer::Archive { member: None });
329 if let Some(found) = recurse(inner, child, depth + 1)? {
330 return Ok(Some(found));
331 }
332 }
333 ArchiveContents::Members(members) => {
334 for (index, member) in members.into_iter().enumerate() {
335 let child = spec.clone().push(Layer::Archive {
336 member: Some(index),
337 });
338 if let Some(found) = recurse(member.source, child, depth + 1)? {
339 return Ok(Some(found));
340 }
341 }
342 }
343 // A future `#[non_exhaustive]` ArchiveContents variant this
344 // resolver predates: fall through like an unrecognized source.
345 _ => {} // cov:unreachable: no other ArchiveContents variant exists today
346 }
347 }
348 }
349 Ok(None)
350}
351
352/// The owned head/tail sniff buffers of a source, plus their filled lengths and
353/// the source's total length: `(head, head_len, tail, tail_len, total_len)`. A
354/// [`SniffWindow`] borrows the two buffers.
355type SniffBuffers = (Vec<u8>, usize, Vec<u8>, usize, u64);
356
357/// Read the head (up to [`SNIFF_CAP`]) and tail (up to [`TAIL_CAP`]) sniff buffers
358/// of `source` in two bounded reads. The caller builds a [`SniffWindow`] borrowing
359/// the returned buffers. The tail lets a prober match a trailer signature the head
360/// never reaches — e.g. the DMG `koly` footer.
361fn read_sniff_buffers(source: &DynSource) -> VfsResult<SniffBuffers> {
362 let total = source.len();
363 let cap = total.clamp(1, SNIFF_CAP) as usize;
364 let mut head = vec![0u8; cap];
365 let n = source.read_at(0, &mut head)?;
366 let tail_cap = total.min(TAIL_CAP);
367 let tail_start = total - tail_cap;
368 let mut tail = vec![0u8; tail_cap as usize];
369 let tn = source.read_at(tail_start, &mut tail)?;
370 Ok((head, n, tail, tn, total))
371}
372
373/// Eager signature-encryption pass (ADR 0010): descend any `Yes`-verdict scheme
374/// (BitLocker/LUKS/FileVault) and recurse on its decrypted plaintext, recording
375/// each `Maybe`-verdict (signature-less) scheme's index into `credential_attempt`
376/// for the last-resort pass. A `Yes` decrypt failure propagates loud (the source
377/// *is* identified encryption), never a silent fall-through.
378///
379/// A free function rather than a method: `Openers` lives in the leaf, so the
380/// resolver cannot add inherent methods to it (the orphan rule — the same reason
381/// [`SourceOpen`] is an extension trait).
382fn descend_signature_encryption(
383 openers: &Openers,
384 window: &SniffWindow,
385 source: &DynSource,
386 spec: &Locator,
387 depth: usize,
388 creds: &dyn CredentialSource,
389 credential_attempt: &mut Vec<usize>,
390) -> VfsResult<Option<Resolved>> {
391 for (i, enc) in openers.encryption_layers().iter().enumerate() {
392 match enc.probe(window) {
393 Confidence::Yes { .. } => {
394 let layer = enc.open(source.clone())?;
395 let decrypted = layer.open(creds)?;
396 let child = spec.clone().push(Layer::Encryption {
397 scheme: enc.scheme(),
398 });
399 if let Some(found) =
400 openers.open_with_credentials(decrypted, child, depth + 1, creds)?
401 {
402 return Ok(Some(found));
403 }
404 }
405 Confidence::Maybe => credential_attempt.push(i),
406 Confidence::No => {}
407 }
408 }
409 Ok(None)
410}
411
412/// Credential-attempt last-resort pass (ADR 0010): for each recorded `Maybe`
413/// scheme (VeraCrypt), construct the layer and attempt to decrypt with `creds`,
414/// recursing on success. A `Maybe` was never a positive identification, so a failed
415/// *decrypt* means "not this scheme / wrong creds" — indistinguishable from random
416/// data — and falls through to the next candidate, never breaking the empty-source
417/// contract by erroring on an unrecognized blob. Constructing the layer, by
418/// contrast, is loud (`?`): a construction failure is an unexpected internal error,
419/// not a "wrong password", so it is surfaced rather than swallowed.
420fn descend_credential_attempt_encryption(
421 openers: &Openers,
422 source: &DynSource,
423 spec: &Locator,
424 depth: usize,
425 creds: &dyn CredentialSource,
426 credential_attempt: &[usize],
427) -> VfsResult<Option<Resolved>> {
428 for &i in credential_attempt {
429 let Some(enc) = openers.encryption_layers().get(i) else {
430 continue; // cov:unreachable: indices came from this same slice
431 };
432 let layer = enc.open(source.clone())?;
433 let Ok(decrypted) = layer.open(creds) else {
434 continue;
435 };
436 let child = spec.clone().push(Layer::Encryption {
437 scheme: enc.scheme(),
438 });
439 if let Some(found) = openers.open_with_credentials(decrypted, child, depth + 1, creds)? {
440 return Ok(Some(found));
441 }
442 }
443 Ok(None)
444}
445
446/// One snapshot of a filesystem, viewed as a time-indexed state in the `[H]`
447/// cohort: the wall-clock [`EpochTag`], the transaction id, the snapshot name,
448/// and a re-openable [`Locator`] locator (base ⇒ `Snapshot{ApfsXid}`).
449#[derive(Debug, Clone)]
450pub struct SnapshotView {
451 /// Time-indexed identity, derived from the snapshot's `create_time`.
452 pub epoch: EpochTag,
453 /// The snapshot transaction id.
454 pub xid: u64,
455 /// The snapshot name.
456 pub name: String,
457 /// A locator that the orchestration layer re-opens end-to-end.
458 pub locator: Locator,
459}
460
461/// Build a [`SnapshotView`] under `source_spec` (the source's pre-filesystem
462/// locator) from a snapshot's transaction id, name, and `create_time`. Takes
463/// primitives rather than a concrete reader's `#[non_exhaustive]` snapshot type so
464/// the mapping is unit-testable directly.
465#[must_use]
466pub fn snapshot_view(
467 source_spec: &Locator,
468 xid: u64,
469 name: String,
470 create_time: u64,
471) -> SnapshotView {
472 SnapshotView {
473 epoch: epoch_from_create_time(create_time),
474 xid,
475 name,
476 locator: source_spec.clone().push(Layer::Snapshot {
477 store: SnapshotRef::ApfsXid(xid),
478 }),
479 }
480}
481
482/// Derive an [`EpochTag`] from a snapshot `create_time` (nanoseconds since
483/// 1970-01-01 UTC). The big-endian nanosecond timestamp occupies the low 8 bytes
484/// (indices 24..32) of the 32-byte tag; the rest is zero. This is simple and
485/// reversible — the timestamp round-trips back out of those 8 bytes — and orders
486/// correctly: a later `create_time` yields a lexicographically greater tag.
487#[must_use]
488pub fn epoch_from_create_time(create_time_ns: u64) -> EpochTag {
489 let mut bytes = [0u8; 32];
490 bytes[24..32].copy_from_slice(&create_time_ns.to_be_bytes());
491 EpochTag::from_bytes(bytes)
492}
493
494/// One node found by [`walk`]: its path components (filesystem names are bytes,
495/// not guaranteed UTF-8), its filesystem id, and its metadata.
496pub struct WalkEntry {
497 /// Path components from the root, each a raw filesystem name.
498 pub path: Vec<Vec<u8>>,
499 /// The node's filesystem-specific id.
500 pub id: FileId,
501 /// The node's forensic metadata.
502 pub meta: FsMeta,
503}
504
505/// Recursively enumerate every node of a mounted filesystem from the root — the
506/// traversal a triage consumer runs over a resolved filesystem. Depth-capped and
507/// visited-guarded against directory loops; `.`/`..` self/parent entries are
508/// skipped. Returns the nodes; a per-node read error aborts loud.
509///
510/// # Errors
511/// Propagates the first `read_dir`/`meta`/entry-stream error encountered.
512pub fn walk(fs: &dyn FileSystem) -> VfsResult<Vec<WalkEntry>> {
513 let mut out = Vec::new();
514 let mut visited: HashSet<FileId> = HashSet::new();
515 let mut stack: Vec<(Vec<Vec<u8>>, FileId, usize)> = vec![(Vec::new(), fs.root(), 0)];
516 while let Some((prefix, dir_id, depth)) = stack.pop() {
517 if depth > WALK_MAX_DEPTH || !visited.insert(dir_id) {
518 continue;
519 }
520 for entry in fs.read_dir(dir_id)? {
521 let entry = entry?;
522 if matches!(entry.name.as_slice(), b"." | b"..") {
523 continue;
524 }
525 let mut path = prefix.clone();
526 path.push(entry.name);
527 let meta = fs.meta(entry.id)?;
528 let is_dir = matches!(meta.kind, NodeKind::Dir);
529 out.push(WalkEntry {
530 path: path.clone(),
531 id: entry.id,
532 meta,
533 });
534 if is_dir {
535 stack.push((path, entry.id, depth + 1));
536 }
537 }
538 }
539 Ok(out)
540}