Skip to main content

rtb_app/
regex_util.rs

1//! Bounded compilation of untrusted regular expressions.
2//!
3//! Per CLAUDE.md §Regex Compilation, any `regex::Regex` whose pattern
4//! originates *outside* the binary (config file, CLI flag, TUI input, HTTP
5//! payload, message queue) must be compiled through [`compile_bounded`],
6//! which caps compile-time memory and rejects over-long patterns. Literal,
7//! build-time patterns may use `regex::Regex::new` directly (optionally
8//! `once_cell::sync::Lazy<Regex>`).
9//!
10//! Unlike GTB's Go counterpart, no match-time timeout is needed: Rust's
11//! `regex` is a Thompson NFA with linear-time matching, so there is no
12//! catastrophic-backtracking class to defend against. The caps bound
13//! *compile-time* memory — the only remaining denial-of-service vector.
14//!
15//! See rust-tool-base spec
16//! [0031, `regex_util::compile_bounded`](https://gitlab.com/phpboyscout/rust-tool-base/-/wikis/specs/0031-rtb-app-regex-util-compile-bounded).
17
18use regex::RegexBuilder;
19
20/// Maximum byte length of an externally-sourced pattern.
21pub const MAX_PATTERN_LEN: usize = 1024; // 1 KiB
22
23/// `RegexBuilder::size_limit` — compiled-program memory cap.
24pub const SIZE_LIMIT: usize = 1 << 20; // 1 MiB
25
26/// `RegexBuilder::dfa_size_limit` — lazy-DFA cache cap.
27pub const DFA_SIZE_LIMIT: usize = 8 << 20; // 8 MiB
28
29/// Error returned when an untrusted pattern is rejected or fails to compile
30/// within the configured memory bounds.
31#[derive(Debug, thiserror::Error, miette::Diagnostic)]
32pub enum RegexCompileError {
33    /// The pattern exceeds [`MAX_PATTERN_LEN`].
34    #[error("regex pattern is {len} bytes; limit is {} bytes", MAX_PATTERN_LEN)]
35    #[diagnostic(code(rtb_app::regex::too_long))]
36    TooLong {
37        /// Actual pattern length, in bytes.
38        len: usize,
39    },
40
41    /// The pattern was syntactically invalid or would exceed [`SIZE_LIMIT`]
42    /// / [`DFA_SIZE_LIMIT`] when compiled.
43    #[error("regex failed to compile within memory bounds")]
44    #[diagnostic(
45        code(rtb_app::regex::compile),
46        help("simplify the pattern or reduce repetition counts")
47    )]
48    Compile(#[source] regex::Error),
49}
50
51/// Compile an untrusted `pattern` under fixed memory bounds.
52///
53/// The length gate is checked *before* the pattern reaches the regex
54/// engine, so an over-long pattern is rejected cheaply.
55///
56/// # Errors
57///
58/// Returns [`RegexCompileError::TooLong`] if `pattern` exceeds
59/// [`MAX_PATTERN_LEN`] bytes, or [`RegexCompileError::Compile`] if it is
60/// syntactically invalid or would exceed the compile-time memory bounds.
61pub fn compile_bounded(pattern: &str) -> Result<regex::Regex, RegexCompileError> {
62    if pattern.len() > MAX_PATTERN_LEN {
63        return Err(RegexCompileError::TooLong { len: pattern.len() });
64    }
65    RegexBuilder::new(pattern)
66        .size_limit(SIZE_LIMIT)
67        .dfa_size_limit(DFA_SIZE_LIMIT)
68        .build()
69        .map_err(RegexCompileError::Compile)
70}
71
72#[cfg(test)]
73mod tests {
74    use super::{compile_bounded, RegexCompileError, DFA_SIZE_LIMIT, MAX_PATTERN_LEN, SIZE_LIMIT};
75
76    #[test]
77    fn compiles_a_valid_pattern() {
78        let re = compile_bounded(r"^\d{3}-\d{4}$").expect("valid pattern compiles");
79        assert!(re.is_match("123-4567"));
80        assert!(!re.is_match("nope"));
81    }
82
83    #[test]
84    fn rejects_overlong_pattern() {
85        let pattern = "a".repeat(MAX_PATTERN_LEN + 1);
86        let err = compile_bounded(&pattern).expect_err("over-long pattern is rejected");
87        assert!(matches!(err, RegexCompileError::TooLong { len } if len == MAX_PATTERN_LEN + 1));
88    }
89
90    #[test]
91    fn accepts_pattern_at_exact_limit() {
92        // A pattern of exactly MAX_PATTERN_LEN bytes passes the length gate
93        // and compiles (1024 literal 'a's is a tiny program).
94        let pattern = "a".repeat(MAX_PATTERN_LEN);
95        let re = compile_bounded(&pattern).expect("at-limit pattern compiles");
96        assert!(re.is_match(&"a".repeat(MAX_PATTERN_LEN)));
97    }
98
99    #[test]
100    fn rejects_syntactically_invalid_pattern() {
101        let err = compile_bounded("(").expect_err("unbalanced group is rejected");
102        assert!(matches!(err, RegexCompileError::Compile(_)));
103    }
104
105    #[test]
106    fn enforces_size_limit_without_panicking() {
107        // Short enough to pass the length gate, but its compiled program far
108        // exceeds SIZE_LIMIT — the cap must fire as a Compile error, not a
109        // panic or an unbounded allocation.
110        let err = compile_bounded(r"(?:a{10000}){10000}")
111            .expect_err("oversized compiled program is rejected");
112        assert!(matches!(err, RegexCompileError::Compile(_)));
113    }
114
115    #[test]
116    fn limit_constants_match_documented_values() {
117        assert_eq!(MAX_PATTERN_LEN, 1024);
118        assert_eq!(SIZE_LIMIT, 1 << 20);
119        assert_eq!(DFA_SIZE_LIMIT, 8 << 20);
120    }
121}