Skip to main content

forensic_vfs_engine/
lib.rs

1//! # forensic-vfs-engine
2//!
3//! The openers registry + resolver over the `forensic-vfs` contracts: one
4//! [`Vfs::open`] that detects the container/volume/filesystem stack of a piece
5//! of evidence and mounts a read-only `dyn FileSystem`. This is the
6//! ORCHESTRATION crate — the one place that depends *down* on every fleet reader.
7
8#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
9
10use std::collections::HashSet;
11use std::io::Cursor;
12use std::path::Path;
13use std::sync::Arc;
14
15use forensic_vfs::adapters::{FileSource, SeekPoolSource, SourceCursor, SubRange};
16use forensic_vfs::read::{le_u32, le_u64};
17use forensic_vfs::{
18    Confidence, ContainerFormat, ContainerOpen, DynFs, DynSource, FileId, FileSystem,
19    FileSystemOpen, FsKind, FsMeta, Layer, NodeAddr, NodeKind, Openers, PathSpec, SmallHex,
20    SnapshotRef, SniffWindow, VfsError, VfsResult, VolumeDesc, VolumeKind, VolumeScheme,
21    VolumeSystem, VolumeSystemOpen,
22};
23use forensic_vfs_resolver::SourceOpen;
24use state_history_forensic::epoch::EpochTag;
25
26/// One resolved piece of evidence: its locator plus the mounted filesystem, when
27/// the engine detected one (`None` for a source no registered prober recognized).
28pub struct Evidence {
29    /// The locator this evidence was opened from.
30    pub root: PathSpec,
31    /// The mounted read-only filesystem, if detected.
32    pub fs: Option<DynFs>,
33}
34
35/// The engine handle: the reader openers plus the resolver.
36pub struct Vfs {
37    openers: Openers,
38}
39
40impl Default for Vfs {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl Vfs {
47    /// A `Vfs` with every fleet reader registered ([`default_openers`]).
48    #[must_use]
49    pub fn new() -> Self {
50        Self {
51            openers: default_openers(),
52        }
53    }
54
55    /// Open evidence at `path`: resolve the base byte source (an EWF container by
56    /// path, or a raw file), then recurse container/volume/filesystem layers and
57    /// mount the first filesystem found. A source nothing recognizes yields an
58    /// `Evidence` with `fs: None` — a genuinely clean unknown, not an error.
59    pub fn open(&self, path: &Path) -> VfsResult<Evidence> {
60        let base = open_base(path)?;
61        let base_spec = PathSpec::os(path);
62        match self.openers.open(base, base_spec.clone(), 0)? {
63            Some(r) => Ok(Evidence {
64                root: r.spec,
65                fs: Some(r.fs),
66            }),
67            None => Ok(Evidence {
68                root: base_spec,
69                fs: None,
70            }),
71        }
72    }
73
74    /// Resolve a filesystem directly from a byte source — an in-memory buffer, a
75    /// nested image, or a carved region. `Ok(None)` when nothing recognizes it.
76    pub fn open_source(&self, source: DynSource) -> VfsResult<Option<DynFs>> {
77        let base = PathSpec::root(Layer::Range {
78            start: 0,
79            len: source.len(),
80        });
81        Ok(self.openers.open(source, base, 0)?.map(|r| r.fs))
82    }
83
84    /// Enumerate an APFS volume's snapshots as a time-indexed `[H]` cohort. The
85    /// path is resolved through any container/volume-system nesting to its APFS
86    /// filesystem (exactly as [`Vfs::open`] does), then apfs-core lists the
87    /// snapshot-metadata tree. Evidence with no APFS filesystem yields an **empty**
88    /// cohort — a genuinely clean "no APFS snapshots here", not an error.
89    ///
90    /// The returned cohort is a `Vec<SnapshotView>` (the list form of the richer
91    /// `state_history_forensic::TemporalCohort<H>`, adopted here once the generic
92    /// `HistoricalSource` wiring lands); each view carries an [`EpochTag`] derived
93    /// from the snapshot's `create_time` and a re-openable [`PathSpec`] locator.
94    ///
95    /// # Errors
96    /// The bootstrap/decoding errors of resolving the path, or an apfs-core decode
97    /// failure while walking the snapshot-metadata tree.
98    pub fn snapshots(&self, path: &Path) -> VfsResult<Vec<SnapshotView>> {
99        let base = open_base(path)?;
100        let base_spec = PathSpec::os(path);
101        let Some(resolved) = self.openers.open(base, base_spec, 0)? else {
102            return Ok(Vec::new());
103        };
104        if !is_apfs(&resolved.spec) {
105            return Ok(Vec::new());
106        }
107        let source_spec = resolved.source_spec;
108        let len = resolved.source.len();
109        let cursor = SourceCursor::new(resolved.source, 0, len);
110        let snaps = apfs_core::vfs::ApfsFs::snapshots(cursor).map_err(map_apfs_err)?;
111        Ok(snaps
112            .into_iter()
113            .map(|s| snapshot_view(&source_spec, s.xid, s.name, s.create_time))
114            .collect())
115    }
116
117    /// Re-mount one APFS snapshot by its transaction `xid` — the end-to-end
118    /// counterpart to a [`SnapshotView`] locator. Resolves the path to its APFS
119    /// filesystem, then mounts the volume state frozen at `xid` (the live volume
120    /// for its own xid, else the retained snapshot). The returned [`Evidence`]
121    /// carries the snapshot-topped locator and the mounted point-in-time
122    /// filesystem.
123    ///
124    /// # Errors
125    /// [`VfsError::Bootstrap`] if the path resolves to no filesystem;
126    /// [`VfsError::Unsupported`] if the resolved filesystem is not APFS; or an
127    /// apfs-core decode failure (including [`VfsError::Decode`] for an unknown
128    /// `xid`).
129    pub fn open_snapshot(&self, path: &Path, xid: u64) -> VfsResult<Evidence> {
130        let base = open_base(path)?;
131        let base_spec = PathSpec::os(path);
132        let resolved = self
133            .openers
134            .open(base, base_spec, 0)?
135            .ok_or(VfsError::Bootstrap {
136                stage: "apfs snapshot",
137                detail: "no filesystem detected in evidence".to_string(),
138            })?;
139        if !is_apfs(&resolved.spec) {
140            return Err(VfsError::Unsupported {
141                layer: "snapshot",
142                scheme: "non-APFS filesystem has no APFS snapshot".to_string(),
143            });
144        }
145        let source_spec = resolved.source_spec;
146        let len = resolved.source.len();
147        let cursor = SourceCursor::new(resolved.source, 0, len);
148        let fs = apfs_core::vfs::ApfsFs::open_snapshot(cursor, xid).map_err(map_apfs_err)?;
149        let root = source_spec
150            .push(Layer::Snapshot {
151                store: SnapshotRef::ApfsXid(xid),
152            })
153            .push(Layer::Fs {
154                kind: FsKind::APFS,
155                at: NodeAddr::Path(Vec::new()),
156            });
157        Ok(Evidence {
158            root,
159            fs: Some(Arc::new(fs)),
160        })
161    }
162}
163
164/// One snapshot of an APFS volume, viewed as a time-indexed state in the `[H]`
165/// cohort: the wall-clock [`EpochTag`], the APFS transaction id, the snapshot
166/// name, and a re-openable [`PathSpec`] locator (base ⇒ `Snapshot{ApfsXid}`).
167#[derive(Debug, Clone)]
168pub struct SnapshotView {
169    /// Time-indexed identity, derived from the snapshot's `create_time`.
170    pub epoch: EpochTag,
171    /// The APFS snapshot transaction id.
172    pub xid: u64,
173    /// The snapshot name.
174    pub name: String,
175    /// A locator that [`Vfs::open_snapshot`] re-opens end-to-end.
176    pub locator: PathSpec,
177}
178
179/// True when a resolved locator's top layer is an APFS filesystem.
180fn is_apfs(spec: &PathSpec) -> bool {
181    matches!(
182        spec.layer,
183        Layer::Fs {
184            kind: FsKind::APFS,
185            ..
186        }
187    )
188}
189
190/// Build a [`SnapshotView`] under `source_spec` (the APFS source's
191/// pre-filesystem locator) from a snapshot's transaction id, name, and
192/// `create_time`. Takes primitives rather than the `#[non_exhaustive]`
193/// `apfs_core::snapshot::Snapshot` so the mapping is unit-testable directly.
194fn snapshot_view(source_spec: &PathSpec, xid: u64, name: String, create_time: u64) -> SnapshotView {
195    SnapshotView {
196        epoch: epoch_from_create_time(create_time),
197        xid,
198        name,
199        locator: source_spec.clone().push(Layer::Snapshot {
200            store: SnapshotRef::ApfsXid(xid),
201        }),
202    }
203}
204
205/// Derive an [`EpochTag`] from an APFS snapshot `create_time` (nanoseconds since
206/// 1970-01-01 UTC). The big-endian nanosecond timestamp occupies the low 8 bytes
207/// (indices 24..32) of the 32-byte tag; the rest is zero. This is simple and
208/// reversible — the timestamp round-trips back out of those 8 bytes — and orders
209/// correctly: a later `create_time` yields a lexicographically greater tag.
210fn epoch_from_create_time(create_time_ns: u64) -> EpochTag {
211    let mut bytes = [0u8; 32];
212    bytes[24..32].copy_from_slice(&create_time_ns.to_be_bytes());
213    EpochTag::from_bytes(bytes)
214}
215
216/// Map an apfs-core error into a VFS decode error, keeping the original message.
217// Used as a `.map_err(map_apfs_err)` adapter, so it must take the error by value.
218#[allow(clippy::needless_pass_by_value)]
219fn map_apfs_err(e: apfs_core::ApfsError) -> VfsError {
220    VfsError::Decode {
221        layer: "apfs snapshot",
222        offset: 0,
223        detail: e.to_string(),
224        bytes: SmallHex::new(&[]),
225    }
226}
227
228/// The fleet reader openers: filesystem probers + volume-system probers +
229/// container decoders. Crypto (`EncryptionOpen`) and archive (`ArchiveOpen`)
230/// layers register here as those readers grow their `vfs` features.
231#[must_use]
232pub fn default_openers() -> Openers {
233    Openers::new()
234        .filesystem(NtfsProbe)
235        .filesystem(Ext4Probe)
236        .filesystem(XfsProbe)
237        .filesystem(Iso9660Probe)
238        .filesystem(ApfsProbe)
239        .filesystem(HfsPlusProbe)
240        .filesystem(ExFatProbe)
241        .filesystem(FatProbe)
242        .filesystem(UdfProbe)
243        .filesystem(UfsProbe)
244        .filesystem(BtrfsProbe)
245        .volume_system(GptProbe)
246        .volume_system(MbrProbe)
247        .volume_system(ApmProbe)
248        .container(VhdDecoder)
249        .container(Qcow2Decoder)
250        .container(VmdkDecoder)
251        .container(VhdxDecoder)
252        .container(DmgDecoder)
253        .container(Aff4Decoder)
254}
255
256/// Resolve the base [`DynSource`] for a path. EWF is multi-segment and opens *by
257/// path* (it discovers `.E02...` itself), so it is handled here rather than as a
258/// single-stream `ContainerOpen`; everything else is a raw [`FileSource`].
259fn open_base(path: &Path) -> VfsResult<DynSource> {
260    if is_ewf(path) {
261        let reader = ewf::EwfReader::open(path).map_err(|e| VfsError::Bootstrap {
262            stage: "ewf::open",
263            detail: e.to_string(),
264        })?;
265        Ok(Arc::new(reader))
266    } else {
267        Ok(Arc::new(FileSource::open(path)?))
268    }
269}
270
271fn is_ewf(path: &Path) -> bool {
272    path.extension()
273        .and_then(|e| e.to_str())
274        .is_some_and(|e| e.eq_ignore_ascii_case("e01") || e.eq_ignore_ascii_case("ex01"))
275}
276
277/// NTFS filesystem prober: recognizes the `NTFS` OEM id and mounts `ntfs_core::NtfsFs`.
278struct NtfsProbe;
279
280impl FileSystemOpen for NtfsProbe {
281    fn kind(&self) -> FsKind {
282        FsKind::NTFS
283    }
284
285    fn probe(&self, w: &SniffWindow) -> Confidence {
286        // NTFS boot sector: OEM id "NTFS    " at byte offset 3.
287        if w.has_magic(3, b"NTFS    ") {
288            Confidence::Yes { how: "NTFS OEM id" }
289        } else {
290            Confidence::No
291        }
292    }
293
294    fn open(&self, src: DynSource) -> VfsResult<DynFs> {
295        let len = src.len();
296        let cursor = SourceCursor::new(src, 0, len);
297        let fs = ntfs_core::NtfsFs::open(cursor).map_err(|e| VfsError::Decode {
298            layer: "ntfs",
299            offset: 0,
300            detail: e.to_string(),
301            bytes: SmallHex::new(&[]),
302        })?;
303        Ok(Arc::new(fs))
304    }
305}
306
307/// ext2/3/4 filesystem prober: recognizes the ext superblock magic and mounts
308/// `ext4fs::Ext4Fs`.
309struct Ext4Probe;
310
311impl FileSystemOpen for Ext4Probe {
312    fn kind(&self) -> FsKind {
313        FsKind::EXT
314    }
315
316    fn probe(&self, w: &SniffWindow) -> Confidence {
317        // The ext superblock sits at byte offset 1024; its `s_magic` (0xEF53,
318        // little-endian) is at +0x38, i.e. absolute offset 1080.
319        if w.has_magic(1080, &[0x53, 0xEF]) {
320            Confidence::Yes {
321                how: "ext2/3/4 superblock magic",
322            }
323        } else {
324            Confidence::No
325        }
326    }
327
328    fn open(&self, src: DynSource) -> VfsResult<DynFs> {
329        let len = src.len();
330        let cursor = SourceCursor::new(src, 0, len);
331        let fs = ext4fs::Ext4Fs::open(cursor).map_err(|e| VfsError::Decode {
332            layer: "ext4",
333            offset: 0,
334            detail: e.to_string(),
335            bytes: SmallHex::new(&[]),
336        })?;
337        Ok(Arc::new(fs))
338    }
339}
340
341/// XFS filesystem prober: recognizes the `XFSB` superblock magic at byte 0 and
342/// mounts `xfs::vfs::XfsFs`. XfsFs is slice-based, so `open` reads the whole
343/// source into memory (see the xfs vfs adapter docs on the `&[u8]` bridge).
344struct XfsProbe;
345
346impl FileSystemOpen for XfsProbe {
347    fn kind(&self) -> FsKind {
348        FsKind::XFS
349    }
350
351    fn probe(&self, w: &SniffWindow) -> Confidence {
352        xfs::vfs::xfs_probe(w)
353    }
354
355    fn open(&self, src: DynSource) -> VfsResult<DynFs> {
356        Ok(Arc::new(xfs::vfs::XfsFs::open(&src)?))
357    }
358}
359
360/// ISO 9660 filesystem prober: recognizes the Primary Volume Descriptor and
361/// mounts `iso::vfs::IsoVfs`. The PVD's `CD001` standard identifier sits at byte
362/// offset 32769 (LBA 16, +1), so this needs the enlarged sniff window.
363struct Iso9660Probe;
364
365impl FileSystemOpen for Iso9660Probe {
366    fn kind(&self) -> FsKind {
367        FsKind::ISO9660
368    }
369
370    fn probe(&self, w: &SniffWindow) -> Confidence {
371        // ECMA-119 §8.1: a Volume Descriptor at LBA 16 begins with the standard
372        // identifier "CD001" at byte offset 32769 (32768 + 1 type byte).
373        if w.has_magic(32769, b"CD001") {
374            Confidence::Yes {
375                how: "ISO 9660 CD001 volume descriptor",
376            }
377        } else {
378            Confidence::No
379        }
380    }
381
382    fn open(&self, src: DynSource) -> VfsResult<DynFs> {
383        let len = src.len();
384        let cursor = SourceCursor::new(src, 0, len);
385        let fs = iso::vfs::IsoVfs::open(cursor).map_err(|e| VfsError::Decode {
386            layer: "iso9660",
387            offset: 0,
388            detail: e.to_string(),
389            bytes: SmallHex::new(&[]),
390        })?;
391        Ok(Arc::new(fs))
392    }
393}
394
395/// APFS container: the `nx_superblock` carries the magic `NXSB` at byte offset 32
396/// (immediately after the 32-byte `obj_phys` object header) in block 0.
397struct ApfsProbe;
398
399impl FileSystemOpen for ApfsProbe {
400    fn kind(&self) -> FsKind {
401        FsKind::APFS
402    }
403
404    fn probe(&self, w: &SniffWindow) -> Confidence {
405        if w.has_magic(32, b"NXSB") {
406            Confidence::Yes {
407                how: "APFS NXSB container superblock",
408            }
409        } else {
410            Confidence::No
411        }
412    }
413
414    fn open(&self, src: DynSource) -> VfsResult<DynFs> {
415        let len = src.len();
416        let cursor = SourceCursor::new(src, 0, len);
417        let fs = apfs_core::vfs::ApfsFs::open(cursor).map_err(|e| VfsError::Decode {
418            layer: "apfs",
419            offset: 0,
420            detail: e.to_string(),
421            bytes: SmallHex::new(&[]),
422        })?;
423        Ok(Arc::new(fs))
424    }
425}
426
427/// HFS+ / HFSX: the volume header sits at byte offset 1024 and begins with the
428/// signature `H+` (`0x482B`) for HFS Plus or `HX` (`0x4858`) for HFSX.
429struct HfsPlusProbe;
430
431impl FileSystemOpen for HfsPlusProbe {
432    fn kind(&self) -> FsKind {
433        FsKind::HFS_PLUS
434    }
435
436    fn probe(&self, w: &SniffWindow) -> Confidence {
437        match w.at(1024, 2) {
438            Some([0x48, 0x2B | 0x58]) => Confidence::Yes {
439                how: "HFS+/HFSX volume header",
440            },
441            _ => Confidence::No,
442        }
443    }
444
445    fn open(&self, src: DynSource) -> VfsResult<DynFs> {
446        // The HFS+ reader is slice-based, so the whole volume is read into a Vec.
447        // No streaming path yet — a memory consideration for multi-GB volumes.
448        let len = src.len();
449        let mut volume = vec![0u8; usize::try_from(len).unwrap_or(usize::MAX)];
450        let n = src.read_at(0, &mut volume)?;
451        volume.truncate(n);
452        let fs = hfsplus::vfs::HfsFs::new(volume)?;
453        Ok(Arc::new(fs))
454    }
455}
456
457/// exFAT: the `EXFAT   ` identifier at byte offset 3 plus the `0x55AA` boot
458/// signature. Registered before [`FatProbe`] because exFAT zeroes the legacy BPB
459/// fields the FAT probe keys on.
460struct ExFatProbe;
461
462impl FileSystemOpen for ExFatProbe {
463    fn kind(&self) -> FsKind {
464        FsKind::EXFAT
465    }
466
467    fn probe(&self, w: &SniffWindow) -> Confidence {
468        if w.at(510, 2) == Some(&[0x55, 0xaa]) && w.has_magic(3, b"EXFAT   ") {
469            Confidence::Yes {
470                how: "exFAT boot signature",
471            }
472        } else {
473            Confidence::No
474        }
475    }
476
477    fn open(&self, src: DynSource) -> VfsResult<DynFs> {
478        let len = src.len();
479        let cursor = SourceCursor::new(src, 0, len);
480        let fs = fat::FatFs::open(cursor).map_err(|e| VfsError::Decode {
481            layer: "exfat",
482            offset: 0,
483            detail: e.to_string(),
484            bytes: SmallHex::new(&[]),
485        })?;
486        Ok(Arc::new(fs))
487    }
488}
489
490/// FAT12/16/32: a valid BPB — a jump instruction (`0xEB`/`0xE9`) at offset 0, a
491/// power-of-two bytes-per-sector, and the `0x55AA` boot signature.
492struct FatProbe;
493
494impl FileSystemOpen for FatProbe {
495    fn kind(&self) -> FsKind {
496        FsKind::FAT
497    }
498
499    fn probe(&self, w: &SniffWindow) -> Confidence {
500        if w.at(510, 2) != Some(&[0x55, 0xaa]) {
501            return Confidence::No;
502        }
503        let jump = w.at(0, 1).and_then(|s| s.first().copied());
504        let jump_ok = matches!(jump, Some(0xEB | 0xE9));
505        let bps = w
506            .at(11, 2)
507            .and_then(|b| <[u8; 2]>::try_from(b).ok())
508            .map_or(0, u16::from_le_bytes);
509        if jump_ok && bps.is_power_of_two() && (512..=4096).contains(&bps) {
510            Confidence::Yes { how: "FAT BPB" }
511        } else {
512            Confidence::No
513        }
514    }
515
516    fn open(&self, src: DynSource) -> VfsResult<DynFs> {
517        let len = src.len();
518        let cursor = SourceCursor::new(src, 0, len);
519        let fs = fat::FatFs::open(cursor).map_err(|e| VfsError::Decode {
520            layer: "fat",
521            offset: 0,
522            detail: e.to_string(),
523            bytes: SmallHex::new(&[]),
524        })?;
525        Ok(Arc::new(fs))
526    }
527}
528
529/// UDF (ISO/UDF optical filesystem) prober: recognizes the Volume Recognition
530/// Sequence at sector 16 and mounts `udf_forensic::vfs::UdfVfs`. Delegates the
531/// sniff to `udf_forensic::detect_udf` over the head window (a `Cursor` of the
532/// sniff bytes) so the NSR02/NSR03 UDF mark — the definitive indicator — is
533/// tested without re-deriving the descriptor offsets. The first VRS descriptors
534/// sit at bytes 32769/34817/… (LBA 16+, 2048-byte sectors), inside the window.
535struct UdfProbe;
536
537impl FileSystemOpen for UdfProbe {
538    fn kind(&self) -> FsKind {
539        FsKind::UDF
540    }
541
542    fn probe(&self, w: &SniffWindow) -> Confidence {
543        if udf_forensic::detect_udf(&mut Cursor::new(w.bytes())) {
544            Confidence::Yes {
545                how: "UDF NSR02/NSR03 volume recognition sequence",
546            }
547        } else {
548            Confidence::No
549        }
550    }
551
552    fn open(&self, src: DynSource) -> VfsResult<DynFs> {
553        let len = src.len();
554        let cursor = SourceCursor::new(src, 0, len);
555        let fs = udf_forensic::vfs::UdfVfs::open(cursor)?;
556        Ok(Arc::new(fs))
557    }
558}
559
560/// UFS/FFS filesystem prober: delegates to `ufs::vfs::ufs_probe`, which matches
561/// the UFS2 `fs_magic` (`0x19540119`, at absolute offset 66908) or the UFS1
562/// `fs_magic` (`0x00011954`, at 9564), in either byte order. Only the UFS1 magic
563/// falls inside the resolver's 40 KiB head window; a UFS2 superblock's magic sits
564/// beyond it, so UFS2 auto-detection through the head window is window-limited
565/// (see the module note — this is the resolver's `SNIFF_CAP`, not this wrapper).
566struct UfsProbe;
567
568impl FileSystemOpen for UfsProbe {
569    fn kind(&self) -> FsKind {
570        FsKind::UFS
571    }
572
573    fn probe(&self, w: &SniffWindow) -> Confidence {
574        ufs::vfs::ufs_probe(w)
575    }
576
577    fn open(&self, src: DynSource) -> VfsResult<DynFs> {
578        let fs = ufs::vfs::UfsFs::open(&src)?;
579        Ok(Arc::new(fs))
580    }
581}
582
583/// Absolute byte offset of the btrfs primary-superblock magic: the superblock
584/// sits at `BTRFS_SUPER_INFO_OFFSET` (0x10000) and its `_BHRfS_M` magic is at
585/// +0x40 within it, i.e. 0x10040 (65600).
586const BTRFS_MAGIC_OFFSET: usize = btrfs_core::BTRFS_SUPER_INFO_OFFSET as usize + 0x40;
587
588/// btrfs filesystem prober: matches the `_BHRfS_M` superblock magic at byte
589/// 0x10040 and mounts `btrfs_core::vfs::BtrfsFs`.
590///
591/// That magic (65600) lies **beyond the resolver's 40 KiB head sniff window**, so
592/// the head window in practice never carries it and this declines — btrfs is
593/// registered and correct-by-construction (it succeeds the moment the window
594/// spans the offset) but is not auto-detected through the current head window.
595/// It deliberately does **not** return `Confidence::Maybe`: a `Maybe` would run
596/// `open` on every otherwise-unrecognized source, and `BtrfsFs::open` errors on a
597/// non-btrfs image — turning the "clean unknown / empty container ⇒ `None`"
598/// contract into a loud error (e.g. an all-zero decoded container). Declining
599/// keeps that contract intact; widening the resolver's `SNIFF_CAP` is the fix.
600struct BtrfsProbe;
601
602impl FileSystemOpen for BtrfsProbe {
603    fn kind(&self) -> FsKind {
604        FsKind::BTRFS
605    }
606
607    fn probe(&self, w: &SniffWindow) -> Confidence {
608        if w.has_magic(BTRFS_MAGIC_OFFSET, &btrfs_core::BTRFS_MAGIC) {
609            Confidence::Yes {
610                how: "btrfs _BHRfS_M superblock magic",
611            }
612        } else {
613            Confidence::No
614        }
615    }
616
617    fn open(&self, src: DynSource) -> VfsResult<DynFs> {
618        let fs = btrfs_core::vfs::BtrfsFs::open(&*src)?;
619        Ok(Arc::new(fs))
620    }
621}
622
623/// MBR (DOS) partition-table volume system: the classic 4-entry table at the end
624/// of the boot sector. Extended partitions (types 0x05/0x0f) are not yet chased.
625struct MbrProbe;
626
627impl VolumeSystemOpen for MbrProbe {
628    fn scheme(&self) -> VolumeScheme {
629        VolumeScheme::Mbr
630    }
631
632    fn probe(&self, w: &SniffWindow) -> Confidence {
633        // 0x55AA boot signature plus at least one plausible partition entry.
634        if w.at(510, 2) != Some(&[0x55, 0xaa]) {
635            return Confidence::No;
636        }
637        let data = w.bytes();
638        for i in 0..4usize {
639            let base = 446 + i * 16;
640            let ptype = data.get(base + 4).copied().unwrap_or(0);
641            let size = le_u32(data, base + 12);
642            if ptype != 0 && ptype != 0xEE && size != 0 {
643                return Confidence::Yes {
644                    how: "MBR partition table",
645                };
646            }
647        }
648        Confidence::No
649    }
650
651    fn open(&self, src: DynSource) -> VfsResult<Box<dyn VolumeSystem>> {
652        Ok(Box::new(Mbr::parse(src)?))
653    }
654}
655
656/// A parsed MBR: the parent source plus its primary partitions.
657struct Mbr {
658    parent: DynSource,
659    volumes: Vec<VolumeDesc>,
660}
661
662impl Mbr {
663    fn parse(src: DynSource) -> VfsResult<Self> {
664        let mut sector = [0u8; 512];
665        src.read_at(0, &mut sector)?;
666        let mut volumes = Vec::new();
667        for i in 0..4usize {
668            let base = 446 + i * 16;
669            let ptype = sector.get(base + 4).copied().unwrap_or(0);
670            let start_lba = le_u32(&sector, base + 8);
671            let size = le_u32(&sector, base + 12);
672            if ptype == 0 || ptype == 0xEE || size == 0 {
673                continue;
674            }
675            volumes.push(VolumeDesc {
676                index: i,
677                kind: VolumeKind::Partition,
678                start: u64::from(start_lba) * 512,
679                len: u64::from(size) * 512,
680                type_hint: Some(format!("0x{ptype:02x}")),
681                label: None,
682            });
683        }
684        Ok(Self {
685            parent: src,
686            volumes,
687        })
688    }
689}
690
691impl VolumeSystem for Mbr {
692    fn scheme(&self) -> VolumeScheme {
693        VolumeScheme::Mbr
694    }
695
696    fn volumes(&self) -> &[VolumeDesc] {
697        &self.volumes
698    }
699
700    fn open_volume(&self, index: usize) -> VfsResult<DynSource> {
701        let desc = self.volumes.get(index).ok_or(VfsError::OutOfRange {
702            what: "mbr volume index",
703            offset: index as u64,
704            len: 1,
705            bound: self.volumes.len() as u64,
706        })?;
707        Ok(Arc::new(SubRange::new(
708            self.parent.clone(),
709            desc.start,
710            desc.len,
711        )))
712    }
713}
714
715/// GPT (GUID Partition Table) volume system: the `EFI PART` header at LBA 1 and
716/// its partition-entry array. The protective MBR at LBA 0 is left to `MbrProbe`,
717/// which ignores the 0xEE marker so GPT takes over.
718struct GptProbe;
719
720impl VolumeSystemOpen for GptProbe {
721    fn scheme(&self) -> VolumeScheme {
722        VolumeScheme::Gpt
723    }
724
725    fn probe(&self, w: &SniffWindow) -> Confidence {
726        // GPT header signature "EFI PART" at LBA 1 (byte offset 512).
727        if w.has_magic(512, b"EFI PART") {
728            Confidence::Yes {
729                how: "GPT EFI PART header",
730            }
731        } else {
732            Confidence::No
733        }
734    }
735
736    fn open(&self, src: DynSource) -> VfsResult<Box<dyn VolumeSystem>> {
737        Ok(Box::new(Gpt::parse(src)?))
738    }
739}
740
741/// A parsed GPT: the parent source plus its partitions.
742struct Gpt {
743    parent: DynSource,
744    volumes: Vec<VolumeDesc>,
745}
746
747impl Gpt {
748    fn parse(src: DynSource) -> VfsResult<Self> {
749        // The GPT primary header lives in LBA 1.
750        let mut header = [0u8; 512];
751        src.read_at(512, &mut header)?;
752        if header.get(0..8) != Some(b"EFI PART".as_slice()) {
753            return Err(VfsError::Decode {
754                layer: "gpt",
755                offset: 512,
756                detail: "missing EFI PART signature".to_string(),
757                bytes: SmallHex::new(header.get(0..8).unwrap_or(&[])),
758            });
759        }
760        let entries_lba = le_u64(&header, 72);
761        // Bomb guards: cap the entry count and size before allocating.
762        let num_entries = le_u32(&header, 80).min(256) as usize;
763        let entry_size = le_u32(&header, 84).clamp(128, 512) as usize;
764        let array_len = num_entries.checked_mul(entry_size).unwrap_or(0);
765        let mut arr = vec![0u8; array_len];
766        src.read_at(entries_lba.saturating_mul(512), &mut arr)?;
767
768        let mut volumes = Vec::new();
769        for i in 0..num_entries {
770            let Some(base) = i.checked_mul(entry_size) else {
771                break; // cov:unreachable: num_entries<=256 & entry_size<=512 bound base
772            };
773            let Some(entry) = arr.get(base..base.saturating_add(entry_size)) else {
774                break; // cov:unreachable: arr is sized num_entries*entry_size
775            };
776            // An all-zero type GUID marks an unused entry.
777            let type_guid = entry.get(0..16).unwrap_or(&[]);
778            if type_guid.iter().all(|&b| b == 0) {
779                continue;
780            }
781            let first = le_u64(entry, 32);
782            let last = le_u64(entry, 40);
783            if last < first {
784                continue;
785            }
786            let sectors = last - first + 1;
787            volumes.push(VolumeDesc {
788                index: i,
789                kind: VolumeKind::Partition,
790                start: first.saturating_mul(512),
791                len: sectors.saturating_mul(512),
792                type_hint: Some(guid_hint(type_guid)),
793                label: None,
794            });
795        }
796        Ok(Self {
797            parent: src,
798            volumes,
799        })
800    }
801}
802
803impl VolumeSystem for Gpt {
804    fn scheme(&self) -> VolumeScheme {
805        VolumeScheme::Gpt
806    }
807
808    fn volumes(&self) -> &[VolumeDesc] {
809        &self.volumes
810    }
811
812    fn open_volume(&self, index: usize) -> VfsResult<DynSource> {
813        let desc = self.volumes.get(index).ok_or(VfsError::OutOfRange {
814            what: "gpt volume index",
815            offset: index as u64,
816            len: 1,
817            bound: self.volumes.len() as u64,
818        })?;
819        Ok(Arc::new(SubRange::new(
820            self.parent.clone(),
821            desc.start,
822            desc.len,
823        )))
824    }
825}
826
827/// Apple Partition Map (APM): a Driver Descriptor Record (`ER`) in block 0 and a
828/// chain of 512-byte partition-map entries (`PM`) from block 1. All big-endian.
829struct ApmProbe;
830
831impl VolumeSystemOpen for ApmProbe {
832    fn scheme(&self) -> VolumeScheme {
833        VolumeScheme::Apm
834    }
835
836    fn probe(&self, w: &SniffWindow) -> Confidence {
837        // DDR 'ER' at block 0 and the first partition-map entry 'PM' at block 1
838        // (512-byte blocks — the case for every fixed-disk APM).
839        if w.has_magic(0, b"ER") && w.has_magic(512, b"PM") {
840            Confidence::Yes {
841                how: "Apple Partition Map",
842            }
843        } else {
844            Confidence::No
845        }
846    }
847
848    fn open(&self, src: DynSource) -> VfsResult<Box<dyn VolumeSystem>> {
849        Ok(Box::new(Apm::parse(src)?))
850    }
851}
852
853struct Apm {
854    parent: DynSource,
855    volumes: Vec<VolumeDesc>,
856}
857
858/// Bytes read from the device start to parse the map (DDR + entries); covers 256
859/// entries at a 512-byte block size with headroom.
860const APM_MAP_CAP: u64 = 256 * 1024;
861
862impl Apm {
863    fn parse(src: DynSource) -> VfsResult<Self> {
864        // The map (DDR + PM entries) lives at the device start; read a bounded
865        // window and hand it to the fleet `apm-partition-core` reader.
866        let cap = src.len().clamp(1, APM_MAP_CAP) as usize;
867        let mut head = vec![0u8; cap];
868        let n = src.read_at(0, &mut head)?;
869        let map = apm::parse(head.get(..n).unwrap_or(&[])).ok_or_else(|| VfsError::Decode {
870            layer: "apm",
871            offset: 0,
872            detail: "not an Apple Partition Map".to_string(),
873            bytes: SmallHex::new(head.get(..2).unwrap_or(&[])),
874        })?;
875
876        let block_size = u64::from(map.block_size.max(1));
877        let mut volumes = Vec::new();
878        for (i, part) in map.partitions.iter().enumerate() {
879            // Skip the map itself and free/unused space; keep data partitions.
880            if part.type_name.eq_ignore_ascii_case("Apple_partition_map")
881                || part.type_name.eq_ignore_ascii_case("Apple_Free")
882                || part.type_name.eq_ignore_ascii_case("Apple_Void")
883            {
884                continue;
885            }
886            volumes.push(VolumeDesc {
887                index: i,
888                kind: VolumeKind::Partition,
889                start: u64::from(part.start_block) * block_size,
890                len: u64::from(part.block_count) * block_size,
891                type_hint: Some(part.type_name.clone()),
892                label: (!part.name.is_empty()).then(|| part.name.clone()),
893            });
894        }
895
896        Ok(Self {
897            parent: src,
898            volumes,
899        })
900    }
901}
902
903impl VolumeSystem for Apm {
904    fn scheme(&self) -> VolumeScheme {
905        VolumeScheme::Apm
906    }
907
908    fn volumes(&self) -> &[VolumeDesc] {
909        &self.volumes
910    }
911
912    fn open_volume(&self, index: usize) -> VfsResult<DynSource> {
913        let desc = self.volumes.get(index).ok_or(VfsError::OutOfRange {
914            what: "apm volume index",
915            offset: index as u64,
916            len: 1,
917            bound: self.volumes.len() as u64,
918        })?;
919        Ok(Arc::new(SubRange::new(
920            self.parent.clone(),
921            desc.start,
922            desc.len,
923        )))
924    }
925}
926
927fn guid_hint(bytes: &[u8]) -> String {
928    use std::fmt::Write as _;
929    let mut s = String::with_capacity(bytes.len() * 2);
930    for b in bytes {
931        let _ = write!(s, "{b:02x}");
932    }
933    s
934}
935
936/// VHD (Microsoft Virtual Hard Disk) container: a single-stream image with a
937/// `conectix` footer. Decodes to its virtual disk stream via `vhd-core`.
938struct VhdDecoder;
939
940impl ContainerOpen for VhdDecoder {
941    fn format(&self) -> ContainerFormat {
942        ContainerFormat::Vhd
943    }
944
945    fn probe(&self, w: &SniffWindow) -> Confidence {
946        // A dynamic/differencing VHD carries a footer copy ("conectix") at
947        // offset 0; a fixed VHD has it only at the end (and its head sniffs as
948        // the raw filesystem, so a filesystem prober handles that case).
949        if w.has_magic(0, b"conectix") {
950            Confidence::Yes {
951                how: "VHD conectix footer",
952            }
953        } else {
954            Confidence::No
955        }
956    }
957
958    fn open(&self, src: DynSource) -> VfsResult<DynSource> {
959        let len = src.len();
960        let cursor = SourceCursor::new(src, 0, len);
961        let reader =
962            vhd::VhdReader::open_reader(Box::new(cursor)).map_err(|e| VfsError::Decode {
963                layer: "vhd",
964                offset: 0,
965                detail: e.to_string(),
966                bytes: SmallHex::new(&[]),
967            })?;
968        let vsize = reader.virtual_disk_size();
969        Ok(Arc::new(SeekPoolSource::single(reader, vsize)))
970    }
971}
972
973/// QCOW2 (QEMU Copy-On-Write v2) container: magic `QFI\xfb`. Decodes to its
974/// virtual disk via `qcow2-core`.
975struct Qcow2Decoder;
976
977impl ContainerOpen for Qcow2Decoder {
978    fn format(&self) -> ContainerFormat {
979        ContainerFormat::Qcow2
980    }
981
982    fn probe(&self, w: &SniffWindow) -> Confidence {
983        if w.has_magic(0, &[0x51, 0x46, 0x49, 0xfb]) {
984            Confidence::Yes { how: "QCOW2 magic" }
985        } else {
986            Confidence::No
987        }
988    }
989
990    fn open(&self, src: DynSource) -> VfsResult<DynSource> {
991        let len = src.len();
992        let cursor = SourceCursor::new(src, 0, len);
993        let reader =
994            qcow2::Qcow2Reader::open_reader(Box::new(cursor)).map_err(|e| VfsError::Decode {
995                layer: "qcow2",
996                offset: 0,
997                detail: e.to_string(),
998                bytes: SmallHex::new(&[]),
999            })?;
1000        let vsize = reader.virtual_disk_size();
1001        Ok(Arc::new(SeekPoolSource::single(reader, vsize)))
1002    }
1003}
1004
1005/// VMDK (VMware Virtual Disk) monolithic/sparse container: magic `KDMV`. Decodes
1006/// to its virtual disk via `vmdk-core` (multi-file flat extents are out of scope
1007/// for the single-stream decoder).
1008struct VmdkDecoder;
1009
1010impl ContainerOpen for VmdkDecoder {
1011    fn format(&self) -> ContainerFormat {
1012        ContainerFormat::Vmdk
1013    }
1014
1015    fn probe(&self, w: &SniffWindow) -> Confidence {
1016        // Sparse-extent magic "KDMV" at offset 0 (monolithicSparse / streamOptimized).
1017        if w.has_magic(0, b"KDMV") {
1018            Confidence::Yes {
1019                how: "VMDK KDMV magic",
1020            }
1021        } else {
1022            Confidence::No
1023        }
1024    }
1025
1026    fn open(&self, src: DynSource) -> VfsResult<DynSource> {
1027        let len = src.len();
1028        let cursor = SourceCursor::new(src, 0, len);
1029        let boxed: Box<dyn vmdk::ReadSeek + Send> = Box::new(cursor);
1030        let reader = vmdk::VmdkReader::open(boxed).map_err(|e| VfsError::Decode {
1031            layer: "vmdk",
1032            offset: 0,
1033            detail: e.to_string(),
1034            bytes: SmallHex::new(&[]),
1035        })?;
1036        let vsize = reader.virtual_disk_size();
1037        Ok(Arc::new(SeekPoolSource::single(reader, vsize)))
1038    }
1039}
1040
1041/// VHDX (Hyper-V v2) container: the file identifier `vhdxfile` sits at offset 0.
1042struct VhdxDecoder;
1043
1044impl ContainerOpen for VhdxDecoder {
1045    fn format(&self) -> ContainerFormat {
1046        ContainerFormat::Vhdx
1047    }
1048
1049    fn probe(&self, w: &SniffWindow) -> Confidence {
1050        if w.has_magic(0, vhdx::FILE_MAGIC) {
1051            Confidence::Yes {
1052                how: "VHDX file magic",
1053            }
1054        } else {
1055            Confidence::No
1056        }
1057    }
1058
1059    fn open(&self, src: DynSource) -> VfsResult<DynSource> {
1060        let len = src.len();
1061        let cursor = SourceCursor::new(src, 0, len);
1062        let reader =
1063            vhdx::VhdxReader::open_reader(Box::new(cursor)).map_err(|e| VfsError::Decode {
1064                layer: "vhdx",
1065                offset: 0,
1066                detail: e.to_string(),
1067                bytes: SmallHex::new(&[]),
1068            })?;
1069        let vsize = reader.virtual_disk_size();
1070        Ok(Arc::new(SeekPoolSource::single(reader, vsize)))
1071    }
1072}
1073
1074/// DMG (Apple UDIF disk image) container: the `koly` trailer sits at the very
1075/// end of the file (`total_len - 512`), so this is a tail-probed decoder. Decodes
1076/// to its virtual disk stream via `dmg-core`.
1077struct DmgDecoder;
1078
1079impl ContainerOpen for DmgDecoder {
1080    fn format(&self) -> ContainerFormat {
1081        ContainerFormat::Dmg
1082    }
1083
1084    fn probe(&self, w: &SniffWindow) -> Confidence {
1085        // UDIF footer: the 512-byte koly trailer begins at file_len - 512.
1086        if w.has_magic_from_end(512, b"koly") {
1087            Confidence::Yes {
1088                how: "DMG koly trailer",
1089            }
1090        } else {
1091            Confidence::No
1092        }
1093    }
1094
1095    fn open(&self, src: DynSource) -> VfsResult<DynSource> {
1096        let len = src.len();
1097        let cursor = SourceCursor::new(src, 0, len);
1098        let reader = dmg::DmgReader::open(cursor).map_err(|e| VfsError::Decode {
1099            layer: "dmg",
1100            offset: 0,
1101            detail: e.to_string(),
1102            bytes: SmallHex::new(&[]),
1103        })?;
1104        let vsize = reader.virtual_disk_size();
1105        Ok(Arc::new(SeekPoolSource::single(reader, vsize)))
1106    }
1107}
1108
1109/// AFF4 (Advanced Forensic Format 4) container: a Zip archive, so it sniffs only
1110/// as a `Maybe` on the `PK\x03\x04` local-file-header magic — `open` (via
1111/// `aff4-core`) disambiguates a real AFF4 from an unrelated Zip.
1112struct Aff4Decoder;
1113
1114impl ContainerOpen for Aff4Decoder {
1115    fn format(&self) -> ContainerFormat {
1116        ContainerFormat::Aff4
1117    }
1118
1119    fn probe(&self, w: &SniffWindow) -> Confidence {
1120        if w.has_magic(0, &[0x50, 0x4b, 0x03, 0x04]) {
1121            Confidence::Maybe
1122        } else {
1123            Confidence::No
1124        }
1125    }
1126
1127    fn open(&self, src: DynSource) -> VfsResult<DynSource> {
1128        let len = src.len();
1129        let cursor = SourceCursor::new(src, 0, len);
1130        let reader =
1131            aff4::Aff4Reader::open_reader(Box::new(cursor)).map_err(|e| VfsError::Decode {
1132                layer: "aff4",
1133                offset: 0,
1134                detail: e.to_string(),
1135                bytes: SmallHex::new(&[]),
1136            })?;
1137        let vsize = reader.virtual_disk_size();
1138        Ok(Arc::new(SeekPoolSource::single(reader, vsize)))
1139    }
1140}
1141
1142/// Cap on directory recursion depth in [`walk`] — a filesystem-loop guard.
1143const WALK_MAX_DEPTH: usize = 256;
1144
1145/// One node found by [`walk`]: its path components (filesystem names are bytes,
1146/// not guaranteed UTF-8), its filesystem id, and its metadata.
1147pub struct WalkEntry {
1148    pub path: Vec<Vec<u8>>,
1149    pub id: FileId,
1150    pub meta: FsMeta,
1151}
1152
1153/// Recursively enumerate every node of a mounted filesystem from the root — the
1154/// traversal a triage consumer runs over `Vfs::open(...).fs`. Depth-capped and
1155/// visited-guarded against directory loops; `.`/`..` self/parent entries are
1156/// skipped. Returns the nodes; a per-node read error aborts loud.
1157pub fn walk(fs: &dyn FileSystem) -> VfsResult<Vec<WalkEntry>> {
1158    let mut out = Vec::new();
1159    let mut visited: HashSet<FileId> = HashSet::new();
1160    let mut stack: Vec<(Vec<Vec<u8>>, FileId, usize)> = vec![(Vec::new(), fs.root(), 0)];
1161    while let Some((prefix, dir_id, depth)) = stack.pop() {
1162        if depth > WALK_MAX_DEPTH || !visited.insert(dir_id) {
1163            continue;
1164        }
1165        for entry in fs.read_dir(dir_id)? {
1166            let entry = entry?;
1167            if matches!(entry.name.as_slice(), b"." | b"..") {
1168                continue;
1169            }
1170            let mut path = prefix.clone();
1171            path.push(entry.name);
1172            let meta = fs.meta(entry.id)?;
1173            let is_dir = matches!(meta.kind, NodeKind::Dir);
1174            out.push(WalkEntry {
1175                path: path.clone(),
1176                id: entry.id,
1177                meta,
1178            });
1179            if is_dir {
1180                stack.push((path, entry.id, depth + 1));
1181            }
1182        }
1183    }
1184    Ok(out)
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189    use super::*;
1190    use forensic_vfs::ImageSource;
1191    use std::io::Write;
1192
1193    struct Mem(Vec<u8>);
1194    impl ImageSource for Mem {
1195        fn len(&self) -> u64 {
1196            self.0.len() as u64
1197        }
1198        fn read_at(&self, offset: u64, buf: &mut [u8]) -> VfsResult<usize> {
1199            let off = usize::try_from(offset).unwrap_or(usize::MAX);
1200            let Some(s) = self.0.get(off..) else {
1201                return Ok(0);
1202            };
1203            let n = s.len().min(buf.len());
1204            buf[..n].copy_from_slice(&s[..n]);
1205            Ok(n)
1206        }
1207    }
1208    fn mem(b: Vec<u8>) -> DynSource {
1209        Arc::new(Mem(b))
1210    }
1211    fn window(b: &[u8]) -> SniffWindow<'_> {
1212        SniffWindow::new(0, b)
1213    }
1214
1215    #[test]
1216    fn default_openers_registers_btrfs_ufs_udf() {
1217        // The three new filesystem probers grow the registered set from 8 to 11
1218        // and expose the BTRFS/UFS/UDF kinds so the resolver can auto-detect them.
1219        let kinds: Vec<FsKind> = default_openers()
1220            .filesystems()
1221            .iter()
1222            .map(|p| p.kind())
1223            .collect();
1224        assert!(kinds.contains(&FsKind::BTRFS), "btrfs prober registered");
1225        assert!(kinds.contains(&FsKind::UFS), "ufs prober registered");
1226        assert!(kinds.contains(&FsKind::UDF), "udf prober registered");
1227        assert_eq!(kinds.len(), 11, "8 existing + 3 new filesystem probers");
1228    }
1229
1230    #[test]
1231    fn default_is_new_and_probers_report_their_kinds() {
1232        let _ = Vfs::default().open_source(mem(vec![0u8; 64])).unwrap();
1233        assert_eq!(NtfsProbe.kind(), FsKind::NTFS);
1234        assert_eq!(MbrProbe.scheme(), VolumeScheme::Mbr);
1235        assert_eq!(GptProbe.scheme(), VolumeScheme::Gpt);
1236    }
1237
1238    #[test]
1239    fn probers_say_no_on_unrecognized_bytes() {
1240        let empty = window(&[]);
1241        assert_eq!(NtfsProbe.probe(&empty), Confidence::No);
1242        assert_eq!(MbrProbe.probe(&empty), Confidence::No);
1243        assert_eq!(GptProbe.probe(&empty), Confidence::No);
1244        // 0x55AA present but only a 0xEE protective entry -> Mbr declines (GPT's job).
1245        let mut prot = vec![0u8; 512];
1246        prot[446 + 4] = 0xEE;
1247        prot[446 + 12] = 1; // non-zero size
1248        prot[510] = 0x55;
1249        prot[511] = 0xaa;
1250        assert_eq!(MbrProbe.probe(&window(&prot)), Confidence::No);
1251    }
1252
1253    #[test]
1254    fn ntfs_magic_but_invalid_boot_is_a_loud_error() {
1255        // "NTFS    " at offset 3 makes NtfsProbe say Yes; the garbage then fails
1256        // NtfsFs::open -> Decode error propagates (never a silent None).
1257        let mut v = vec![0u8; 4096];
1258        v[3..11].copy_from_slice(b"NTFS    ");
1259        assert!(Vfs::new().open_source(mem(v)).is_err());
1260    }
1261
1262    #[test]
1263    fn a_garbage_e01_path_fails_loud() {
1264        let mut f = tempfile::Builder::new().suffix(".E01").tempfile().unwrap();
1265        f.write_all(b"not really an EWF image").unwrap();
1266        f.flush().unwrap();
1267        assert!(Vfs::new().open(f.path()).is_err());
1268    }
1269
1270    #[test]
1271    fn gpt_parse_without_signature_errors_and_mbr_volume_index_is_bounded() {
1272        // Gpt::parse directly on bytes lacking EFI PART.
1273        assert!(Gpt::parse(mem(vec![0u8; 1024])).is_err());
1274        // A valid single-entry MBR; open_volume out of range errors.
1275        let mut d = vec![0u8; 512];
1276        d[446 + 4] = 0x07;
1277        d[446 + 8] = 1; // start LBA 1
1278        d[446 + 12] = 4; // size 4 sectors
1279        d[510] = 0x55;
1280        d[511] = 0xaa;
1281        let m = Mbr::parse(mem(d)).unwrap();
1282        assert_eq!(m.scheme(), VolumeScheme::Mbr);
1283        assert_eq!(m.volumes().len(), 1);
1284        assert!(m.open_volume(0).is_ok());
1285        assert!(m.open_volume(9).is_err());
1286    }
1287
1288    #[test]
1289    fn apm_maps_partitions_and_errors_on_non_apm() {
1290        // A Driver Descriptor Map (block 0) with a 512-byte block size.
1291        let mut img = vec![0u8; 512];
1292        img[0..2].copy_from_slice(b"ER");
1293        img[2..4].copy_from_slice(&512u16.to_be_bytes()); // sbBlkSize
1294                                                          // A partition-map entry.
1295        let pm = |map_cnt: u32, pstart: u32, pcnt: u32, ptype: &str| {
1296            let mut e = vec![0u8; 512];
1297            e[0..2].copy_from_slice(b"PM");
1298            e[4..8].copy_from_slice(&map_cnt.to_be_bytes());
1299            e[8..12].copy_from_slice(&pstart.to_be_bytes());
1300            e[0x0c..0x10].copy_from_slice(&pcnt.to_be_bytes());
1301            e[0x30..0x30 + ptype.len()].copy_from_slice(ptype.as_bytes());
1302            e
1303        };
1304        // The map's own entry (skipped) + one Apple_HFS data partition.
1305        img.extend(pm(2, 1, 63, "Apple_partition_map"));
1306        img.extend(pm(2, 4, 2, "Apple_HFS"));
1307        img.extend(vec![0u8; 4 * 512]);
1308
1309        let apm = Apm::parse(mem(img)).unwrap();
1310        assert_eq!(apm.scheme(), VolumeScheme::Apm);
1311        assert_eq!(apm.volumes().len(), 1); // Apple_partition_map is skipped
1312        assert_eq!(apm.volumes()[0].start, 4 * 512); // start_block 4 × 512
1313        assert!(apm.open_volume(0).is_ok());
1314        assert!(apm.open_volume(9).is_err());
1315
1316        // No 'ER' signature -> apm-partition-core returns None -> loud Decode error.
1317        assert!(Apm::parse(mem(vec![0u8; 2048])).is_err());
1318    }
1319
1320    #[test]
1321    fn recursion_is_depth_capped_on_a_self_referential_mbr() {
1322        // A partition covering the whole disk (start 0) recurses into itself; the
1323        // depth cap breaks it, yielding None rather than a stack overflow.
1324        let mut d = vec![0u8; 1024];
1325        d[446 + 4] = 0x83; // linux
1326                           // start LBA 0 (bytes stay 0), size 2 sectors
1327        d[446 + 12] = 2;
1328        d[510] = 0x55;
1329        d[511] = 0xaa;
1330        assert!(Vfs::new().open_source(mem(d)).unwrap().is_none());
1331    }
1332
1333    #[test]
1334    fn container_decoders_report_format_and_error_on_bad_content() {
1335        assert_eq!(VhdDecoder.format(), ContainerFormat::Vhd);
1336        assert_eq!(Qcow2Decoder.format(), ContainerFormat::Qcow2);
1337        assert_eq!(VmdkDecoder.format(), ContainerFormat::Vmdk);
1338        assert_eq!(VhdxDecoder.format(), ContainerFormat::Vhdx);
1339        // Valid magic but garbage body -> the reader fails -> loud error, never
1340        // a silent None.
1341        let mut vhd = vec![0u8; 4096];
1342        vhd[0..8].copy_from_slice(b"conectix");
1343        assert!(Vfs::new().open_source(mem(vhd)).is_err());
1344        let mut q = vec![0u8; 4096];
1345        q[0..4].copy_from_slice(&[0x51, 0x46, 0x49, 0xfb]);
1346        assert!(Vfs::new().open_source(mem(q)).is_err());
1347        let mut v = vec![0u8; 4096];
1348        v[0..4].copy_from_slice(b"KDMV");
1349        assert!(Vfs::new().open_source(mem(v)).is_err());
1350        let mut x = vec![0u8; 4096];
1351        x[0..8].copy_from_slice(vhdx::FILE_MAGIC);
1352        assert!(Vfs::new().open_source(mem(x)).is_err());
1353    }
1354
1355    #[test]
1356    fn dmg_decoder_format_probe_and_open_error() {
1357        assert_eq!(DmgDecoder.format(), ContainerFormat::Dmg);
1358        // No koly trailer in the tail -> No (an all-zero window).
1359        assert_eq!(
1360            DmgDecoder.probe(&SniffWindow::with_tail(0, &[], 1024, &[0u8; 512])),
1361            Confidence::No
1362        );
1363        // koly at the tail (file_len - 512) makes the tail probe say Yes; an
1364        // xml plist region that overruns the file then fails DmgReader::open ->
1365        // loud error, never a silent None. koly starts at offset 512; its
1366        // xml_length field (koly+224) is set past the file end.
1367        let mut v = vec![0u8; 1024];
1368        v[512..516].copy_from_slice(b"koly");
1369        v[512 + 224..512 + 232].copy_from_slice(&u64::MAX.to_be_bytes());
1370        assert!(Vfs::new().open_source(mem(v)).is_err());
1371    }
1372
1373    #[test]
1374    fn aff4_decoder_format_probe_and_open_error() {
1375        assert_eq!(Aff4Decoder.format(), ContainerFormat::Aff4);
1376        // No PK header -> No; a PK header -> Maybe (open disambiguates).
1377        assert_eq!(Aff4Decoder.probe(&window(&[])), Confidence::No);
1378        assert_eq!(
1379            Aff4Decoder.probe(&window(&[0x50, 0x4b, 0x03, 0x04])),
1380            Confidence::Maybe
1381        );
1382        // PK magic but not a valid AFF4 (garbage after the header) -> the reader
1383        // fails -> loud error, never a silent None.
1384        let mut v = vec![0u8; 256];
1385        v[0..4].copy_from_slice(&[0x50, 0x4b, 0x03, 0x04]);
1386        assert!(Vfs::new().open_source(mem(v)).is_err());
1387    }
1388
1389    #[test]
1390    fn a_valid_container_holding_no_filesystem_resolves_to_none() {
1391        // An empty dynamic VHD decodes fine but its virtual disk is all zeros —
1392        // no filesystem inside, so the container loop falls through to None.
1393        let vhd = include_bytes!("../tests/data/empty.vhd").to_vec();
1394        assert!(Vfs::new().open_source(mem(vhd)).unwrap().is_none());
1395    }
1396
1397    #[test]
1398    fn ext4_probe_kind_and_open_error() {
1399        assert_eq!(Ext4Probe.kind(), FsKind::EXT);
1400        // ext4 magic (0x53EF LE @ 1080) but an absurd s_log_block_size (@ 1048)
1401        // -> Ext4Fs::open rejects it -> loud error, never a silent None.
1402        let mut v = vec![0u8; 4096];
1403        v[1080] = 0x53;
1404        v[1081] = 0xef;
1405        v[1048..1052].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
1406        assert!(Vfs::new().open_source(mem(v)).is_err());
1407    }
1408
1409    #[test]
1410    fn iso9660_probe_kind_and_open_error() {
1411        assert_eq!(Iso9660Probe.kind(), FsKind::ISO9660);
1412        assert_eq!(Iso9660Probe.probe(&window(&[])), Confidence::No);
1413        // CD001 present at offset 32769 makes the probe say Yes; the surrounding
1414        // garbage then fails IsoVfs::open -> loud Decode error, never a silent None.
1415        let mut v = vec![0u8; 40 * 1024];
1416        v[32769..32774].copy_from_slice(b"CD001");
1417        assert_eq!(
1418            Iso9660Probe.probe(&window(&v)),
1419            Confidence::Yes {
1420                how: "ISO 9660 CD001 volume descriptor"
1421            }
1422        );
1423        assert!(Vfs::new().open_source(mem(v)).is_err());
1424    }
1425
1426    #[test]
1427    fn apfs_probe_kind_and_open_error() {
1428        assert_eq!(ApfsProbe.kind(), FsKind::APFS);
1429        assert_eq!(ApfsProbe.probe(&window(&[])), Confidence::No);
1430        // NXSB at offset 32 makes the probe say Yes; the surrounding garbage then
1431        // fails ApfsFs::open -> loud Decode error, never a silent None.
1432        let mut v = vec![0u8; 40 * 1024];
1433        v[32..36].copy_from_slice(b"NXSB");
1434        assert_eq!(
1435            ApfsProbe.probe(&window(&v)),
1436            Confidence::Yes {
1437                how: "APFS NXSB container superblock"
1438            }
1439        );
1440        assert!(Vfs::new().open_source(mem(v)).is_err());
1441    }
1442
1443    #[test]
1444    fn hfsplus_probe_kind_and_no_on_short_window() {
1445        assert_eq!(HfsPlusProbe.kind(), FsKind::HFS_PLUS);
1446        // A window shorter than 1026 bytes cannot carry the @1024 signature.
1447        assert_eq!(HfsPlusProbe.probe(&window(&[])), Confidence::No);
1448        // HFSX signature 'HX' at 1024 is also accepted.
1449        let mut v = vec![0u8; 40 * 1024];
1450        v[1024..1026].copy_from_slice(&[0x48, 0x58]);
1451        assert_eq!(
1452            HfsPlusProbe.probe(&window(&v)),
1453            Confidence::Yes {
1454                how: "HFS+/HFSX volume header"
1455            }
1456        );
1457    }
1458
1459    #[test]
1460    fn fat_and_exfat_magic_but_garbage_are_loud_errors() {
1461        // exFAT identifier + boot signature, but no valid structure -> loud error.
1462        let mut x = vec![0u8; 4096];
1463        x[3..11].copy_from_slice(b"EXFAT   ");
1464        x[510] = 0x55;
1465        x[511] = 0xaa;
1466        assert!(Vfs::new().open_source(mem(x)).is_err());
1467
1468        // A plausible FAT BPB (jump + 512 bytes/sector + 0x55AA) over garbage ->
1469        // FatProbe says Yes, then FatFs::open fails loudly, never a silent None.
1470        let mut f = vec![0u8; 4096];
1471        f[0] = 0xEB;
1472        f[11..13].copy_from_slice(&512u16.to_le_bytes());
1473        f[510] = 0x55;
1474        f[511] = 0xaa;
1475        assert!(Vfs::new().open_source(mem(f)).is_err());
1476    }
1477
1478    #[test]
1479    fn guid_hint_is_lowercase_hex() {
1480        assert_eq!(guid_hint(&[0xde, 0xad, 0xbe, 0xef]), "deadbeef");
1481    }
1482
1483    #[test]
1484    fn gpt_parse_skips_unused_and_reversed_entries() {
1485        let mut d = vec![0u8; 1280];
1486        d[512..520].copy_from_slice(b"EFI PART");
1487        d[512 + 72..512 + 80].copy_from_slice(&2u64.to_le_bytes()); // entries LBA 2
1488        d[512 + 80..512 + 84].copy_from_slice(&2u32.to_le_bytes()); // num entries
1489        d[512 + 84..512 + 88].copy_from_slice(&128u32.to_le_bytes()); // entry size
1490                                                                      // entry 0 @ 1024: valid basic-data partition, first 100 last 200
1491        d[1024] = 0xa2; // non-zero type GUID
1492        d[1024 + 32..1024 + 40].copy_from_slice(&100u64.to_le_bytes());
1493        d[1024 + 40..1024 + 48].copy_from_slice(&200u64.to_le_bytes());
1494        // entry 1 @ 1152: non-zero GUID but last<first -> skipped (continue)
1495        d[1152] = 0xa2;
1496        d[1152 + 32..1152 + 40].copy_from_slice(&500u64.to_le_bytes());
1497        d[1152 + 40..1152 + 48].copy_from_slice(&400u64.to_le_bytes());
1498        let g = Gpt::parse(mem(d)).unwrap();
1499        assert_eq!(g.scheme(), VolumeScheme::Gpt);
1500        assert_eq!(g.volumes().len(), 1, "reversed entry 1 is skipped");
1501        assert_eq!(g.volumes()[0].start, 100 * 512);
1502        assert!(g.open_volume(0).is_ok());
1503        assert!(g.open_volume(7).is_err());
1504
1505        // test helper: a read starting past the end returns 0
1506        assert_eq!(Mem(vec![1, 2, 3]).read_at(99, &mut [0u8; 4]).unwrap(), 0);
1507    }
1508
1509    // --- APFS snapshot cohort ([H] wiring) ---
1510
1511    const APFS_FIXTURE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/apfs_volume.bin");
1512    const EXT4_FIXTURE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/ext4.img");
1513
1514    /// The committed P4 fixture's live volume transaction id, resolved through the
1515    /// public apfs-core container API (the fixture has zero snapshots, so the live
1516    /// xid is the only mountable point in the timeline).
1517    fn apfs_live_xid() -> u64 {
1518        use std::io::{Read, Seek, SeekFrom};
1519        let bytes = std::fs::read(APFS_FIXTURE).unwrap();
1520        let mut c = apfs_core::ApfsContainer::open(std::io::Cursor::new(bytes)).unwrap();
1521        let bs = u64::from(c.superblock().block_size);
1522        let vaddr = c.volume_superblock_addrs().unwrap()[0];
1523        let mut r = c.into_reader();
1524        r.seek(SeekFrom::Start(vaddr * bs)).unwrap();
1525        let mut buf = vec![0u8; bs as usize];
1526        r.read_exact(&mut buf).unwrap();
1527        apfs_core::volume::ApfsVolume::parse(&buf).unwrap().xid()
1528    }
1529
1530    fn zeros_file() -> tempfile::NamedTempFile {
1531        let mut f = tempfile::NamedTempFile::new().unwrap();
1532        f.write_all(&[0u8; 4096]).unwrap();
1533        f.flush().unwrap();
1534        f
1535    }
1536
1537    #[test]
1538    fn epoch_from_create_time_round_trips_and_orders() {
1539        let t = 0x0123_4567_89ab_cdefu64;
1540        let tag = epoch_from_create_time(t);
1541        assert_eq!(&tag.0[0..24], &[0u8; 24], "high 24 bytes are zero");
1542        assert_eq!(
1543            u64::from_be_bytes(tag.0[24..32].try_into().unwrap()),
1544            t,
1545            "create_time round-trips out of the low 8 bytes"
1546        );
1547        assert!(
1548            epoch_from_create_time(t + 1).0 > tag.0,
1549            "a later create_time yields a greater tag"
1550        );
1551    }
1552
1553    #[test]
1554    fn snapshot_view_carries_epoch_and_snapshot_locator() {
1555        let base = PathSpec::os("/ev.dmg");
1556        let v = snapshot_view(&base, 42, "daily".to_string(), 1000);
1557        assert_eq!(v.xid, 42);
1558        assert_eq!(v.name, "daily");
1559        assert_eq!(v.epoch, epoch_from_create_time(1000));
1560        assert!(matches!(
1561            v.locator.layer,
1562            Layer::Snapshot {
1563                store: SnapshotRef::ApfsXid(42)
1564            }
1565        ));
1566    }
1567
1568    #[test]
1569    fn snapshots_on_unrecognized_source_is_empty() {
1570        let f = zeros_file();
1571        assert!(Vfs::new().snapshots(f.path()).unwrap().is_empty());
1572    }
1573
1574    #[test]
1575    fn snapshots_on_non_apfs_filesystem_is_empty() {
1576        // ext4 mounts fine but is not APFS -> an empty cohort, never an error.
1577        assert!(Vfs::new()
1578            .snapshots(Path::new(EXT4_FIXTURE))
1579            .unwrap()
1580            .is_empty());
1581    }
1582
1583    #[test]
1584    fn open_snapshot_without_filesystem_is_bootstrap_error() {
1585        let f = zeros_file();
1586        assert!(matches!(
1587            Vfs::new().open_snapshot(f.path(), 1),
1588            Err(VfsError::Bootstrap { .. })
1589        ));
1590    }
1591
1592    #[test]
1593    fn open_snapshot_on_non_apfs_is_unsupported() {
1594        assert!(matches!(
1595            Vfs::new().open_snapshot(Path::new(EXT4_FIXTURE), 1),
1596            Err(VfsError::Unsupported { .. })
1597        ));
1598    }
1599
1600    #[test]
1601    fn open_snapshot_unknown_xid_is_a_loud_decode_error() {
1602        // A xid that is neither the live volume's nor a retained snapshot's ->
1603        // apfs-core SnapshotNotFound, surfaced as a VFS decode error.
1604        let bogus = apfs_live_xid().wrapping_add(0xDEAD_BEEF);
1605        assert!(matches!(
1606            Vfs::new().open_snapshot(Path::new(APFS_FIXTURE), bogus),
1607            Err(VfsError::Decode { .. })
1608        ));
1609    }
1610
1611    #[test]
1612    fn open_snapshot_at_live_xid_mounts_and_walks() {
1613        let ev = Vfs::new()
1614            .open_snapshot(Path::new(APFS_FIXTURE), apfs_live_xid())
1615            .expect("open live-xid snapshot");
1616        let uri = ev.root.to_uri();
1617        assert!(
1618            uri.contains("snapshot:apfs") && uri.contains("fs:apfs"),
1619            "locator names the snapshot + APFS layers: {uri}"
1620        );
1621        let fs = ev.fs.expect("snapshot mounts a filesystem");
1622        let names: Vec<String> = walk(fs.as_ref())
1623            .unwrap()
1624            .into_iter()
1625            .filter_map(|e| {
1626                e.path
1627                    .last()
1628                    .map(|n| String::from_utf8_lossy(n).to_string())
1629            })
1630            .collect();
1631        assert!(names.iter().any(|n| n == "plain.txt"), "walk: {names:?}");
1632    }
1633
1634    // --- golden: engine resolution == forensic_vfs_resolver::SourceOpen::open ---
1635
1636    #[test]
1637    fn engine_resolution_matches_openers_open_directly() {
1638        // Driving resolution through the engine (`open_source`) yields the SAME
1639        // resolved filesystem as calling `Openers::open` (the resolver's
1640        // `SourceOpen`) directly on the same source. Both paths share the
1641        // resolver's one implementation; this pins that invariant so a future
1642        // divergence is caught by a failing test, not shipped silently.
1643        let bytes = std::fs::read(EXT4_FIXTURE).unwrap();
1644        let len = bytes.len() as u64;
1645
1646        // Engine path.
1647        let via_engine = Vfs::new()
1648            .open_source(mem(bytes.clone()))
1649            .unwrap()
1650            .expect("engine resolves the ext4 fixture");
1651
1652        // Direct Openers::open path, same default openers, same base spec.
1653        let base = PathSpec::root(Layer::Range { start: 0, len });
1654        let resolved = default_openers()
1655            .open(mem(bytes), base, 0)
1656            .unwrap()
1657            .expect("Openers::open resolves the ext4 fixture");
1658
1659        // Same mounted filesystem identity: an identical walk of every node.
1660        let names = |fs: &dyn FileSystem| {
1661            let mut v: Vec<Vec<Vec<u8>>> = walk(fs).unwrap().into_iter().map(|e| e.path).collect();
1662            v.sort();
1663            v
1664        };
1665        assert_eq!(
1666            names(via_engine.as_ref()),
1667            names(resolved.fs.as_ref()),
1668            "engine and Openers::open mount the same filesystem"
1669        );
1670        // And the registry locator's top layer names the ext filesystem.
1671        assert!(
1672            matches!(
1673                resolved.spec.layer,
1674                Layer::Fs {
1675                    kind: FsKind::EXT,
1676                    ..
1677                }
1678            ),
1679            "registry resolved spec tops with fs:ext: {}",
1680            resolved.spec.to_uri()
1681        );
1682    }
1683}