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