forensic-vfs-resolver 0.1.3

The generic layer resolver over the forensic-vfs contracts: sniff a byte source, match a registered prober, and descend container/volume/filesystem layers until a filesystem mounts — the evolving detection behavior, firewalled from the frozen forensic-vfs contract leaf
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
//! # forensic-vfs-resolver
//!
//! The generic layer resolver: sniff a byte source, match a registered prober,
//! and descend container/volume/filesystem layers until a filesystem mounts.
//!
//! This is the reader-independent core of detection — it touches only the
//! [`forensic_vfs::Openers`] prober traits and the layered
//! [`PathSpec`]/[`Layer`] model, never a concrete reader. It is deliberately
//! split out of the [`forensic-vfs`](https://docs.rs/forensic-vfs) contract leaf
//! so the *evolving detection behavior* (this crate) is firewalled from the
//! *frozen contract* the fleet's reader crates pin.
//!
//! The orchestration layer (`forensic-vfs-engine`) wires concrete probers into a
//! [`Openers`], resolves a base [`DynSource`] from a path (EWF-by-path vs a raw
//! file), and offers the by-path `open`/snapshot API; everything below that — the
//! recursion, the sniff windows, [`walk`], and the snapshot *view* — lives here so
//! any tool or test can drive it openers-first.
//!
//! Because `open` cannot be an inherent method on the leaf's [`Openers`] from
//! another crate (the orphan rule), it is exposed as the [`SourceOpen`] extension
//! trait; bring it into scope and call `openers.open(source, spec, 0)`.

// Tests may unwrap/expect freely; production code may not.
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]

use std::collections::HashSet;

use state_history_forensic::epoch::EpochTag;

use forensic_vfs::{
    ArchiveContents, Confidence, CredentialSource, DynSource, FileId, FileSystem, FsMeta, Layer,
    NoCredentials, NodeAddr, NodeKind, Openers, PathSpec, SnapshotRef, SniffWindow, VfsResult,
};

/// Depth cap on the recursive resolve (container/volume nesting) — a bomb guard.
const MAX_DEPTH: usize = 8;

/// Bytes read into the sniff window. Sized so a prober can see multi-offset
/// magics — notably the ISO 9660 Primary Volume Descriptor (`CD001` at byte
/// offset 32769, LBA 16). NTFS/ext4/MBR/GPT/container magics all sit in the
/// first few KiB, so the larger window is a strict superset. One bounded read.
const SNIFF_CAP: u64 = 128 * 1024;

/// Bytes read into the *tail* sniff window from the end of the source. Sized to
/// cover trailer signatures like the DMG `koly` footer (at `total_len - 512`).
const TAIL_CAP: u64 = 4096;

/// Cap on directory recursion depth in [`walk`] — a filesystem-loop guard.
const WALK_MAX_DEPTH: usize = 256;

/// One resolved piece of evidence: its locator plus the mounted filesystem, when
/// the resolver detected one (`None` for a source no registered prober recognized).
pub struct Evidence {
    /// The locator this evidence was opened from.
    pub root: PathSpec,
    /// The mounted read-only filesystem, if detected.
    pub fs: Option<forensic_vfs::DynFs>,
}

/// One resolved layer stack: the mounted filesystem, its locator, and the byte
/// source it was mounted from (plus that source's pre-filesystem locator, the
/// base a snapshot layer is pushed onto).
pub struct Resolved {
    /// The mounted read-only filesystem.
    pub fs: forensic_vfs::DynFs,
    /// The full locator, topped by the `fs:` layer.
    pub spec: PathSpec,
    /// The byte source the filesystem was mounted from.
    pub source: DynSource,
    /// That source's pre-filesystem locator (the base a snapshot layer sits on).
    pub source_spec: PathSpec,
}

/// The generic layer resolver, exposed as an extension trait on the leaf's
/// [`Openers`]. Bring it into scope (`use forensic_vfs_resolver::SourceOpen;`) to
/// call `openers.open(source, spec, 0)`.
pub trait SourceOpen {
    /// Recursively resolve a source to a filesystem, supplying no credentials.
    /// A convenience wrapper over [`SourceOpen::open_with_credentials`] with the
    /// leaf's [`NoCredentials`] context: a signature-detected encryption layer then
    /// surfaces `NeedCredentials` loudly and a credential-attempt scheme falls
    /// through, so an encrypted volume is never silently skipped nor guessed.
    ///
    /// # Errors
    /// Propagates a source read error, or a prober `open`/decode failure raised
    /// after a positive probe verdict.
    fn open(&self, source: DynSource, spec: PathSpec, depth: usize) -> VfsResult<Option<Resolved>> {
        self.open_with_credentials(source, spec, depth, &NoCredentials)
    }

