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