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        if let Ok(mut cache) = COMPILE_CACHE.lock() {
105            cache.insert(cache_key, re.clone());
106        }
107        Ok(Self {
108            source: source.to_owned(),
109            re,
110        })
111    }
112
113    #[must_use]
114    pub const fn re(&self) -> &Regex {
115        &self.re
116    }
117}
118
119/// Code-point index for a byte offset known to sit on a char boundary.
120#[must_use]
121pub fn cp_index(haystack: &str, byte_idx: usize) -> usize {
122    haystack[..byte_idx].chars().count()
123}
124
125/// Candidate match as byte span into the scanned string.
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub struct Candidate {
128    pub start: usize,
129    pub end: usize,
130}
131
132impl Candidate {
133    #[must_use]
134    pub const fn new(start: usize, end: usize) -> Self {
135        Self { start, end }
136    }
137
138    #[must_use]
139    pub fn text<'a>(&self, haystack: &'a str) -> &'a str {
140        &haystack[self.start..self.end]
141    }
142}
143
144#[must_use]
145pub fn find_all(re: &Regex, haystack: &str) -> Vec<Candidate> {
146    re.find_iter(haystack)
147        .map(|m| Candidate::new(m.start(), m.end()))
148        .collect()
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn translates_absolute_end_anchor() {
157        let re = PyRegex::compile(r"\A[^\n]*x\s*\Z", false).unwrap();
158        assert!(re.re().is_match("a x"));
159        assert!(!re.re().is_match("a\nx"));
160    }
161
162    #[test]
163    fn translates_python_dollar() {
164        let re = PyRegex::compile(r"(?:/|\s|$)", false).unwrap();
165        assert!(re.re().is_match("/"));
166        assert!(re.re().is_match("x\n"));
167    }
168
169    #[test]
170    fn scoped_case_sensitivity() {
171        let re = PyRegex::compile("(?i)(?-i:rO0AB)", false).unwrap();
172        assert!(re.re().is_match("rO0AB"));
173        assert!(!re.re().is_match("ro0ab"));
174    }
175}