Skip to main content

forensic_carve/
engine.rs

1//! The single-pass sweep engine (fleet ADR 0001 §2/§4/§8).
2//!
3//! One aho-corasick detection pass over the supplied regions; each magic hit is
4//! converted to an absolute source offset, the carver-declared window is
5//! materialized (detection ≠ materialization — a large artifact is not truncated to
6//! a scan chunk), the owning carver runs, and the medium-neutral [`CarvedItem`] is
7//! wrapped in a [`SweptItem`] carrying the region's attribution tag. The engine owns
8//! both the chunk-boundary overlap and the window materialization; medium defaults
9//! (chunk size, confidence policy) live with the caller in [`CarveOptions`].
10
11use aho_corasick::AhoCorasick;
12
13use crate::{CarveContext, CarvedItem, Carver, ConfidencePolicy, RecoveryMethod};
14
15/// A positioned-read edge over the source being swept. Disk drivers implement it
16/// over `forensic-vfs` positioned reads; memory drivers over `read_virt`. A short
17/// read (fewer bytes than requested) signals a gap or end-of-source — the engine
18/// treats the window as truncated, never fabricating bytes.
19pub trait RegionSource {
20    /// Read into `buf` starting at absolute `offset`; return the number of bytes
21    /// actually read (0 at/after the end, short at a gap).
22    fn read_at(&self, offset: u64, buf: &mut [u8]) -> usize;
23}
24
25/// A contiguous span of the source to sweep, plus an opaque medium-specific
26/// attribution `tag` (disk: volume/run id; memory: PID/VA) that rides back out on
27/// each [`SweptItem`] — keeping [`CarvedItem`] itself medium-neutral (ADR §8/C1).
28#[derive(Debug, Clone)]
29pub struct Region<R> {
30    /// Absolute start offset of the span in the source.
31    pub start: u64,
32    /// Length of the span in bytes.
33    pub len: u64,
34    /// Medium-specific attribution carried back on each item.
35    pub tag: R,
36}
37
38/// A carved item plus where it came from: the source-relative offset and the
39/// region's attribution tag. Keeps `CarvedItem` medium-neutral (ADR §8/C1).
40#[derive(Debug, Clone)]
41pub struct SweptItem<R> {
42    /// The region attribution tag the item was found under.
43    pub region: R,
44    /// The item's absolute offset in the source.
45    pub offset: u64,
46    /// The medium-neutral carved item.
47    pub item: CarvedItem,
48}
49
50/// Engine knobs. Public fields, medium defaults set by the caller's driver.
51#[derive(Debug, Clone)]
52pub struct CarveOptions {
53    /// Bytes read per detection chunk (overlap is added automatically).
54    pub chunk_size: usize,
55    /// Hard cap on the window materialized per hit (an alloc-bomb backstop; the
56    /// carver's own `max_window` caps it further).
57    pub max_window: u64,
58    /// What to do with carved items by confidence.
59    pub confidence_policy: ConfidencePolicy,
60    /// The recovery method this sweep carves under — the driver's medium/tier
61    /// (disk unallocated → `UnallocatedCarve`, memory → `MemoryCarve`). Threaded into
62    /// each carver's `CarveContext`.
63    pub recovery_method: RecoveryMethod,
64}
65
66impl Default for CarveOptions {
67    fn default() -> Self {
68        Self {
69            chunk_size: 1 << 20,   // 1 MiB
70            max_window: 256 << 20, // 256 MiB
71            confidence_policy: ConfidencePolicy::KeepAll,
72            recovery_method: RecoveryMethod::UnallocatedCarve,
73        }
74    }
75}
76
77/// Run one detection pass over `regions`, dispatching capped windows to the matching
78/// carver. Returns the carved items, each wrapped with its source offset and region
79/// tag. Panic-free: a build failure or empty pattern set yields no items rather than
80/// erroring.
81pub fn sweep<S, R>(
82    source: &S,
83    regions: impl IntoIterator<Item = Region<R>>,
84    carvers: &[&dyn Carver],
85    opts: &CarveOptions,
86) -> Vec<SweptItem<R>>
87where
88    S: RegionSource,
89    R: Clone,
90{
91    // Build the pattern set: each signature maps back to (carver index, signature).
92    let mut patterns: Vec<&[u8]> = Vec::new();
93    let mut meta: Vec<(usize, crate::Signature)> = Vec::new();
94    for (ci, c) in carvers.iter().enumerate() {
95        for sig in c.signatures() {
96            patterns.push(sig.magic());
97            meta.push((ci, *sig));
98        }
99    }
100    if patterns.is_empty() {
101        return Vec::new();
102    }
103    let Ok(ac) = AhoCorasick::new(&patterns) else {
104        return Vec::new(); // cov:unreachable: patterns is non-empty (guarded above) and within aho-corasick's build limits
105    };
106
107    // Overlap carried across chunk boundaries so a magic spanning the boundary is
108    // still found in exactly one chunk's scan buffer.
109    let longest = patterns.iter().map(|p| p.len()).max().unwrap_or(0);
110    let overlap = longest.saturating_sub(1);
111    // A chunk must be at least the longest magic, or a magic can't fit in the scan
112    // buffer (carry + chunk) and would be missed. The default 1 MiB dwarfs any magic;
113    // this only clamps up a pathologically small configured chunk_size.
114    let chunk_size = opts.chunk_size.max(longest).max(1);
115
116    let mut out: Vec<SweptItem<R>> = Vec::new();
117
118    for region in regions {
119        let region_end = region.start.saturating_add(region.len);
120        let mut carry: Vec<u8> = Vec::new();
121        let mut pos = region.start;
122
123        while pos < region_end {
124            let want = chunk_size.min(usize_saturating(region_end - pos));
125            if want == 0 {
126                break; // cov:unreachable: loop guard `pos < region_end` ⇒ region_end - pos >= 1, and chunk_size >= 1
127            }
128            let mut chunk = vec![0u8; want];
129            let n = source.read_at(pos, &mut chunk);
130            if n == 0 {
131                break;
132            }
133            chunk.truncate(n);
134
135            // Scan buffer = carry (overlap tail of the previous chunk) + fresh chunk.
136            let chunk_new_start = pos;
137            let buffer_base = chunk_new_start - carry.len() as u64;
138            let mut buffer = Vec::with_capacity(carry.len() + chunk.len());
139            buffer.extend_from_slice(&carry);
140            buffer.extend_from_slice(&chunk);
141
142            for m in ac.find_overlapping_iter(&buffer) {
143                let abs_start = buffer_base + m.start() as u64;
144                let abs_end = buffer_base + m.end() as u64;
145                // Dedup: a match fully inside the carry was already reported by the
146                // previous chunk. A straddling match (ends at/after the fresh bytes)
147                // is reported here, once.
148                if abs_end <= chunk_new_start {
149                    continue;
150                }
151                let (cidx, sig) = &meta[m.pattern().as_usize()];
152                // Anchor the artifact start (magic may sit mid-artifact).
153                let Some(artifact_start) = abs_start.checked_sub(sig.offset() as u64) else {
154                    continue;
155                };
156                let Some(carver) = carvers.get(*cidx) else {
157                    continue; // cov:unreachable: meta indices come from `carvers`
158                };
159
160                // Materialize ONLY the carver-declared window (detection != materialization).
161                let window_len = usize_saturating(carver.max_window().min(opts.max_window));
162                if window_len == 0 {
163                    continue;
164                }
165                let mut window = vec![0u8; window_len];
166                let got = source.read_at(artifact_start, &mut window);
167                window.truncate(got);
168
169                let ctx = CarveContext::at(artifact_start)
170                    .with_method(opts.recovery_method)
171                    .with_policy(opts.confidence_policy);
172                for item in carver.carve(&window, &ctx) {
173                    if keeps(opts.confidence_policy, item.confidence()) {
174                        out.push(SweptItem {
175                            region: region.tag.clone(),
176                            offset: item.image_offset(),
177                            item,
178                        });
179                    }
180                }
181            }
182
183            // Carry the overlap tail of the fresh chunk into the next iteration.
184            let ov = overlap.min(chunk.len());
185            carry = chunk[chunk.len() - ov..].to_vec();
186            pos += n as u64;
187        }
188    }
189
190    out
191}
192
193fn keeps(policy: ConfidencePolicy, confidence: f32) -> bool {
194    match policy {
195        ConfidencePolicy::KeepAll => true,
196        ConfidencePolicy::Minimum(floor) => confidence >= floor,
197    }
198}
199
200/// Saturating `u64 -> usize` (on 32-bit hosts a huge span clamps to `usize::MAX`).
201fn usize_saturating(v: u64) -> usize {
202    usize::try_from(v).unwrap_or(usize::MAX)
203}