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