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