rtb-app 0.7.0

Rust Tool Base — application context, tool metadata, runtime features, and the Command plugin trait.
Documentation
//! Bounded compilation of untrusted regular expressions.
//!
//! Per CLAUDE.md §Regex Compilation, any `regex::Regex` whose pattern
//! originates *outside* the binary (config file, CLI flag, TUI input, HTTP
//! payload, message queue) must be compiled through [`compile_bounded`],
//! which caps compile-time memory and rejects over-long patterns. Literal,
//! build-time patterns may use `regex::Regex::new` directly (optionally
//! `once_cell::sync::Lazy<Regex>`).
//!
//! Unlike GTB's Go counterpart, no match-time timeout is needed: Rust's
//! `regex` is a Thompson NFA with linear-time matching, so there is no
//! catastrophic-backtracking class to defend against. The caps bound
//! *compile-time* memory — the only remaining denial-of-service vector.
//!
//! See `docs/development/specs/2026-06-26-rtb-app-regex-util-compile-bounded.md`.

use regex::RegexBuilder;

/// Maximum byte length of an externally-sourced pattern.
pub const MAX_PATTERN_LEN: usize = 1024; // 1 KiB

/// `RegexBuilder::size_limit` — compiled-program memory cap.
pub const SIZE_LIMIT: usize = 1 << 20; // 1 MiB

/// `RegexBuilder::dfa_size_limit` — lazy-DFA cache cap.
pub const DFA_SIZE_LIMIT: usize = 8 << 20; // 8 MiB

/// Error returned when an untrusted pattern is rejected or fails to compile
/// within the configured memory bounds.
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum RegexCompileError {
    /// The pattern exceeds [`MAX_PATTERN_LEN`].
    #[error("regex pattern is {len} bytes; limit is {} bytes", MAX_PATTERN_LEN)]
    #[diagnostic(code(rtb_app::regex::too_long))]
    TooLong {
        /// Actual pattern length, in bytes.
        len: usize,
    },

    /// The pattern was syntactically invalid or would exceed [`SIZE_LIMIT`]
    /// / [`DFA_SIZE_LIMIT`] when compiled.
    #[error("regex failed to compile within memory bounds")]
    #[diagnostic(
        code(rtb_app::regex::compile),
        help("simplify the pattern or reduce repetition counts")
    )]
    Compile(#[source] regex::Error),
}

/// Compile an untrusted `pattern` under fixed memory bounds.
///
/// The length gate is checked *before* the pattern reaches the regex
/// engine, so an over-long pattern is rejected cheaply.
///
/// # Errors
///
/// Returns [`RegexCompileError::TooLong`] if `pattern` exceeds
/// [`MAX_PATTERN_LEN`] bytes, or [`RegexCompileError::Compile`] if it is
/// syntactically invalid or would exceed the compile-time memory bounds.
pub fn compile_bounded(pattern: &str) -> Result<regex::Regex, RegexCompileError> {
    if pattern.len() > MAX_PATTERN_LEN {
        return Err(RegexCompileError::TooLong { len: pattern.len() });
    }
    RegexBuilder::new(pattern)
        .size_limit(SIZE_LIMIT)
        .dfa_size_limit(DFA_SIZE_LIMIT)
        .build()
        .map_err(RegexCompileError::Compile)
}

#[cfg(test)]
mod tests {
    use super::{compile_bounded, RegexCompileError, DFA_SIZE_LIMIT, MAX_PATTERN_LEN, SIZE_LIMIT};

    #[test]
    fn compiles_a_valid_pattern() {
        let re = compile_bounded(r"^\d{3}-\d{4}$").expect("valid pattern compiles");
        assert!(re.is_match("123-4567"));
        assert!(!re.is_match("nope"));
    }

    #[test]
    fn rejects_overlong_pattern() {
        let pattern = "a".repeat(MAX_PATTERN_LEN + 1);
        let err = compile_bounded(&pattern).expect_err("over-long pattern is rejected");
        assert!(matches!(err, RegexCompileError::TooLong { len } if len == MAX_PATTERN_LEN + 1));
    }

    #[test]
    fn accepts_pattern_at_exact_limit() {
        // A pattern of exactly MAX_PATTERN_LEN bytes passes the length gate
        // and compiles (1024 literal 'a's is a tiny program).
        let pattern = "a".repeat(MAX_PATTERN_LEN);
        let re = compile_bounded(&pattern).expect("at-limit pattern compiles");
        assert!(re.is_match(&"a".repeat(MAX_PATTERN_LEN)));
    }

    #[test]
    fn rejects_syntactically_invalid_pattern() {
        let err = compile_bounded("(").expect_err("unbalanced group is rejected");
        assert!(matches!(err, RegexCompileError::Compile(_)));
    }

    #[test]
    fn enforces_size_limit_without_panicking() {
        // Short enough to pass the length gate, but its compiled program far
        // exceeds SIZE_LIMIT — the cap must fire as a Compile error, not a
        // panic or an unbounded allocation.
        let err = compile_bounded(r"(?:a{10000}){10000}")
            .expect_err("oversized compiled program is rejected");
        assert!(matches!(err, RegexCompileError::Compile(_)));
    }

    #[test]
    fn limit_constants_match_documented_values() {
        assert_eq!(MAX_PATTERN_LEN, 1024);
        assert_eq!(SIZE_LIMIT, 1 << 20);
        assert_eq!(DFA_SIZE_LIMIT, 8 << 20);
    }
}