Skip to main content

forensic_vfs_resolver/
lib.rs

1//! # forensic-vfs-resolver
2//!
3//! The generic layer resolver: sniff a byte source, match a registered prober,
4//! and descend container/volume/filesystem layers until a filesystem mounts.
5//!
6//! This is the reader-independent core of detection — it touches only the
7//! [`forensic_vfs::Openers`] prober traits and the layered
8//! [`PathSpec`]/[`Layer`] model, never a concrete reader. It is deliberately
9//! split out of the [`forensic-vfs`](https://docs.rs/forensic-vfs) contract leaf
10//! so the *evolving detection behavior* (this crate) is firewalled from the
11//! *frozen contract* the fleet's reader crates pin.
12//!
13//! The orchestration layer (`forensic-vfs-engine`) wires concrete probers into a
14//! [`Openers`], resolves a base [`DynSource`] from a path (EWF-by-path vs a raw
15//! file), and offers the by-path `open`/snapshot API; everything below that — the
16//! recursion, the sniff windows, [`walk`], and the snapshot *view* — lives here so
17//! any tool or test can drive it openers-first.
18//!
19//! Because `open` cannot be an inherent method on the leaf's [`Openers`] from
20//! another crate (the orphan rule), it is exposed as the [`SourceOpen`] extension
21//! trait; bring it into scope and call `openers.open(source, spec, 0)`.
22
23// Tests may unwrap/expect freely; production code may not.
24#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
25
26use std::collections::HashSet;
27
28use state_history_forensic::epoch::EpochTag;
29
30use forensic_vfs::{
31    ArchiveContents, DynSource, FileId, FileSystem, FsMeta, Layer, NodeAddr, NodeKind, Openers,
32    PathSpec, SnapshotRef, SniffWindow, VfsResult,
33};
34
35/// Depth cap on the recursive resolve (container/volume nesting) — a bomb guard.
36const MAX_DEPTH: usize = 8;
37
38/// Bytes read into the sniff window. Sized so a prober can see multi-offset
39/// magics — notably the ISO 9660 Primary Volume Descriptor (`CD001` at byte
40/// offset 32769, LBA 16). NTFS/ext4/MBR/GPT/container magics all sit in the
41/// first few KiB, so the larger window is a strict superset. One bounded read.
42const SNIFF_CAP: u64 = 128 * 1024;
43
44/// Bytes read into the *tail* sniff window from the end of the source. Sized to
45/// cover trailer signatures like the DMG `koly` footer (at `total_len - 512`).
46const TAIL_CAP: u64 = 4096;
47
48/// Cap on directory recursion depth in [`walk`] — a filesystem-loop guard.
49const WALK_MAX_DEPTH: usize = 256;
50
51/// One resolved piece of evidence: its locator plus the mounted filesystem, when
52/// the resolver detected one (`None` for a source no registered prober recognized).
53pub struct Evidence {
54    /// The locator this evidence was opened from.
55    pub root: PathSpec,
56    /// The mounted read-only filesystem, if detected.
57    pub fs: Option<forensic_vfs::DynFs>,
58}
59
60/// One resolved layer stack: the mounted filesystem, its locator, and the byte
61/// source it was mounted from (plus that source's pre-filesystem locator, the
62/// base a snapshot layer is pushed onto).
63pub struct Resolved {
64    /// The mounted read-only filesystem.
65    pub fs: forensic_vfs::DynFs,
66    /// The full locator, topped by the `fs:` layer.
67    pub spec: PathSpec,
68    /// The byte source the filesystem was mounted from.
69    pub source: DynSource,
70    /// That source's pre-filesystem locator (the base a snapshot layer sits on).
71    pub source_spec: PathSpec,
72}
73
74/// The generic layer resolver, exposed as an extension trait on the leaf's
75/// [`Openers`]. Bring it into scope (`use forensic_vfs_resolver::SourceOpen;`) to
76/// call `openers.open(source, spec, 0)`.
77pub trait SourceOpen {
78    /// Recursively resolve a source to a filesystem: sniff its head (and a tail
79    /// window for trailer magics); if a filesystem prober recognizes it, mount it;
80    /// otherwise if a volume-system prober recognizes it, descend into each volume
81    /// and resolve that; otherwise if a container decoder recognizes it, decode and
82    /// resolve the decoded stream. `Ok(None)` when nothing recognizes it — a
83    /// genuinely clean unknown, not an error. A prober's `open` failure after a
84    /// positive verdict propagates loud (never a silent `None`).
85    ///
86    /// `depth` is the current nesting level; callers start at `0`. The recursion is
87    /// depth-capped against a self-referential container/volume bomb.
88    ///
89    /// # Errors
90    /// Propagates a source read error, or a prober `open`/decode failure raised
91    /// after a positive probe verdict.
92    fn open(&self, source: DynSource, spec: PathSpec, depth: usize) -> VfsResult<Option<Resolved>>;
93}
94
95impl SourceOpen for Openers {
96    fn open(&self, source: DynSource, spec: PathSpec, depth: usize) -> VfsResult<Option<Resolved>> {
97        if depth > MAX_DEPTH {
98            return Ok(None);
99        }
100        let total = source.len();
101        let cap = total.clamp(1, SNIFF_CAP) as usize;
102        let mut head = vec![0u8; cap];
103        let n = source.read_at(0, &mut head)?;
104        // A tail window (the last bytes of the source) so a prober can match a
105        // trailer signature the head never reaches — e.g. the DMG koly footer.
106        let tail_cap = total.min(TAIL_CAP);
107        let tail_start = total - tail_cap;
108        let mut tail = vec![0u8; tail_cap as usize];
109        let tn = source.read_at(tail_start, &mut tail)?;
110        let window = SniffWindow::with_tail(
111            0,
112            head.get(..n).unwrap_or(&[]),
113            total,
114            tail.get(..tn).unwrap_or(&[]),
115        );
116
117        for probe in self.filesystems() {
118            if probe.probe(&window).is_candidate() {
119                let fs = probe.open(source.clone())?;
120                let fs_spec = spec.clone().push(Layer::Fs {
121                    kind: probe.kind(),
122                    at: NodeAddr::Path(Vec::new()),
123                });
124                return Ok(Some(Resolved {
125                    fs,
126                    spec: fs_spec,
127                    source,
128                    source_spec: spec,
129                }));
130            }
131        }
132        for vsp in self.volume_systems() {
133            if vsp.probe(&window).is_candidate() {
134                let vs = vsp.open(source.clone())?;
135                for index in 0..vs.volumes().len() {
136                    let sub = vs.open_volume(index)?;
137                    let child = spec.clone().push(Layer::Volume {
138                        scheme: vsp.scheme(),
139                        index,
140                        guid: None,
141                    });
142                    if let Some(found) = self.open(sub, child, depth + 1)? {
143                        return Ok(Some(found));
144                    }
145                }
146            }
147        }
148        for cd in self.containers() {
149            if cd.probe(&window).is_candidate() {
150                let decoded = cd.open(source.clone())?;
151                let child = spec.clone().push(Layer::Container {
152                    format: cd.format(),
153                });
154                if let Some(found) = self.open(decoded, child, depth + 1)? {
155                    return Ok(Some(found));
156                }
157            }
158        }
159        // Archive descent (ADR 0008): a bare gz/bz2 wrapper peels to a single
160        // decoded stream (1→1) that re-enters resolution like a container decode;
161        // a multi-member archive peels to a member table (1→N) whose members each
162        // re-enter. The member index selected is carried in the locator via
163        // `Layer::Archive { member }`, mirroring the volume-system multi-volume
164        // descent (first sub-source that resolves to a filesystem wins).
165        for ar in self.archives() {
166            if ar.probe(&window).is_candidate() {
167                match ar.open(source.clone())? {
168                    ArchiveContents::Stream(inner) => {
169                        let child = spec.clone().push(Layer::Archive { member: None });
170                        if let Some(found) = self.open(inner, child, depth + 1)? {
171                            return Ok(Some(found));
172                        }
173                    }
174                    ArchiveContents::Members(members) => {
175                        for (index, member) in members.into_iter().enumerate() {
176                            let child = spec.clone().push(Layer::Archive {
177                                member: Some(index),
178                            });
179                            if let Some(found) = self.open(member.source, child, depth + 1)? {
180                                return Ok(Some(found));
181                            }
182                        }
183                    }
184                    // A future `#[non_exhaustive]` ArchiveContents variant this
185                    // resolver predates: fall through like an unrecognized source.
186                    _ => {} // cov:unreachable: no other ArchiveContents variant exists today
187                }
188            }
189        }
190        Ok(None)
191    }
192}
193
194/// One snapshot of a filesystem, viewed as a time-indexed state in the `[H]`
195/// cohort: the wall-clock [`EpochTag`], the transaction id, the snapshot name,
196/// and a re-openable [`PathSpec`] locator (base ⇒ `Snapshot{ApfsXid}`).
197#[derive(Debug, Clone)]
198pub struct SnapshotView {
199    /// Time-indexed identity, derived from the snapshot's `create_time`.
200    pub epoch: EpochTag,
201    /// The snapshot transaction id.
202    pub xid: u64,
203    /// The snapshot name.
204    pub name: String,
205    /// A locator that the orchestration layer re-opens end-to-end.
206    pub locator: PathSpec,
207}
208
209/// Build a [`SnapshotView`] under `source_spec` (the source's pre-filesystem
210/// locator) from a snapshot's transaction id, name, and `create_time`. Takes
211/// primitives rather than a concrete reader's `#[non_exhaustive]` snapshot type so
212/// the mapping is unit-testable directly.
213#[must_use]
214pub fn snapshot_view(
215    source_spec: &PathSpec,
216    xid: u64,
217    name: String,
218    create_time: u64,
219) -> SnapshotView {
220    SnapshotView {
221        epoch: epoch_from_create_time(create_time),
222        xid,
223        name,
224        locator: source_spec.clone().push(Layer::Snapshot {
225            store: SnapshotRef::ApfsXid(xid),
226        }),
227    }
228}
229
230/// Derive an [`EpochTag`] from a snapshot `create_time` (nanoseconds since
231/// 1970-01-01 UTC). The big-endian nanosecond timestamp occupies the low 8 bytes
232/// (indices 24..32) of the 32-byte tag; the rest is zero. This is simple and
233/// reversible — the timestamp round-trips back out of those 8 bytes — and orders
234/// correctly: a later `create_time` yields a lexicographically greater tag.
235#[must_use]
236pub fn epoch_from_create_time(create_time_ns: u64) -> EpochTag {
237    let mut bytes = [0u8; 32];
238    bytes[24..32].copy_from_slice(&create_time_ns.to_be_bytes());
239    EpochTag::from_bytes(bytes)
240}
241
242/// One node found by [`walk`]: its path components (filesystem names are bytes,
243/// not guaranteed UTF-8), its filesystem id, and its metadata.
244pub struct WalkEntry {
245    /// Path components from the root, each a raw filesystem name.
246    pub path: Vec<Vec<u8>>,
247    /// The node's filesystem-specific id.
248    pub id: FileId,
249    /// The node's forensic metadata.
250    pub meta: FsMeta,
251}
252
253/// Recursively enumerate every node of a mounted filesystem from the root — the
254/// traversal a triage consumer runs over a resolved filesystem. Depth-capped and
255/// visited-guarded against directory loops; `.`/`..` self/parent entries are
256/// skipped. Returns the nodes; a per-node read error aborts loud.
257///
258/// # Errors
259/// Propagates the first `read_dir`/`meta`/entry-stream error encountered.
260pub fn walk(fs: &dyn FileSystem) -> VfsResult<Vec<WalkEntry>> {
261    let mut out = Vec::new();
262    let mut visited: HashSet<FileId> = HashSet::new();
263    let mut stack: Vec<(Vec<Vec<u8>>, FileId, usize)> = vec![(Vec::new(), fs.root(), 0)];
264    while let Some((prefix, dir_id, depth)) = stack.pop() {
265        if depth > WALK_MAX_DEPTH || !visited.insert(dir_id) {
266            continue;
267        }
268        for entry in fs.read_dir(dir_id)? {
269            let entry = entry?;
270            if matches!(entry.name.as_slice(), b"." | b"..") {
271                continue;
272            }
273            let mut path = prefix.clone();
274            path.push(entry.name);
275            let meta = fs.meta(entry.id)?;
276            let is_dir = matches!(meta.kind, NodeKind::Dir);
277            out.push(WalkEntry {
278                path: path.clone(),
279                id: entry.id,
280                meta,
281            });
282            if is_dir {
283                stack.push((path, entry.id, depth + 1));
284            }
285        }
286    }
287    Ok(out)
288}