Skip to main content

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}