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