    /// Recursively resolve a source to a filesystem: sniff its head (and a tail
    /// window for trailer magics); if a filesystem prober recognizes it, mount it;
    /// otherwise if a volume-system prober recognizes it, descend into each volume
    /// and resolve that; otherwise attempt a signature-detected encryption layer,
    /// then containers, then archives; finally, as a last resort, attempt each
    /// signature-less credential-attempt encryption scheme so a wrong VeraCrypt
    /// guess can never shadow a real filesystem (ADR 0010). `Ok(None)` when nothing
    /// recognizes it — a genuinely clean unknown, not an error.
    ///
    /// Encryption failure semantics follow the probe verdict (ADR 0010): a `Yes`
    /// (BitLocker/LUKS/FileVault) whose decrypt fails propagates loud — the source
    /// *is* identified encryption and a wrong/absent key is a nameable condition —
    /// while a `Maybe` (VeraCrypt) whose decrypt fails falls through, because a
    /// failed decrypt of a signature-less scheme is indistinguishable from random
    /// data and must not break the empty-source contract.
    ///
    /// `creds` supplies keys/passphrases to any encryption layer reached.
    /// `depth` is the current nesting level; callers start at `0`. The recursion is
    /// depth-capped against a self-referential container/volume bomb.
    ///
    /// # Errors
    /// Propagates a source read error, or a prober `open`/decode failure raised
    /// after a positive (`Yes`) probe verdict.
    fn open_with_credentials(
        &self,
        source: DynSource,
        spec: PathSpec,
        depth: usize,
        creds: &dyn CredentialSource,
    ) -> VfsResult<Option<Resolved>>;
}

impl SourceOpen for Openers {
    fn open_with_credentials(
        &self,
        source: DynSource,
        spec: PathSpec,
        depth: usize,
        creds: &dyn CredentialSource,
    ) -> VfsResult<Option<Resolved>> {
        if depth > MAX_DEPTH {
            return Ok(None);
        }
        let (head, n, tail, tn, total) = read_sniff_buffers(&source)?;
        let window = SniffWindow::with_tail(
            0,
            head.get(..n).unwrap_or(&[]),
            total,
            tail.get(..tn).unwrap_or(&[]),
        );

        for probe in self.filesystems() {
            if probe.probe(&window).is_candidate() {
                let fs = probe.open(source.clone())?;
                let fs_spec = spec.clone().push(Layer::Fs {
                    kind: probe.kind(),
                    at: NodeAddr::Path(Vec::new()),
                });
                return Ok(Some(Resolved {
                    fs,
                    spec: fs_spec,
                    source,
                    source_spec: spec,
                }));
            }
        }
        for vsp in self.volume_systems() {
            if vsp.probe(&window).is_candidate() {
                let vs = vsp.open(source.clone())?;
                for index in 0..vs.volumes().len() {
                    let sub = vs.open_volume(index)?;
                    let child = spec.clone().push(Layer::Volume {
                        scheme: vsp.scheme(),
                        index,
                        guid: None,
                    });
                    if let Some(found) = self.open_with_credentials(sub, child, depth + 1, creds)? {
                        return Ok(Some(found));
                    }
                }
            }
        }
        // Encryption descent (ADR 0010), eager signature pass. `Maybe` schemes are
        // recorded for the last-resort pass below, so they never shadow a real fs.
        let mut credential_attempt: Vec<usize> = Vec::new();
        if let Some(found) = descend_signature_encryption(
            self,
            &window,
            &source,
            &spec,
            depth,
            creds,
            &mut credential_attempt,
        )? {
            return Ok(Some(found));
        }
        for cd in self.containers() {
            if cd.probe(&window).is_candidate() {
                let decoded = cd.open(source.clone())?;
                let child = spec.clone().push(Layer::Container {
                    format: cd.format(),
                });
                if let Some(found) = self.open_with_credentials(decoded, child, depth + 1, creds)? {
                    return Ok(Some(found));
                }
            }
        }
        // Archive descent (ADR 0008): a bare gz/bz2 wrapper peels to a single
        // decoded stream (1→1) that re-enters resolution like a container decode;
        // a multi-member archive peels to a member table (1→N) whose members each
        // re-enter. The member index selected is carried in the locator via
        // `Layer::Archive { member }`, mirroring the volume-system multi-volume
        // descent (first sub-source that resolves to a filesystem wins).
        for ar in self.archives() {
            if ar.probe(&window).is_candidate() {
                match ar.open(source.clone())? {
                    ArchiveContents::Stream(inner) => {
                        let child = spec.clone().push(Layer::Archive { member: None });
                        if let Some(found) =
                            self.open_with_credentials(inner, child, depth + 1, creds)?
                        {
                            return Ok(Some(found));
                        }
                    }
                    ArchiveContents::Members(members) => {
                        for (index, member) in members.into_iter().enumerate() {
                            let child = spec.clone().push(Layer::Archive {
                                member: Some(index),
                            });
                            if let Some(found) =
                                self.open_with_credentials(member.source, child, depth + 1, creds)?
                            {
                                return Ok(Some(found));
                            }
                        }
                    }
                    // A future `#[non_exhaustive]` ArchiveContents variant this
                    // resolver predates: fall through like an unrecognized source.
                    _ => {} // cov:unreachable: no other ArchiveContents variant exists today
                }
            }
        }
        // Encryption descent (ADR 0010), credential-attempt last resort — only
        // reached when nothing above claimed the source.
        descend_credential_attempt_encryption(
            self,
            &source,
            &spec,
            depth,
            creds,
            &credential_attempt,
        )
    }
}

