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}