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}