/// The owned head/tail sniff buffers of a source, plus their filled lengths and
/// the source's total length: `(head, head_len, tail, tail_len, total_len)`. A
/// [`SniffWindow`] borrows the two buffers.
type SniffBuffers = (Vec<u8>, usize, Vec<u8>, usize, u64);

/// Read the head (up to [`SNIFF_CAP`]) and tail (up to [`TAIL_CAP`]) sniff buffers
/// of `source` in two bounded reads. The caller builds a [`SniffWindow`] borrowing
/// the returned buffers. The tail lets a prober match a trailer signature the head
/// never reaches — e.g. the DMG `koly` footer.
fn read_sniff_buffers(source: &DynSource) -> VfsResult<SniffBuffers> {
    let total = source.len();
    let cap = total.clamp(1, SNIFF_CAP) as usize;
    let mut head = vec![0u8; cap];
    let n = source.read_at(0, &mut head)?;
    let tail_cap = total.min(TAIL_CAP);
    let tail_start = total - tail_cap;
    let mut tail = vec![0u8; tail_cap as usize];
    let tn = source.read_at(tail_start, &mut tail)?;
    Ok((head, n, tail, tn, total))
}

/// Eager signature-encryption pass (ADR 0010): descend any `Yes`-verdict scheme
/// (BitLocker/LUKS/FileVault) and recurse on its decrypted plaintext, recording
/// each `Maybe`-verdict (signature-less) scheme's index into `credential_attempt`
/// for the last-resort pass. A `Yes` decrypt failure propagates loud (the source
/// *is* identified encryption), never a silent fall-through.
///
/// A free function rather than a method: `Openers` lives in the leaf, so the
/// resolver cannot add inherent methods to it (the orphan rule — the same reason
/// [`SourceOpen`] is an extension trait).
fn descend_signature_encryption(
    openers: &Openers,
    window: &SniffWindow,
    source: &DynSource,
    spec: &PathSpec,
    depth: usize,
    creds: &dyn CredentialSource,
    credential_attempt: &mut Vec<usize>,
) -> VfsResult<Option<Resolved>> {
    for (i, enc) in openers.encryption_layers().iter().enumerate() {
        match enc.probe(window) {
            Confidence::Yes { .. } => {
                let layer = enc.open(source.clone())?;
                let decrypted = layer.open(creds)?;
                let child = spec.clone().push(Layer::Encryption {
                    scheme: enc.scheme(),
                });
                if let Some(found) =
                    openers.open_with_credentials(decrypted, child, depth + 1, creds)?
                {
                    return Ok(Some(found));
                }
            }
            Confidence::Maybe => credential_attempt.push(i),
            Confidence::No => {}
        }
    }
    Ok(None)
}

/// Credential-attempt last-resort pass (ADR 0010): for each recorded `Maybe`
/// scheme (VeraCrypt), construct the layer and attempt to decrypt with `creds`,
/// recursing on success. A `Maybe` was never a positive identification, so a failed
/// *decrypt* means "not this scheme / wrong creds" — indistinguishable from random
/// data — and falls through to the next candidate, never breaking the empty-source
/// contract by erroring on an unrecognized blob. Constructing the layer, by
/// contrast, is loud (`?`): a construction failure is an unexpected internal error,
/// not a "wrong password", so it is surfaced rather than swallowed.
fn descend_credential_attempt_encryption(
    openers: &Openers,
    source: &DynSource,
    spec: &PathSpec,
    depth: usize,
    creds: &dyn CredentialSource,
    credential_attempt: &[usize],
) -> VfsResult<Option<Resolved>> {
    for &i in credential_attempt {
        let Some(enc) = openers.encryption_layers().get(i) else {
            continue; // cov:unreachable: indices came from this same slice
        };
        let layer = enc.open(source.clone())?;
        let Ok(decrypted) = layer.open(creds) else {
            continue;
        };
        let child = spec.clone().push(Layer::Encryption {
            scheme: enc.scheme(),
        });
        if let Some(found) = openers.open_with_credentials(decrypted, child, depth + 1, creds)? {
            return Ok(Some(found));
        }
    }
    Ok(None)
}

