Skip to main content

vyre_libs/rule/
reference_eval.rs

1//! Reference evaluator for [`RuleCondition`] / [`RuleFormula`] trees.
2//!
3//! Mirror of the GPU lowering in [`crate::rule::builder`] but runs the
4//! formula through the deterministic reference oracle. It exists for
5//! parity checks, CI gates, and unit tests that need rule outcomes
6//! without backend dispatch.
7//!
8//! The evaluator is `unsafe`-free, side-effect-free, and `O(formula
9//! size)`.
10//!
11//! # Example
12//!
13//! ```
14//! use vyre_libs::rule::{evaluate_formula, RuleCondition, RuleEvaluationContext, RuleFormula};
15//!
16//! struct Ctx;
17//! impl RuleEvaluationContext for Ctx {
18//!     fn pattern_count(&self, pattern_id: u32) -> u32 {
19//!         if pattern_id == 7 { 5 } else { 0 }
20//!     }
21//!     fn file_size(&self) -> u64 { 1024 }
22//! }
23//!
24//! let f = RuleFormula::and(
25//!     RuleFormula::condition(RuleCondition::PatternCountGte { pattern_id: 7, threshold: 3 }),
26//!     RuleFormula::condition(RuleCondition::FileSizeLt(2048)),
27//! );
28//! assert!(evaluate_formula(&f, &Ctx));
29//! ```
30
31use std::sync::Arc;
32
33use super::ast::{RuleCondition, RuleFormula};
34
35/// Evaluation context the reference evaluator queries when resolving each
36/// condition variant. Default impls return safe falsy values so a
37/// minimal consumer only has to implement the methods it actually
38/// uses.
39pub trait RuleEvaluationContext {
40    /// Number of times pattern `pattern_id` matched in the current
41    /// record. Default: 0 (the pattern never matched). Override to
42    /// resolve `PatternExists` / `PatternCountGt` / `PatternCountGte`.
43    fn pattern_count(&self, _pattern_id: u32) -> u32 {
44        0
45    }
46
47    /// File size in bytes for the current record. Default: 0.
48    /// Override to resolve `FileSize*` conditions.
49    fn file_size(&self) -> u64 {
50        0
51    }
52
53    /// Resolve a named field value. Default: `None`. Override to
54    /// resolve `RegexMatch { field, .. }` / `SubstringMatch { haystack,
55    /// .. }` / `PrefixMatch { value, .. }` / `SuffixMatch { value, .. }`
56    /// / `SetMembership { value, .. }`.
57    ///
58    /// Returns the borrowed field text. Conditions that need the
59    /// caller's value directly carry it inline in the AST and don't
60    /// hit this method.
61    fn field_value(&self, _name: &str) -> Option<&str> {
62        None
63    }
64}
65
66/// Evaluate a [`RuleFormula`] against `ctx`. Recursive over
67/// And/Or/Not nodes; returns the boolean verdict.
68#[must_use]
69pub fn evaluate_formula<C: RuleEvaluationContext + ?Sized>(formula: &RuleFormula, ctx: &C) -> bool {
70    match formula {
71        RuleFormula::Condition(cond) => evaluate_condition(cond, ctx),
72        RuleFormula::And(left, right) => {
73            // Short-circuit: don't evaluate `right` if `left` is false.
74            evaluate_formula(left, ctx) && evaluate_formula(right, ctx)
75        }
76        RuleFormula::Or(left, right) => evaluate_formula(left, ctx) || evaluate_formula(right, ctx),
77        RuleFormula::Not(inner) => !evaluate_formula(inner, ctx),
78    }
79}
80
81/// Evaluate a single [`RuleCondition`] against `ctx`. Pure function;
82/// no I/O, no allocation outside the regex case (see below).
83///
84/// `RegexMatch` compiles its pattern on every call. Callers that
85/// evaluate the same regex thousands of times should hoist the
86/// compile out of the rule by pre-computing a [`RuleCondition::Set
87/// Membership`] or wrapping a custom [`RuleCondition::Opaque`]
88/// extension that caches its own compiled regex.
89///
90/// `Opaque` extension conditions delegate via
91/// [`RuleConditionExt::evaluate_opaque`](vyre_foundation::extension::RuleConditionExt::evaluate_opaque); the trait passes a
92/// `&dyn Any` reference so extensions can downcast to whatever
93/// context type they require. The [`RuleEvaluationContext`] is
94/// passed via the `Any` payload by reference so an extension that
95/// needs the standard context can downcast to `&C`.
96#[must_use]
97pub fn evaluate_condition<C: RuleEvaluationContext + ?Sized>(
98    condition: &RuleCondition,
99    ctx: &C,
100) -> bool {
101    match condition {
102        RuleCondition::PatternExists { pattern_id } => ctx.pattern_count(*pattern_id) > 0,
103        RuleCondition::PatternCountGt {
104            pattern_id,
105            threshold,
106        } => ctx.pattern_count(*pattern_id) > *threshold,
107        RuleCondition::PatternCountGte {
108            pattern_id,
109            threshold,
110        } => ctx.pattern_count(*pattern_id) >= *threshold,
111        RuleCondition::FileSizeLt(t) => ctx.file_size() < *t,
112        RuleCondition::FileSizeLte(t) => ctx.file_size() <= *t,
113        RuleCondition::FileSizeGt(t) => ctx.file_size() > *t,
114        RuleCondition::FileSizeGte(t) => ctx.file_size() >= *t,
115        RuleCondition::FileSizeEq(t) => ctx.file_size() == *t,
116        RuleCondition::FileSizeNe(t) => ctx.file_size() != *t,
117        RuleCondition::LiteralTrue => true,
118        RuleCondition::LiteralFalse => false,
119        RuleCondition::RegexMatch { field, pattern } => {
120            // AUDIT_2026-05-23: was compile-on-every-eval (regex::Regex::new).
121            // Added lazy cache. Long-term: replace with vyre AC kernel
122            // (vyre_libs::scan::aho_corasick) or Opaque pre-compiled condition.
123            let Some(value) = ctx.field_value(field.as_ref()) else {
124                return false;
125            };
126            use std::collections::HashMap;
127            use std::sync::LazyLock;
128            use std::sync::Mutex;
129            static REGEX_CACHE: LazyLock<Mutex<HashMap<String, regex::Regex>>> =
130                LazyLock::new(|| Mutex::new(HashMap::new()));
131            let Ok(cache) = REGEX_CACHE.lock() else {
132                return false;
133            };
134            let re = cache.get(pattern.as_ref()).cloned();
135            drop(cache);
136            match re {
137                Some(re) => re.is_match(value),
138                None => match regex::Regex::new(pattern.as_ref()) {
139                    Ok(re) => {
140                        let Ok(mut cache) = REGEX_CACHE.lock() else {
141                            return false;
142                        };
143                        cache.insert(pattern.to_string(), re.clone());
144                        re.is_match(value)
145                    }
146                    Err(_) => false,
147                },
148            }
149        }
150        RuleCondition::SubstringMatch { haystack, needle } => ctx
151            .field_value(haystack.as_ref())
152            .map(|h| h.contains(needle.as_ref()))
153            .unwrap_or(false),
154        RuleCondition::PrefixMatch { value, prefix } => ctx
155            .field_value(value.as_ref())
156            .map(|v| v.starts_with(prefix.as_ref()))
157            .unwrap_or(false),
158        RuleCondition::SuffixMatch { value, suffix } => ctx
159            .field_value(value.as_ref())
160            .map(|v| v.ends_with(suffix.as_ref()))
161            .unwrap_or(false),
162        RuleCondition::RangeMatch { value, min, max } => *value >= *min && *value <= *max,
163        RuleCondition::SetMembership { value, set } => {
164            set.iter().map(Arc::as_ref).any(|m| m == value.as_ref())
165        }
166        RuleCondition::FieldInSet { field, set } => {
167            let Some(value) = ctx.field_value(field.as_ref()) else {
168                return false;
169            };
170            set.iter().map(Arc::as_ref).any(|m| m == value)
171        }
172        RuleCondition::Opaque(ext) => {
173            // Opaque extensions get a `Unit` `&dyn Any` because the
174            // standard `RuleEvaluationContext` cannot cross the
175            // 'static-required Any boundary as a borrow. Extensions
176            // that need the standard context should be migrated to
177            // a context-aware variant (future RuleConditionExt::
178            // evaluate_with_context); for now the reference evaluator
179            // just respects the upstream contract by passing the
180            // empty payload the GPU lowering also uses.
181            ext.evaluate_opaque(&() as &dyn std::any::Any)
182        }
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    struct StaticCtx<'a> {
191        counts: &'a [(u32, u32)],
192        size: u64,
193        fields: &'a [(&'a str, &'a str)],
194    }
195
196    impl<'a> RuleEvaluationContext for StaticCtx<'a> {
197        fn pattern_count(&self, pid: u32) -> u32 {
198            self.counts
199                .iter()
200                .find(|(p, _)| *p == pid)
201                .map(|(_, c)| *c)
202                .unwrap_or(0)
203        }
204        fn file_size(&self) -> u64 {
205            self.size
206        }
207        fn field_value(&self, name: &str) -> Option<&str> {
208            self.fields
209                .iter()
210                .find(|(n, _)| *n == name)
211                .map(|(_, v)| *v)
212        }
213    }
214
215    fn empty_ctx() -> StaticCtx<'static> {
216        StaticCtx {
217            counts: &[],
218            size: 0,
219            fields: &[],
220        }
221    }
222
223    #[test]
224    fn literal_true_and_false() {
225        assert!(evaluate_condition(
226            &RuleCondition::LiteralTrue,
227            &empty_ctx()
228        ));
229        assert!(!evaluate_condition(
230            &RuleCondition::LiteralFalse,
231            &empty_ctx()
232        ));
233    }
234
235    #[test]
236    fn pattern_exists_uses_count() {
237        let ctx = StaticCtx {
238            counts: &[(7, 3)],
239            size: 0,
240            fields: &[],
241        };
242        assert!(evaluate_condition(
243            &RuleCondition::PatternExists { pattern_id: 7 },
244            &ctx
245        ));
246        assert!(!evaluate_condition(
247            &RuleCondition::PatternExists { pattern_id: 8 },
248            &ctx
249        ));
250    }
251
252    #[test]
253    fn pattern_count_gt_gte() {
254        let ctx = StaticCtx {
255            counts: &[(1, 5)],
256            size: 0,
257            fields: &[],
258        };
259        assert!(evaluate_condition(
260            &RuleCondition::PatternCountGt {
261                pattern_id: 1,
262                threshold: 4,
263            },
264            &ctx
265        ));
266        assert!(!evaluate_condition(
267            &RuleCondition::PatternCountGt {
268                pattern_id: 1,
269                threshold: 5,
270            },
271            &ctx
272        ));
273        assert!(evaluate_condition(
274            &RuleCondition::PatternCountGte {
275                pattern_id: 1,
276                threshold: 5,
277            },
278            &ctx
279        ));
280        assert!(!evaluate_condition(
281            &RuleCondition::PatternCountGte {
282                pattern_id: 1,
283                threshold: 6,
284            },
285            &ctx
286        ));
287    }
288
289    #[test]
290    fn file_size_predicates() {
291        let ctx = StaticCtx {
292            counts: &[],
293            size: 100,
294            fields: &[],
295        };
296        assert!(evaluate_condition(&RuleCondition::FileSizeLt(101), &ctx));
297        assert!(!evaluate_condition(&RuleCondition::FileSizeLt(100), &ctx));
298        assert!(evaluate_condition(&RuleCondition::FileSizeLte(100), &ctx));
299        assert!(evaluate_condition(&RuleCondition::FileSizeGt(99), &ctx));
300        assert!(evaluate_condition(&RuleCondition::FileSizeGte(100), &ctx));
301        assert!(evaluate_condition(&RuleCondition::FileSizeEq(100), &ctx));
302        assert!(evaluate_condition(&RuleCondition::FileSizeNe(99), &ctx));
303        assert!(!evaluate_condition(&RuleCondition::FileSizeNe(100), &ctx));
304    }
305
306    #[test]
307    fn substring_prefix_suffix() {
308        let ctx = StaticCtx {
309            counts: &[],
310            size: 0,
311            fields: &[("path", "src/foo/bar.rs")],
312        };
313        assert!(evaluate_condition(
314            &RuleCondition::SubstringMatch {
315                haystack: "path".into(),
316                needle: "/foo/".into(),
317            },
318            &ctx
319        ));
320        assert!(evaluate_condition(
321            &RuleCondition::PrefixMatch {
322                value: "path".into(),
323                prefix: "src/".into(),
324            },
325            &ctx
326        ));
327        assert!(evaluate_condition(
328            &RuleCondition::SuffixMatch {
329                value: "path".into(),
330                suffix: ".rs".into(),
331            },
332            &ctx
333        ));
334        assert!(!evaluate_condition(
335            &RuleCondition::SuffixMatch {
336                value: "path".into(),
337                suffix: ".py".into(),
338            },
339            &ctx
340        ));
341        assert!(!evaluate_condition(
342            &RuleCondition::SubstringMatch {
343                haystack: "missing".into(),
344                needle: "x".into(),
345            },
346            &ctx
347        ));
348    }
349
350    #[test]
351    fn range_match_inclusive() {
352        let cond = RuleCondition::RangeMatch {
353            value: 50,
354            min: 10,
355            max: 100,
356        };
357        assert!(evaluate_condition(&cond, &empty_ctx()));
358        let cond = RuleCondition::RangeMatch {
359            value: 5,
360            min: 10,
361            max: 100,
362        };
363        assert!(!evaluate_condition(&cond, &empty_ctx()));
364    }
365
366    #[test]
367    fn field_in_set_resolves_via_context() {
368        let ctx = StaticCtx {
369            counts: &[],
370            size: 0,
371            fields: &[("detector_id", "aws-access-key")],
372        };
373        use smallvec::smallvec;
374        let cond = RuleCondition::FieldInSet {
375            field: "detector_id".into(),
376            set: smallvec!["github-pat".into(), "aws-access-key".into()],
377        };
378        assert!(evaluate_condition(&cond, &ctx));
379        let cond = RuleCondition::FieldInSet {
380            field: "detector_id".into(),
381            set: smallvec!["stripe".into()],
382        };
383        assert!(!evaluate_condition(&cond, &ctx));
384        let cond = RuleCondition::FieldInSet {
385            field: "missing".into(),
386            set: smallvec!["x".into()],
387        };
388        assert!(!evaluate_condition(&cond, &ctx));
389    }
390
391    #[test]
392    fn set_membership() {
393        use smallvec::smallvec;
394        let cond = RuleCondition::SetMembership {
395            value: "blue".into(),
396            set: smallvec!["red".into(), "blue".into(), "green".into()],
397        };
398        assert!(evaluate_condition(&cond, &empty_ctx()));
399        let cond = RuleCondition::SetMembership {
400            value: "yellow".into(),
401            set: smallvec!["red".into(), "blue".into()],
402        };
403        assert!(!evaluate_condition(&cond, &empty_ctx()));
404    }
405
406    #[test]
407    fn regex_match_uses_field_value() {
408        let ctx = StaticCtx {
409            counts: &[],
410            size: 0,
411            fields: &[("commit", "abcdef1234567890")],
412        };
413        let cond = RuleCondition::RegexMatch {
414            field: "commit".into(),
415            pattern: "^[0-9a-f]+$".into(),
416        };
417        assert!(evaluate_condition(&cond, &ctx));
418        let cond = RuleCondition::RegexMatch {
419            field: "commit".into(),
420            pattern: "^[A-Z]+$".into(),
421        };
422        assert!(!evaluate_condition(&cond, &ctx));
423        // Unknown field → false.
424        let cond = RuleCondition::RegexMatch {
425            field: "missing".into(),
426            pattern: ".*".into(),
427        };
428        assert!(!evaluate_condition(&cond, &ctx));
429    }
430
431    #[test]
432    fn formula_and_or_not_short_circuit() {
433        let ctx = empty_ctx();
434        let f = RuleFormula::and(
435            RuleFormula::condition(RuleCondition::LiteralTrue),
436            RuleFormula::condition(RuleCondition::LiteralTrue),
437        );
438        assert!(evaluate_formula(&f, &ctx));
439
440        let f = RuleFormula::and(
441            RuleFormula::condition(RuleCondition::LiteralTrue),
442            RuleFormula::condition(RuleCondition::LiteralFalse),
443        );
444        assert!(!evaluate_formula(&f, &ctx));
445
446        let f = RuleFormula::or(
447            RuleFormula::condition(RuleCondition::LiteralFalse),
448            RuleFormula::condition(RuleCondition::LiteralTrue),
449        );
450        assert!(evaluate_formula(&f, &ctx));
451
452        let f = RuleFormula::not_formula(RuleFormula::condition(RuleCondition::LiteralFalse));
453        assert!(evaluate_formula(&f, &ctx));
454    }
455
456    #[test]
457    fn nested_formula() {
458        // (PatternExists(7) AND FileSizeLt(2048)) OR NOT PatternCountGt(99, 1000)
459        let ctx = StaticCtx {
460            counts: &[(7, 3), (99, 50)],
461            size: 1024,
462            fields: &[],
463        };
464        let f = RuleFormula::or(
465            RuleFormula::and(
466                RuleFormula::condition(RuleCondition::PatternExists { pattern_id: 7 }),
467                RuleFormula::condition(RuleCondition::FileSizeLt(2048)),
468            ),
469            RuleFormula::not_formula(RuleFormula::condition(RuleCondition::PatternCountGt {
470                pattern_id: 99,
471                threshold: 1000,
472            })),
473        );
474        assert!(evaluate_formula(&f, &ctx));
475    }
476}