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