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}