use std::num::NonZeroUsize;
use lru::LruCache;
use regex::Regex;
const DANGEROUS_PATTERNS: &[&str] = &[
r"\(\.\*\)\+",
r"\(\.\+\)\+",
r"\([^)]*\*\)\+",
r"\([^)]*\+\)\+",
r"(?:\.\*){2,}",
r"(?:\.\+){2,}",
];
pub struct PatternCache {
cache: LruCache<String, Regex>,
}
impl PatternCache {
#[must_use]
pub fn new(capacity: usize) -> Self {
let cap = capacity.clamp(1, 5000);
Self {
cache: LruCache::new(NonZeroUsize::new(cap).expect("clamped to >= 1")),
}
}
pub fn get_or_compile(&mut self, pattern: &str) -> Result<&Regex, regex::Error> {
self.cache
.try_get_or_insert_mut(pattern.to_owned(), || {
Regex::new(&format!("(?im){pattern}"))
})
.map(|r| &*r)
}
pub fn clear(&mut self) {
self.cache.clear();
}
#[must_use]
pub fn len(&self) -> usize {
self.cache.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.cache.is_empty()
}
}
pub fn compile(pattern: &str) -> Result<Regex, regex::Error> {
Regex::new(&format!("(?im){pattern}"))
}
#[must_use]
pub fn validate_pattern_safety(pattern: &str) -> (bool, &'static str) {
for &dangerous in DANGEROUS_PATTERNS {
if let Ok(checker) = Regex::new(dangerous)
&& checker.is_match(pattern)
{
return (false, dangerous);
}
}
(true, "pattern appears safe")
}
#[must_use]
pub fn batch_compile(patterns: &[&str], validate: bool) -> Vec<(String, Regex)> {
patterns
.iter()
.filter_map(|&pat| {
if validate && !validate_pattern_safety(pat).0 {
return None;
}
compile(pat).ok().map(|re| (pat.to_owned(), re))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cache_basic_operations() {
let mut cache = PatternCache::new(10);
assert!(cache.is_empty());
let re = cache.get_or_compile(r"\d+").unwrap();
assert!(re.is_match("123"));
assert_eq!(cache.len(), 1);
let r1 = cache.get_or_compile(r"\d+").unwrap().as_str().to_owned();
let r2 = cache.get_or_compile(r"\d+").unwrap().as_str().to_owned();
assert_eq!(r1, r2);
assert_eq!(cache.len(), 1);
cache.clear();
assert!(cache.is_empty());
}
#[test]
fn cache_eviction_and_lru_order() {
let mut cache = PatternCache::new(2);
cache.get_or_compile("aaa").unwrap();
cache.get_or_compile("bbb").unwrap();
cache.get_or_compile("ccc").unwrap();
assert_eq!(cache.len(), 2);
let mut cache = PatternCache::new(3);
for i in 0..3 {
cache.get_or_compile(&format!("pattern_{i}")).unwrap();
}
let _ = cache.get_or_compile("pattern_0").unwrap();
cache.get_or_compile("pattern_new").unwrap();
assert_eq!(cache.len(), 3);
}
#[test]
fn cache_capacity_clamping() {
assert_eq!(PatternCache::new(0).cache.cap().get(), 1);
assert_eq!(PatternCache::new(99999).cache.cap().get(), 5000);
}
#[test]
fn dangerous_patterns_detected() {
for pat in [
r"(.*)+",
r"(.+)+",
r"([a-z]*)+",
r"([a-z]+)+",
r".*.*",
r".+.+",
] {
let (safe, _) = validate_pattern_safety(pat);
assert!(!safe, "should flag: {pat}");
}
}
#[test]
fn safe_patterns_pass() {
let (safe, msg) = validate_pattern_safety(r"test\d+");
assert!(safe);
assert_eq!(msg, "pattern appears safe");
for pat in [r"<script[^>]*>", r"\d{3}-\d{3}-\d{4}", r"[a-zA-Z0-9]+"] {
assert!(validate_pattern_safety(pat).0, "should pass: {pat}");
}
}
#[test]
fn compile_flags_and_errors() {
assert!(compile(r"select").unwrap().is_match("SELECT"));
assert!(
compile(r"^hello")
.unwrap()
.is_match("first line\nhello world")
);
assert!(compile(r"invalid(pattern").is_err());
}
#[test]
fn batch_compile_filters_invalid_and_dangerous() {
let results = batch_compile(&[r"\d+", r"invalid(", r"[a-z]+"], false);
assert_eq!(results.len(), 2);
let results = batch_compile(&[r"\d+", r"(.*)+", r"[a-z]+"], true);
assert_eq!(results.len(), 2);
assert!(results.iter().all(|(p, _)| p != "(.*)+"));
assert!(batch_compile(&[r"invalid(", r"[unclosed"], false).is_empty());
}
}