/// One snapshot of a filesystem, viewed as a time-indexed state in the `[H]`
/// cohort: the wall-clock [`EpochTag`], the transaction id, the snapshot name,
/// and a re-openable [`PathSpec`] locator (base ⇒ `Snapshot{ApfsXid}`).
#[derive(Debug, Clone)]
pub struct SnapshotView {
    /// Time-indexed identity, derived from the snapshot's `create_time`.
    pub epoch: EpochTag,
    /// The snapshot transaction id.
    pub xid: u64,
    /// The snapshot name.
    pub name: String,
    /// A locator that the orchestration layer re-opens end-to-end.
    pub locator: PathSpec,
}

/// Build a [`SnapshotView`] under `source_spec` (the source's pre-filesystem
/// locator) from a snapshot's transaction id, name, and `create_time`. Takes
/// primitives rather than a concrete reader's `#[non_exhaustive]` snapshot type so
/// the mapping is unit-testable directly.
#[must_use]
pub fn snapshot_view(
    source_spec: &PathSpec,
    xid: u64,
    name: String,
    create_time: u64,
) -> SnapshotView {
    SnapshotView {
        epoch: epoch_from_create_time(create_time),
        xid,
        name,
        locator: source_spec.clone().push(Layer::Snapshot {
            store: SnapshotRef::ApfsXid(xid),
        }),
    }
}

/// Derive an [`EpochTag`] from a snapshot `create_time` (nanoseconds since
/// 1970-01-01 UTC). The big-endian nanosecond timestamp occupies the low 8 bytes
/// (indices 24..32) of the 32-byte tag; the rest is zero. This is simple and
/// reversible — the timestamp round-trips back out of those 8 bytes — and orders
/// correctly: a later `create_time` yields a lexicographically greater tag.
#[must_use]
pub fn epoch_from_create_time(create_time_ns: u64) -> EpochTag {
    let mut bytes = [0u8; 32];
    bytes[24..32].copy_from_slice(&create_time_ns.to_be_bytes());
    EpochTag::from_bytes(bytes)
}

/// One node found by [`walk`]: its path components (filesystem names are bytes,
/// not guaranteed UTF-8), its filesystem id, and its metadata.
pub struct WalkEntry {
    /// Path components from the root, each a raw filesystem name.
    pub path: Vec<Vec<u8>>,
    /// The node's filesystem-specific id.
    pub id: FileId,
    /// The node's forensic metadata.
    pub meta: FsMeta,
}

/// Recursively enumerate every node of a mounted filesystem from the root — the
/// traversal a triage consumer runs over a resolved filesystem. Depth-capped and
/// visited-guarded against directory loops; `.`/`..` self/parent entries are
/// skipped. Returns the nodes; a per-node read error aborts loud.
///
/// # Errors
/// Propagates the first `read_dir`/`meta`/entry-stream error encountered.
pub fn walk(fs: &dyn FileSystem) -> VfsResult<Vec<WalkEntry>> {
    let mut out = Vec::new();
    let mut visited: HashSet<FileId> = HashSet::new();
    let mut stack: Vec<(Vec<Vec<u8>>, FileId, usize)> = vec![(Vec::new(), fs.root(), 0)];
    while let Some((prefix, dir_id, depth)) = stack.pop() {
        if depth > WALK_MAX_DEPTH || !visited.insert(dir_id) {
            continue;
        }
        for entry in fs.read_dir(dir_id)? {
            let entry = entry?;
            if matches!(entry.name.as_slice(), b"." | b"..") {
                continue;
            }
            let mut path = prefix.clone();
            path.push(entry.name);
            let meta = fs.meta(entry.id)?;
            let is_dir = matches!(meta.kind, NodeKind::Dir);
            out.push(WalkEntry {
                path: path.clone(),
                id: entry.id,
                meta,
            });
            if is_dir {
                stack.push((path, entry.id, depth + 1));
            }
        }
    }
    Ok(out)
}