vyre_libs/scan/engine.rs
1//! Common abstractions over the matching engines in `vyre-libs`.
2//!
3//! Every concrete engine in this crate (`GpuLiteralSet`, `DirectGpuScanner`,
4//! `RulePipeline`, future ones for parsers / taint flow / anomaly scoring)
5//! ships the same shape of public API:
6//!
7//! 1. A `compile(...)` constructor that takes some pattern set.
8//! 2. A `scan(&backend, &haystack, max_matches)` GPU dispatch.
9//! 3. A `reference_scan(&haystack)` parity reference.
10//! 4. A `to_bytes()` / `from_bytes(...)` cache pair.
11//!
12//! Until now each engine duplicated the trait shape ad-hoc. This module
13//! is the lego-block fix: one set of traits, one generic
14//! `cached_load_or_compile` helper, every engine plugs in.
15//!
16//! # Why two traits, not one
17//!
18//! - [`MatchScan`] is dyn-safe (no associated types, no `Sized`). Consumers
19//! can store `Box<dyn MatchScan>` to swap engines at runtime - scanner
20//! backend selection becomes a runtime
21//! trait-object swap instead of a hardcoded match arm.
22//! - [`MatchEngineCache`] keeps typed errors (each engine's own
23//! `WireError` enum with its specific variants), so the cache layer's
24//! error messages stay actionable. Object-safety isn't needed here:
25//! cache wiring always knows the concrete type at compile time.
26//!
27//! Engines implement BOTH; consumers pick whichever fits their call site.
28//!
29//! # Cache wiring rule (Torvalds-style: do it once)
30//!
31//! [`cached_load_or_compile`] is the only blessed way to wire a cache.
32//! Consumers should never re-implement the load/compile
33//! /save dance. If a new engine needs special cache invalidation logic
34//! (e.g. dropping the cache on certain ABI bumps), extend this helper -
35//! don't fork it.
36
37use std::path::{Path, PathBuf};
38use std::sync::atomic::{AtomicU64, Ordering};
39use std::time::Duration;
40
41use vyre::VyreBackend;
42use vyre_foundation::match_result::Match;
43use vyre_primitives::hash::fnv1a::{fnv1a64_initial_state, fnv1a64_update_byte};
44
45static CACHE_TMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
46const MAX_MATCH_ENGINE_CACHE_BYTES: u64 = 64 * 1024 * 1024;
47
48/// Diagnostic-bearing wrapper around a scan result.
49///
50/// Every consumer pipeline ends up reconstructing these flags ad-hoc
51/// (was the scan truncated? how long did it take? did we hit the
52/// disk cache?). Centralising them gives downstream tooling
53/// (telemetry pipelines, watch-mode dashboards, perf benches) a
54/// single struct to read instead of parsing engine-specific output.
55///
56/// `ScanResult::matches` is the primary payload - consumers that
57/// don't care about diagnostics can `result.matches` and ignore the
58/// rest. The struct is `Clone` so it can be passed across thread
59/// boundaries and `Default` so tests can fabricate empties.
60#[derive(Debug, Clone, Default)]
61pub struct ScanResult {
62 /// Sorted matches produced by the engine.
63 pub matches: Vec<Match>,
64 /// True when the engine hit the per-dispatch `max_matches` cap
65 /// AND the underlying scan reported overflow. Consumers should
66 /// treat truncated results as incomplete and re-scan with a
67 /// larger cap if every match matters (security audits).
68 pub truncated: bool,
69 /// Total wall-clock time the scan call spent, including dispatch
70 /// + readback. `Duration::ZERO` when the engine doesn't measure.
71 pub elapsed: Duration,
72 /// True when the engine was loaded from disk cache instead of
73 /// being recompiled. Used by perf tooling to attribute cold-
74 /// start cost.
75 pub cache_hit: bool,
76}
77
78impl ScanResult {
79 /// Build a result from a bare match vector. Diagnostic flags
80 /// default to safe values (not truncated, zero elapsed, no
81 /// cache hit). For engines that produce richer diagnostics,
82 /// construct the struct directly.
83 #[must_use]
84 pub fn from_matches(matches: Vec<Match>) -> Self {
85 Self {
86 matches,
87 ..Self::default()
88 }
89 }
90
91 /// Number of matches produced.
92 #[must_use]
93 pub fn len(&self) -> usize {
94 self.matches.len()
95 }
96
97 /// True when the engine produced no matches.
98 #[must_use]
99 pub fn is_empty(&self) -> bool {
100 self.matches.is_empty()
101 }
102}
103
104/// GPU + Reference scan operations exposed by every matcher in this crate.
105/// Object-safe (`dyn MatchScan` is valid) so consumers can hold a heap-
106/// allocated trait object and swap engines at runtime.
107pub trait MatchScan {
108 /// GPU dispatch through a concrete backend, returning up to
109 /// `max_matches` matches. Engines pre-allocate the hit buffer at
110 /// `max_matches * 3 + 1` u32 slots; setting this too low silently
111 /// truncates results.
112 fn scan(
113 &self,
114 backend: &dyn VyreBackend,
115 haystack: &[u8],
116 max_matches: u32,
117 ) -> Result<Vec<Match>, vyre::BackendError>;
118
119 /// Reference oracle scan. Used by the cross-layer parity tests in
120 /// `vyre-conform`; engines that lack a meaningful CPU stepper
121 /// (none today) can return an empty vec but should never fabricate
122 /// results.
123 ///
124 /// Engines whose CPU stepper can fail (e.g. a haystack exceeding the
125 /// `u32` match ABI) must abort loudly here rather than returning an empty
126 /// vec, an empty result is a silent recall lie (Law 10). Such engines
127 /// override [`Self::try_reference_scan`] so `dyn MatchScan` consumers can
128 /// recover the error instead of unwinding.
129 fn reference_scan(&self, haystack: &[u8]) -> Vec<Match>;
130
131 /// Fallible reference oracle scan. Surfaces a CPU-stepper failure (a
132 /// haystack longer than the `u32` match ABI the GPU path uses) as an error
133 /// instead of aborting, so consumers holding `dyn MatchScan` can recover.
134 /// The default delegates to the infallible [`Self::reference_scan`] for
135 /// engines whose stepper genuinely cannot fail; engines with a fallible
136 /// stepper override this to forward to their real fallible scan (and never
137 /// route through the panicking infallible wrapper).
138 ///
139 /// # Errors
140 /// Engine-specific [`vyre::BackendError`] when the CPU oracle cannot honor
141 /// the same `u32` match ABI the GPU path uses for this haystack.
142 fn try_reference_scan(&self, haystack: &[u8]) -> Result<Vec<Match>, vyre::BackendError> {
143 Ok(self.reference_scan(haystack))
144 }
145
146 /// Stable identity for cache filenames + telemetry. Engines hash
147 /// their pattern set + version constant. Consumers pass this
148 /// straight to [`cached_load_or_compile`] without further hashing.
149 fn cache_key(&self) -> String;
150}
151
152/// Wire serialization for caching a compiled engine. Kept separate
153/// from [`MatchScan`] because typed errors aren't dyn-safe.
154pub trait MatchEngineCache: Sized {
155 /// The engine's wire-error enum. Forwarded to the cache helper so
156 /// load failures discriminate "stale cache, recompile" from "real
157 /// bug, refuse to start".
158 type WireError: std::fmt::Display + std::fmt::Debug;
159
160 /// Wire-format magic the engine stamps on every encoded blob. The
161 /// contracts test asserts that `to_bytes()[0..4] == WIRE_MAGIC`
162 /// so consumers cannot accidentally forge a cache file with a
163 /// different magic and have it silently load.
164 const WIRE_MAGIC: [u8; 4];
165
166 /// Wire-format version stamped after the magic. Bumped on any
167 /// breaking layout change. The cache helper uses this to discard
168 /// blobs from older builds; a `VersionMismatch` decode error is
169 /// the canonical "stale cache, recompile" signal.
170 const WIRE_VERSION: u32;
171
172 /// Largest on-disk cache blob this engine will read back. A blob exceeding
173 /// it is treated as oversized (dropped, then recompiled). Defaults to
174 /// [`MAX_MATCH_ENGINE_CACHE_BYTES`]; engines whose compiled form is
175 /// genuinely large (e.g. a batched-megakernel DFA catalog, ~GB) override it.
176 const MAX_CACHE_BYTES: u64 = MAX_MATCH_ENGINE_CACHE_BYTES;
177
178 /// Encode the compiled engine for on-disk caching.
179 ///
180 /// # Errors
181 /// Engine-specific framing error.
182 fn to_bytes(&self) -> Result<Vec<u8>, Self::WireError>;
183
184 /// Decode a previously-cached engine.
185 ///
186 /// # Errors
187 /// Engine-specific framing error. The cache helper treats every
188 /// `WireError` as "stale, drop and recompile" - that's the
189 /// designed-in semantics.
190 fn from_bytes(bytes: &[u8]) -> Result<Self, Self::WireError>;
191}
192
193/// Resolve the cache file path for `cache_key` under `cache_dir`.
194/// Creates `cache_dir` (and any missing parents) on first use. Returns
195/// `None` when the directory could not be created - consumers should
196/// fall through to a non-cached compile in that case.
197pub fn cache_path(cache_dir: &Path, cache_key: &str) -> Option<PathBuf> {
198 if !cache_dir.exists() && std::fs::create_dir_all(cache_dir).is_err() {
199 return None;
200 }
201 Some(cache_dir.join(format!("{cache_key}.bin")))
202}
203
204fn cache_tmp_path(path: &Path) -> PathBuf {
205 let sequence = CACHE_TMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
206 path.with_extension(format!("tmp.{}.{}", std::process::id(), sequence))
207}
208
209fn read_match_engine_cache_bounded(
210 path: &Path,
211 max_bytes: u64,
212) -> std::io::Result<Option<Vec<u8>>> {
213 let mut reader = std::fs::File::open(path)?;
214 let mut bytes = Vec::new();
215 let mut total = 0u64;
216 let mut chunk = [0u8; 8192];
217 loop {
218 let read = std::io::Read::read(&mut reader, &mut chunk)?;
219 if read == 0 {
220 return Ok(Some(bytes));
221 }
222 let read = read as u64;
223 total = total.saturating_add(read);
224 if total > max_bytes {
225 return Ok(None);
226 }
227 bytes.extend_from_slice(&chunk[..read as usize]);
228 }
229}
230
231fn remove_match_engine_cache(path: &Path, message: &'static str) {
232 if let Err(error) = std::fs::remove_file(path) {
233 tracing::debug!(
234 path = %path.display(),
235 error = %error,
236 "{}",
237 message
238 );
239 }
240}
241
242/// Generic load-or-compile-and-save for any [`MatchEngineCache`].
243///
244/// Replaces the per-engine cache wiring every downstream scanner
245/// would otherwise duplicate. The contract:
246///
247/// - Cache hit: read the file, attempt `from_bytes`. On success
248/// return the loaded engine. On framing error, delete the stale
249/// blob and fall through.
250/// - Cache miss / stale: call `compile`, `to_bytes`, atomically
251/// write to a `.tmp.<pid>.<sequence>` sibling, rename onto the final path.
252/// - Any save-side error is logged at `tracing::debug` and ignored -
253/// a failed cache write must never break the scan path.
254///
255/// `compile` is `FnOnce` so consumers can move expensive captures
256/// (pattern sources, file readers) into it without cloning.
257pub fn cached_load_or_compile<E, F>(cache_dir: &Path, cache_key: &str, compile: F) -> E
258where
259 E: MatchEngineCache,
260 F: FnOnce() -> E,
261{
262 let Some(path) = cache_path(cache_dir, cache_key) else {
263 return compile();
264 };
265
266 match read_match_engine_cache_bounded(&path, E::MAX_CACHE_BYTES) {
267 Ok(Some(bytes)) => match E::from_bytes(&bytes) {
268 Ok(engine) => return engine,
269 Err(_) => remove_match_engine_cache(&path, "failed to remove corrupt matching cache"),
270 },
271 Ok(None) => {
272 remove_match_engine_cache(&path, "failed to remove oversized matching cache");
273 }
274 Err(error) => {
275 tracing::debug!(
276 path = %path.display(),
277 error = %error,
278 "failed to read matching cache"
279 );
280 }
281 }
282
283 let engine = compile();
284 if let Ok(bytes) = engine.to_bytes() {
285 let tmp = cache_tmp_path(&path);
286 match std::fs::write(&tmp, &bytes) {
287 Ok(()) => {
288 if let Err(error) = std::fs::rename(&tmp, &path) {
289 tracing::debug!(
290 path = %path.display(),
291 tmp = %tmp.display(),
292 error = %error,
293 "failed to publish matching cache"
294 );
295 if let Err(cleanup_error) = std::fs::remove_file(&tmp) {
296 tracing::debug!(
297 tmp = %tmp.display(),
298 error = %cleanup_error,
299 "failed to remove matching cache temp file"
300 );
301 }
302 }
303 }
304 Err(error) => {
305 tracing::debug!(
306 tmp = %tmp.display(),
307 error = %error,
308 "failed to write matching cache temp file"
309 );
310 }
311 }
312 }
313 engine
314}
315
316// ---- Concrete impls for the engines this crate ships ----
317
318use crate::scan::literal_set::{GpuLiteralSet, LiteralSetWireError};
319
320impl MatchScan for GpuLiteralSet {
321 fn scan(
322 &self,
323 backend: &dyn VyreBackend,
324 haystack: &[u8],
325 max_matches: u32,
326 ) -> Result<Vec<Match>, vyre::BackendError> {
327 GpuLiteralSet::scan(self, backend, haystack, max_matches)
328 }
329
330 fn reference_scan(&self, haystack: &[u8]) -> Vec<Match> {
331 GpuLiteralSet::reference_scan(self, haystack)
332 }
333
334 fn cache_key(&self) -> String {
335 // Rendered from `pattern_fingerprint` (literal_set.rs), the single
336 // owner of a literal set's identity hash. This used to repeat the
337 // hash expression and rely on a test to keep the two in step, which
338 // is one definition too many for a value that names cache files.
339 //
340 // That owner uses vyre's FNV-1a primitive, not std::DefaultHasher:
341 // DefaultHasher's SipHash seed is randomized per process, so cache
342 // files written by one run would never match keys generated by the
343 // next, silently breaking the cache. FNV-1a is deterministic, fast,
344 // and an identity hash needs no collision resistance.
345 format!("lit-{:016x}", self.pattern_fingerprint())
346 }
347}
348
349impl MatchEngineCache for GpuLiteralSet {
350 type WireError = LiteralSetWireError;
351 const WIRE_MAGIC: [u8; 4] = *b"VLIT";
352 // One owner: the wire version lives in literal_set.rs; referencing it here
353 // means a future bump cannot desync the cache-version signal.
354 const WIRE_VERSION: u32 = super::literal_set::LITERAL_SET_WIRE_VERSION;
355
356 fn to_bytes(&self) -> Result<Vec<u8>, Self::WireError> {
357 GpuLiteralSet::to_bytes(self)
358 }
359
360 fn from_bytes(bytes: &[u8]) -> Result<Self, Self::WireError> {
361 GpuLiteralSet::from_bytes(bytes)
362 }
363}
364
365#[cfg(feature = "matching-dfa")]
366mod direct_gpu_impls {
367 use super::*;
368 use crate::scan::direct_gpu::DirectGpuScanner;
369
370 impl MatchScan for DirectGpuScanner {
371 fn scan(
372 &self,
373 backend: &dyn VyreBackend,
374 haystack: &[u8],
375 max_matches: u32,
376 ) -> Result<Vec<Match>, vyre::BackendError> {
377 DirectGpuScanner::scan(self, backend, haystack, max_matches)
378 }
379
380 fn reference_scan(&self, haystack: &[u8]) -> Vec<Match> {
381 DirectGpuScanner::reference_scan(self, haystack)
382 }
383
384 fn cache_key(&self) -> String {
385 // Direct scanner is a thin wrapper over a literal-set -
386 // delegate so caches don't fork.
387 format!("direct-gpu-{}", self.literal_set_cache_key())
388 }
389 }
390}
391
392#[cfg(feature = "matching-nfa")]
393mod rule_pipeline_impls {
394 use super::*;
395 use crate::scan::mega_scan::{PipelineWireError, RulePipeline};
396
397 impl MatchScan for RulePipeline {
398 fn scan(
399 &self,
400 backend: &dyn VyreBackend,
401 haystack: &[u8],
402 max_matches: u32,
403 ) -> Result<Vec<Match>, vyre::BackendError> {
404 RulePipeline::scan(self, backend, haystack, max_matches)
405 }
406
407 fn reference_scan(&self, haystack: &[u8]) -> Vec<Match> {
408 RulePipeline::reference_scan(self, haystack)
409 }
410
411 fn try_reference_scan(&self, haystack: &[u8]) -> Result<Vec<Match>, vyre::BackendError> {
412 // Forward to the inherent fallible scan, NOT the default, the
413 // default would call the panicking infallible `reference_scan`,
414 // turning a recoverable >u32 haystack into an abort.
415 RulePipeline::try_reference_scan(self, haystack)
416 }
417
418 fn cache_key(&self) -> String {
419 // Deterministic hash via vyre's FNV-1a primitive - see the
420 // `GpuLiteralSet::cache_key` implementation for why
421 // `DefaultHasher` is the wrong choice here (per-process
422 // SipHash seed defeats persistent caching).
423 let header = [self.plan.num_states, self.plan.input_len];
424 let h = fnv1a64_word_slices([
425 header.as_slice(),
426 self.transition_table.as_slice(),
427 self.epsilon_table.as_slice(),
428 ]);
429 format!("pipe-{h:016x}")
430 }
431 }
432
433 impl MatchEngineCache for RulePipeline {
434 type WireError = PipelineWireError;
435 const WIRE_MAGIC: [u8; 4] = *b"VRPL";
436 // Tracks `mega_scan::PIPELINE_WIRE_VERSION` (V4 adds the
437 // per-workgroup max-scan-bytes uniform buffer to the encoded
438 // Program; V3 added the runtime-haystack-len buffer).
439 const WIRE_VERSION: u32 = 4;
440
441 fn to_bytes(&self) -> Result<Vec<u8>, Self::WireError> {
442 RulePipeline::to_bytes(self)
443 }
444
445 fn from_bytes(bytes: &[u8]) -> Result<Self, Self::WireError> {
446 RulePipeline::from_bytes(bytes)
447 }
448 }
449}
450
451/// Deterministic FNV-1a over the little-endian bytes of one or more `u32`
452/// slices. The single owner for every identity/cache hash over word arrays in
453/// the scan crate (cache keys and literal-set fingerprints) so they cannot drift.
454pub(crate) fn fnv1a64_word_slices<const N: usize>(slices: [&[u32]; N]) -> u64 {
455 let mut h = fnv1a64_initial_state();
456 for words in slices {
457 for &word in words {
458 for byte in word.to_le_bytes() {
459 h = fnv1a64_update_byte(h, byte);
460 }
461 }
462 }
463 h
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469 use crate::scan::literal_set::GpuLiteralSet;
470
471 #[test]
472 fn cache_key_changes_when_patterns_change() {
473 let a = GpuLiteralSet::compile(&[b"AKIA".as_slice(), b"ghp_".as_slice()]);
474 let b = GpuLiteralSet::compile(&[b"AKIA".as_slice(), b"ghp__".as_slice()]);
475 assert_ne!(MatchScan::cache_key(&a), MatchScan::cache_key(&b));
476 }
477
478 #[test]
479 fn cache_key_stable_for_same_patterns() {
480 let a = GpuLiteralSet::compile(&[b"AKIA".as_slice(), b"ghp_".as_slice()]);
481 let b = GpuLiteralSet::compile(&[b"AKIA".as_slice(), b"ghp_".as_slice()]);
482 assert_eq!(MatchScan::cache_key(&a), MatchScan::cache_key(&b));
483 }
484
485 #[test]
486 fn streaming_word_hash_matches_allocated_little_endian_bytes() {
487 let words_a = [0x0102_0304_u32, 0xAABB_CCDD];
488 let words_b = [0x1122_3344_u32];
489 let mut bytes = Vec::new();
490 for &word in words_a.iter().chain(words_b.iter()) {
491 bytes.extend_from_slice(&word.to_le_bytes());
492 }
493
494 assert_eq!(
495 fnv1a64_word_slices([words_a.as_slice(), words_b.as_slice()]),
496 vyre_primitives::hash::fnv1a::fnv1a64(&bytes)
497 );
498 }
499
500 #[test]
501 fn cached_helper_round_trips_via_disk() {
502 let dir = tempfile::tempdir().unwrap();
503 let key = "test-engine";
504 let mut compiles = 0;
505 let _engine: GpuLiteralSet = cached_load_or_compile(dir.path(), key, || {
506 compiles += 1;
507 GpuLiteralSet::compile(&[b"AKIA".as_slice()])
508 });
509 assert_eq!(compiles, 1);
510
511 // Second call hits the disk cache; the closure must NOT run.
512 let mut second_compiles = 0;
513 let _engine2: GpuLiteralSet = cached_load_or_compile(dir.path(), key, || {
514 second_compiles += 1;
515 GpuLiteralSet::compile(&[b"AKIA".as_slice()])
516 });
517 assert_eq!(second_compiles, 0);
518 }
519
520 #[test]
521 fn cache_tmp_paths_do_not_collide_within_process() {
522 let dir = tempfile::tempdir().unwrap();
523 let path = cache_path(dir.path(), "same-key").unwrap();
524 let first = cache_tmp_path(&path);
525 let second = cache_tmp_path(&path);
526
527 assert_ne!(
528 first, second,
529 "Fix: concurrent cache writers in one process need distinct temp files."
530 );
531 assert_eq!(first.parent(), path.parent());
532 assert_eq!(second.parent(), path.parent());
533 }
534
535 #[test]
536 fn cached_helper_recompiles_on_corrupt_blob() {
537 let dir = tempfile::tempdir().unwrap();
538 let key = "test-corrupt";
539 // Plant a corrupt blob.
540 std::fs::write(dir.path().join(format!("{key}.bin")), b"not a real blob").unwrap();
541
542 let mut compiles = 0;
543 let _engine: GpuLiteralSet = cached_load_or_compile(dir.path(), key, || {
544 compiles += 1;
545 GpuLiteralSet::compile(&[b"AKIA".as_slice()])
546 });
547 assert_eq!(compiles, 1);
548 }
549}