Skip to main content

guard_core_engine/
compiler.rs

1use std::num::NonZeroUsize;
2
3use lru::LruCache;
4use regex::Regex;
5
6const DANGEROUS_PATTERNS: &[&str] = &[
7    r"\(\.\*\)\+",
8    r"\(\.\+\)\+",
9    r"\([^)]*\*\)\+",
10    r"\([^)]*\+\)\+",
11    r"(?:\.\*){2,}",
12    r"(?:\.\+){2,}",
13];
14
15/// LRU cache for compiled regex patterns.
16///
17/// Capacity is clamped to 1..=5000. Patterns are compiled with
18/// case-insensitive and multiline flags (`(?im)`).
19pub struct PatternCache {
20    cache: LruCache<String, Regex>,
21}
22
23impl PatternCache {
24    /// Create a new cache. Capacity is clamped to 1..=5000.
25    #[must_use]
26    pub fn new(capacity: usize) -> Self {
27        let cap = capacity.clamp(1, 5000);
28        Self {
29            cache: LruCache::new(NonZeroUsize::new(cap).expect("clamped to >= 1")),
30        }
31    }
32
33    /// Retrieve from cache or compile and insert. LRU eviction on overflow.
34    pub fn get_or_compile(&mut self, pattern: &str) -> Result<&Regex, regex::Error> {
35        // single hash lookup via try_get_or_insert_mut (lru 0.17+)
36        self.cache
37            .try_get_or_insert_mut(pattern.to_owned(), || {
38                Regex::new(&format!("(?im){pattern}"))
39            })
40            .map(|r| &*r)
41    }
42
43    /// Drop all cached entries.
44    pub fn clear(&mut self) {
45        self.cache.clear();
46    }
47
48    /// Number of currently cached patterns.
49    #[must_use]
50    pub fn len(&self) -> usize {
51        self.cache.len()
52    }
53
54    /// `true` if no patterns are cached.
55    #[must_use]
56    pub fn is_empty(&self) -> bool {
57        self.cache.is_empty()
58    }
59}
60
61/// Compile a one-shot pattern with case-insensitive + multiline flags.
62pub fn compile(pattern: &str) -> Result<Regex, regex::Error> {
63    Regex::new(&format!("(?im){pattern}"))
64}
65
66/// Check if a pattern contains constructs that cause catastrophic
67/// backtracking in PCRE/Python `re`.
68///
69/// Rust's `regex` crate uses finite automata and is inherently ReDoS-safe,
70/// but this flags patterns that would be dangerous in other engines.
71/// On match, the returned reason names the specific dangerous construct.
72#[must_use]
73pub fn validate_pattern_safety(pattern: &str) -> (bool, &'static str) {
74    for &dangerous in DANGEROUS_PATTERNS {
75        if let Ok(checker) = Regex::new(dangerous)
76            && checker.is_match(pattern)
77        {
78            return (false, dangerous);
79        }
80    }
81
82    (true, "pattern appears safe")
83}
84
85/// Compile multiple patterns, skipping invalid ones. When `validate` is
86/// true, patterns flagged by [`validate_pattern_safety`] are also skipped.
87#[must_use]
88pub fn batch_compile(patterns: &[&str], validate: bool) -> Vec<(String, Regex)> {
89    patterns
90        .iter()
91        .filter_map(|&pat| {
92            if validate && !validate_pattern_safety(pat).0 {
93                return None;
94            }
95            compile(pat).ok().map(|re| (pat.to_owned(), re))
96        })
97        .collect()
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn cache_basic_operations() {
106        let mut cache = PatternCache::new(10);
107        assert!(cache.is_empty());
108
109        let re = cache.get_or_compile(r"\d+").unwrap();
110        assert!(re.is_match("123"));
111        assert_eq!(cache.len(), 1);
112
113        // second lookup: same pattern, still 1 entry, LRU result consistent
114        let r1 = cache.get_or_compile(r"\d+").unwrap().as_str().to_owned();
115        let r2 = cache.get_or_compile(r"\d+").unwrap().as_str().to_owned();
116        assert_eq!(r1, r2);
117        assert_eq!(cache.len(), 1);
118
119        cache.clear();
120        assert!(cache.is_empty());
121    }
122
123    #[test]
124    fn cache_eviction_and_lru_order() {
125        let mut cache = PatternCache::new(2);
126        cache.get_or_compile("aaa").unwrap();
127        cache.get_or_compile("bbb").unwrap();
128        cache.get_or_compile("ccc").unwrap();
129        assert_eq!(cache.len(), 2);
130
131        // LRU: aaa was evicted; touch bbb then add new — ccc stays
132        let mut cache = PatternCache::new(3);
133        for i in 0..3 {
134            cache.get_or_compile(&format!("pattern_{i}")).unwrap();
135        }
136        let _ = cache.get_or_compile("pattern_0").unwrap();
137        cache.get_or_compile("pattern_new").unwrap();
138        assert_eq!(cache.len(), 3);
139    }
140
141    #[test]
142    fn cache_capacity_clamping() {
143        assert_eq!(PatternCache::new(0).cache.cap().get(), 1);
144        assert_eq!(PatternCache::new(99999).cache.cap().get(), 5000);
145    }
146
147    #[test]
148    fn dangerous_patterns_detected() {
149        for pat in [
150            r"(.*)+",
151            r"(.+)+",
152            r"([a-z]*)+",
153            r"([a-z]+)+",
154            r".*.*",
155            r".+.+",
156        ] {
157            let (safe, _) = validate_pattern_safety(pat);
158            assert!(!safe, "should flag: {pat}");
159        }
160    }
161
162    #[test]
163    fn safe_patterns_pass() {
164        let (safe, msg) = validate_pattern_safety(r"test\d+");
165        assert!(safe);
166        assert_eq!(msg, "pattern appears safe");
167
168        for pat in [r"<script[^>]*>", r"\d{3}-\d{3}-\d{4}", r"[a-zA-Z0-9]+"] {
169            assert!(validate_pattern_safety(pat).0, "should pass: {pat}");
170        }
171    }
172
173    #[test]
174    fn compile_flags_and_errors() {
175        // case-insensitive + multiline by default
176        assert!(compile(r"select").unwrap().is_match("SELECT"));
177        assert!(
178            compile(r"^hello")
179                .unwrap()
180                .is_match("first line\nhello world")
181        );
182        // invalid pattern
183        assert!(compile(r"invalid(pattern").is_err());
184    }
185
186    #[test]
187    fn batch_compile_filters_invalid_and_dangerous() {
188        let results = batch_compile(&[r"\d+", r"invalid(", r"[a-z]+"], false);
189        assert_eq!(results.len(), 2);
190
191        let results = batch_compile(&[r"\d+", r"(.*)+", r"[a-z]+"], true);
192        assert_eq!(results.len(), 2);
193        assert!(results.iter().all(|(p, _)| p != "(.*)+"));
194
195        assert!(batch_compile(&[r"invalid(", r"[unclosed"], false).is_empty());
196    }
197}