Skip to main content

guard_core_engine/
compiler.rs

1use std::num::NonZeroUsize;
2
3use lru::LruCache;
4use regex::Regex;
5
6/// LRU cache for compiled regex patterns.
7///
8/// Capacity is clamped to 1..=5000. Patterns are compiled with
9/// case-insensitive and multiline flags (`(?im)`).
10pub struct PatternCache {
11    cache: LruCache<String, Regex>,
12}
13
14impl PatternCache {
15    /// Create a new cache. Capacity is clamped to 1..=5000.
16    #[must_use]
17    pub fn new(capacity: usize) -> Self {
18        let cap = capacity.clamp(1, 5000);
19        Self {
20            cache: LruCache::new(NonZeroUsize::new(cap).expect("clamped to >= 1")),
21        }
22    }
23
24    /// Retrieve from cache or compile and insert. LRU eviction on overflow.
25    pub fn get_or_compile(&mut self, pattern: &str) -> Result<&Regex, regex::Error> {
26        // single hash lookup via try_get_or_insert_mut (lru 0.17+)
27        self.cache
28            .try_get_or_insert_mut(pattern.to_owned(), || {
29                Regex::new(&format!("(?im){pattern}"))
30            })
31            .map(|r| &*r)
32    }
33
34    /// Drop all cached entries.
35    pub fn clear(&mut self) {
36        self.cache.clear();
37    }
38
39    /// Number of currently cached patterns.
40    #[must_use]
41    pub fn len(&self) -> usize {
42        self.cache.len()
43    }
44
45    /// `true` if no patterns are cached.
46    #[must_use]
47    pub fn is_empty(&self) -> bool {
48        self.cache.is_empty()
49    }
50}
51
52/// Compile a one-shot pattern with case-insensitive + multiline flags.
53pub fn compile(pattern: &str) -> Result<Regex, regex::Error> {
54    Regex::new(&format!("(?im){pattern}"))
55}
56
57/// Check whether a pattern is safe from catastrophic backtracking with the
58/// full reference safety chain.
59///
60/// The chain runs dangerous constructs, the compile check, the structural
61/// detectors, probe synthesis, and the empirical cost arbiter. The `regex`
62/// crate is linear-time, so this certifies the pattern source the way the
63/// reference engine does: the reasons name the specific finding and match
64/// the reference's human-readable prefixes.
65///
66/// Prefer [`crate::redos::validate_pattern_safety`] for the structured
67/// verdict; this compat shim keeps the historic two-tuple shape.
68#[must_use]
69pub fn validate_pattern_safety(pattern: &str) -> (bool, String) {
70    crate::redos::validate_pattern_safety_compat(pattern)
71}
72
73/// Compile multiple patterns, skipping invalid ones. When `validate` is
74/// true, patterns flagged by [`validate_pattern_safety`] are also skipped.
75#[must_use]
76pub fn batch_compile(patterns: &[&str], validate: bool) -> Vec<(String, Regex)> {
77    patterns
78        .iter()
79        .filter_map(|&pat| {
80            if validate && !validate_pattern_safety(pat).0 {
81                return None;
82            }
83            compile(pat).ok().map(|re| (pat.to_owned(), re))
84        })
85        .collect()
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn cache_basic_operations() {
94        let mut cache = PatternCache::new(10);
95        assert!(cache.is_empty());
96
97        let re = cache.get_or_compile(r"\d+").unwrap();
98        assert!(re.is_match("123"));
99        assert_eq!(cache.len(), 1);
100
101        // second lookup: same pattern, still 1 entry, LRU result consistent
102        let r1 = cache.get_or_compile(r"\d+").unwrap().as_str().to_owned();
103        let r2 = cache.get_or_compile(r"\d+").unwrap().as_str().to_owned();
104        assert_eq!(r1, r2);
105        assert_eq!(cache.len(), 1);
106
107        cache.clear();
108        assert!(cache.is_empty());
109    }
110
111    #[test]
112    fn cache_eviction_and_lru_order() {
113        let mut cache = PatternCache::new(2);
114        cache.get_or_compile("aaa").unwrap();
115        cache.get_or_compile("bbb").unwrap();
116        cache.get_or_compile("ccc").unwrap();
117        assert_eq!(cache.len(), 2);
118
119        // LRU: aaa was evicted; touch bbb then add new — ccc stays
120        let mut cache = PatternCache::new(3);
121        for i in 0..3 {
122            cache.get_or_compile(&format!("pattern_{i}")).unwrap();
123        }
124        let _ = cache.get_or_compile("pattern_0").unwrap();
125        cache.get_or_compile("pattern_new").unwrap();
126        assert_eq!(cache.len(), 3);
127    }
128
129    #[test]
130    fn cache_capacity_clamping() {
131        assert_eq!(PatternCache::new(0).cache.cap().get(), 1);
132        assert_eq!(PatternCache::new(99999).cache.cap().get(), 5000);
133    }
134
135    #[test]
136    fn dangerous_patterns_detected() {
137        for pat in [
138            r"(.*)+",
139            r"(.+)+",
140            r"([a-z]*)+",
141            r"([a-z]+)+",
142            r".*.*",
143            r".+.+",
144        ] {
145            let (safe, _) = validate_pattern_safety(pat);
146            assert!(!safe, "should flag: {pat}");
147        }
148    }
149
150    #[test]
151    fn safe_patterns_pass() {
152        let (safe, msg) = validate_pattern_safety(r"test\d+");
153        assert!(safe);
154        assert_eq!(msg, "Pattern appears safe");
155
156        for pat in [r"<script[^>]*>", r"[a-zA-Z0-9]+"] {
157            assert!(validate_pattern_safety(pat).0, "should pass: {pat}");
158        }
159    }
160
161    #[test]
162    fn bounded_cost_pattern_passes_at_the_configured_cap() {
163        // At the default cap the zero-fill probes extrapolate over budget,
164        // so callers cap the cost verdict at their content limit (the
165        // corpus pins 10000 for this exact pattern).
166        let verdict = crate::redos::validate_pattern_safety(
167            r"\d{3}-\d{3}-\d{4}",
168            &crate::redos::SafetyMode::CostVerdict {
169                max_content_length: Some(10000),
170            },
171        );
172        assert!(verdict.safe, "reason: {}", verdict.reason);
173
174        // The verdict at the default cap is host-speed dependent (an
175        // optimized build extrapolates under the budget), so only the
176        // deterministic capped verdict is asserted here. The over-budget
177        // reason class is pinned by the corpus and by the verdict-math
178        // unit tests.
179    }
180
181    #[test]
182    fn compile_flags_and_errors() {
183        // case-insensitive + multiline by default
184        assert!(compile(r"select").unwrap().is_match("SELECT"));
185        assert!(
186            compile(r"^hello")
187                .unwrap()
188                .is_match("first line\nhello world")
189        );
190        // invalid pattern
191        assert!(compile(r"invalid(pattern").is_err());
192    }
193
194    #[test]
195    fn batch_compile_filters_invalid_and_dangerous() {
196        let results = batch_compile(&[r"\d+", r"invalid(", r"[a-z]+"], false);
197        assert_eq!(results.len(), 2);
198
199        let results = batch_compile(&[r"\d+", r"(.*)+", r"[a-z]+"], true);
200        assert_eq!(results.len(), 2);
201        assert!(results.iter().all(|(p, _)| p != "(.*)+"));
202
203        assert!(batch_compile(&[r"invalid(", r"[unclosed"], false).is_empty());
204    }
205}