Skip to main content

guard_core_engine/patterns/
pyregex.rs

1//! Python `re` compatibility layer for the detection pattern table.
2//!
3//! The spec 4.0.2 table is authored as Python regex sources and the conformance
4//! corpus compares the canonical source string carried by every threat. This
5//! module compiles those sources for the `regex` crate with equivalent
6//! semantics for the constructs the table uses.
7//!
8//! Translation rules:
9//! - a leading `(?i)` is left in place (the crate honors it); builtin
10//!   IGNORECASE is expressed by prepending `(?i)` when the source does not
11//!   carry its own global flag;
12//! - sources embedding `(?-i:...)` wrappers compile without a global case
13//!   fold: the wrapper scopes case sensitivity back for the wrapped body and
14//!   the crate honors that natively;
15//! - `\Z` (Python absolute end) becomes `\z`;
16//! - a bare `$` outside a class means "end of string or just before a
17//!   trailing newline" in Python and becomes `(?:\n?\z)`.
18//!
19//! Constructs the crate rejects (lookaround, backreferences) fail compilation
20//! here; those table entries are served by structural matchers instead.
21
22use regex::Regex;
23use std::collections::HashMap;
24use std::sync::{LazyLock, Mutex};
25
26/// Process-wide compile cache. The reference compiles every pattern once at
27/// module import; several structural helpers here compile helper sources
28/// per call. `Regex` clone is an O(1) Arc clone, so caching compiled regexes
29/// keyed by (source, case) makes every per-call compile a lookup after the
30/// first use. The key set is bounded by the fixed source strings in this
31/// crate.
32static COMPILE_CACHE: LazyLock<Mutex<HashMap<(String, bool), Regex>>> =
33    LazyLock::new(|| Mutex::new(HashMap::new()));
34
35pub struct PyRegex {
36    /// Canonical Python-style source; carried verbatim on threats.
37    pub source: String,
38    re: Regex,
39}
40
41fn translate(source: &str) -> String {
42    let mut out = String::with_capacity(source.len() + 8);
43    let mut chars = source.chars().peekable();
44    let mut in_class = false;
45    while let Some(ch) = chars.next() {
46        if ch == '\\' {
47            out.push(ch);
48            if let Some(&next) = chars.peek() {
49                if next == 'Z' {
50                    out.pop();
51                    out.push_str("\\z");
52                } else {
53                    out.push(next);
54                }
55                chars.next();
56            }
57            continue;
58        }
59        match ch {
60            '[' if !in_class => {
61                in_class = true;
62                out.push(ch);
63            }
64            ']' if in_class => {
65                in_class = false;
66                out.push(ch);
67            }
68            '$' if !in_class => {
69                // Python `$`: end of string, or just before a trailing newline.
70                out.push_str("(?:\\n?\\z)");
71            }
72            _ => out.push(ch),
73        }
74    }
75    out
76}
77
78impl PyRegex {
79    pub fn compile(source: &str, ignore_case: bool) -> Result<Self, String> {
80        let effective_case = if source.contains("(?-i:") {
81            // scoped case-sensitive body: the reference wraps fully
82            // case-sensitive alternations in `(?-i:...)` and the builtin
83            // IGNORECASE applies to nothing else in those sources
84            false
85        } else {
86            ignore_case || source.starts_with("(?i)")
87        };
88        let cache_key = (source.to_owned(), effective_case);
89        if let Ok(cache) = COMPILE_CACHE.lock()
90            && let Some(re) = cache.get(&cache_key)
91        {
92            return Ok(Self {
93                source: source.to_owned(),
94                re: re.clone(),
95            });
96        }
97        let translated = translate(source);
98        let mut pattern = String::with_capacity(translated.len() + 8);
99        if effective_case && !translated.starts_with("(?i)") {
100            pattern.push_str("(?i)");
101        }
102        pattern.push_str(&translated);
103        let re = Regex::new(&pattern).map_err(|e| format!("{source}: {e}"))?;
104        #[cfg(not(coverage))] // unreachable: the lock is never poisoned,
105        // nothing panics while it is held
106        if let Ok(mut cache) = COMPILE_CACHE.lock() {
107            cache.insert(cache_key, re.clone());
108        }
109        #[cfg(coverage)]
110        COMPILE_CACHE
111            .lock()
112            .expect("the compile cache is never poisoned")
113            .insert(cache_key, re.clone());
114        Ok(Self {
115            source: source.to_owned(),
116            re,
117        })
118    }
119
120    #[must_use]
121    pub const fn re(&self) -> &Regex {
122        &self.re
123    }
124}
125
126/// Code-point index for a byte offset known to sit on a char boundary.
127#[must_use]
128pub fn cp_index(haystack: &str, byte_idx: usize) -> usize {
129    haystack[..byte_idx].chars().count()
130}
131
132/// Candidate match as byte span into the scanned string.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct Candidate {
135    pub start: usize,
136    pub end: usize,
137}
138
139impl Candidate {
140    #[must_use]
141    pub const fn new(start: usize, end: usize) -> Self {
142        Self { start, end }
143    }
144
145    #[must_use]
146    pub fn text<'a>(&self, haystack: &'a str) -> &'a str {
147        &haystack[self.start..self.end]
148    }
149}
150
151#[must_use]
152pub fn find_all(re: &Regex, haystack: &str) -> Vec<Candidate> {
153    re.find_iter(haystack)
154        .map(|m| Candidate::new(m.start(), m.end()))
155        .collect()
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[test]
163    fn translates_absolute_end_anchor() {
164        let re = PyRegex::compile(r"\A[^\n]*x\s*\Z", false).unwrap();
165        assert!(re.re().is_match("a x"));
166        assert!(!re.re().is_match("a\nx"));
167    }
168
169    #[test]
170    fn translates_python_dollar() {
171        let re = PyRegex::compile(r"(?:/|\s|$)", false).unwrap();
172        assert!(re.re().is_match("/"));
173        assert!(re.re().is_match("x\n"));
174    }
175
176    #[test]
177    fn scoped_case_sensitivity() {
178        let re = PyRegex::compile("(?i)(?-i:rO0AB)", false).unwrap();
179        assert!(re.re().is_match("rO0AB"));
180        assert!(!re.re().is_match("ro0ab"));
181    }
182}
183
184#[cfg(test)]
185mod coverage_tests {
186    use super::*;
187
188    #[test]
189    fn find_all_maps_every_match_to_a_candidate() {
190        let re = PyRegex::compile(r"ab", false).unwrap();
191        let ms = find_all(re.re(), "abxab");
192        assert_eq!(ms.len(), 2);
193        assert_eq!(ms[0].start, 0);
194        assert_eq!(ms[1].start, 3);
195    }
196
197    #[test]
198    fn a_trailing_escape_passes_through_the_translator() {
199        // a source ending in a lone backslash has no escaped char to consume
200        assert_eq!(translate("x\\"), "x\\");
201        assert_eq!(translate("\\"), "\\");
202        // the regex crate rejects the malformed source, but only after the
203        // translation ran
204        assert!(PyRegex::compile("x\\", false).is_err());
205    }
206}