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 `docs/development/specs/2026-06-26-rtb-app-regex-util-compile-bounded.md`.
16
17use regex::RegexBuilder;
18
19/// Maximum byte length of an externally-sourced pattern.
20pub const MAX_PATTERN_LEN: usize = 1024; // 1 KiB
21
22/// `RegexBuilder::size_limit` — compiled-program memory cap.
23pub const SIZE_LIMIT: usize = 1 << 20; // 1 MiB
24
25/// `RegexBuilder::dfa_size_limit` — lazy-DFA cache cap.
26pub const DFA_SIZE_LIMIT: usize = 8 << 20; // 8 MiB
27
28/// Error returned when an untrusted pattern is rejected or fails to compile
29/// within the configured memory bounds.
30#[derive(Debug, thiserror::Error, miette::Diagnostic)]
31pub enum RegexCompileError {
32    /// The pattern exceeds [`MAX_PATTERN_LEN`].
33    #[error("regex pattern is {len} bytes; limit is {} bytes", MAX_PATTERN_LEN)]
34    #[diagnostic(code(rtb_app::regex::too_long))]
35    TooLong {
36        /// Actual pattern length, in bytes.
37        len: usize,
38    },
39
40    /// The pattern was syntactically invalid or would exceed [`SIZE_LIMIT`]
41    /// / [`DFA_SIZE_LIMIT`] when compiled.
42    #[error("regex failed to compile within memory bounds")]
43    #[diagnostic(
44        code(rtb_app::regex::compile),
45        help("simplify the pattern or reduce repetition counts")
46    )]
47    Compile(#[source] regex::Error),
48}
49
50/// Compile an untrusted `pattern` under fixed memory bounds.
51///
52/// The length gate is checked *before* the pattern reaches the regex
53/// engine, so an over-long pattern is rejected cheaply.
54///
55/// # Errors
56///
57/// Returns [`RegexCompileError::TooLong`] if `pattern` exceeds
58/// [`MAX_PATTERN_LEN`] bytes, or [`RegexCompileError::Compile`] if it is
59/// syntactically invalid or would exceed the compile-time memory bounds.
60pub fn compile_bounded(pattern: &str) -> Result<regex::Regex, RegexCompileError> {
61    if pattern.len() > MAX_PATTERN_LEN {
62        return Err(RegexCompileError::TooLong { len: pattern.len() });
63    }
64    RegexBuilder::new(pattern)
65        .size_limit(SIZE_LIMIT)
66        .dfa_size_limit(DFA_SIZE_LIMIT)
67        .build()
68        .map_err(RegexCompileError::Compile)
69}
70
71#[cfg(test)]
72mod tests {
73    use super::{compile_bounded, RegexCompileError, DFA_SIZE_LIMIT, MAX_PATTERN_LEN, SIZE_LIMIT};
74
75    #[test]
76    fn compiles_a_valid_pattern() {
77        let re = compile_bounded(r"^\d{3}-\d{4}$").expect("valid pattern compiles");
78        assert!(re.is_match("123-4567"));
79        assert!(!re.is_match("nope"));
80    }
81
82    #[test]
83    fn rejects_overlong_pattern() {
84        let pattern = "a".repeat(MAX_PATTERN_LEN + 1);
85        let err = compile_bounded(&pattern).expect_err("over-long pattern is rejected");
86        assert!(matches!(err, RegexCompileError::TooLong { len } if len == MAX_PATTERN_LEN + 1));
87    }
88
89    #[test]
90    fn accepts_pattern_at_exact_limit() {
91        // A pattern of exactly MAX_PATTERN_LEN bytes passes the length gate
92        // and compiles (1024 literal 'a's is a tiny program).
93        let pattern = "a".repeat(MAX_PATTERN_LEN);
94        let re = compile_bounded(&pattern).expect("at-limit pattern compiles");
95        assert!(re.is_match(&"a".repeat(MAX_PATTERN_LEN)));
96    }
97
98    #[test]
99    fn rejects_syntactically_invalid_pattern() {
100        let err = compile_bounded("(").expect_err("unbalanced group is rejected");
101        assert!(matches!(err, RegexCompileError::Compile(_)));
102    }
103
104    #[test]
105    fn enforces_size_limit_without_panicking() {
106        // Short enough to pass the length gate, but its compiled program far
107        // exceeds SIZE_LIMIT — the cap must fire as a Compile error, not a
108        // panic or an unbounded allocation.
109        let err = compile_bounded(r"(?:a{10000}){10000}")
110            .expect_err("oversized compiled program is rejected");
111        assert!(matches!(err, RegexCompileError::Compile(_)));
112    }
113
114    #[test]
115    fn limit_constants_match_documented_values() {
116        assert_eq!(MAX_PATTERN_LEN, 1024);
117        assert_eq!(SIZE_LIMIT, 1 << 20);
118        assert_eq!(DFA_SIZE_LIMIT, 8 << 20);
119    }
120}