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}