use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use serde::{Deserialize, Serialize};
use super::{ambiguity, heuristics};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Severity {
Low,
Medium,
High,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct ReDoSResult {
pub(crate) detected: bool,
pub(crate) severity: Severity,
pub(crate) reason: String,
#[serde(rename = "vulnerableGroups", skip_serializing_if = "Option::is_none")]
pub(crate) vulnerable_groups: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) witness: Option<String>,
}
static DECIDED: LazyLock<Mutex<HashMap<(String, String), ReDoSResult>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
const CACHE_CEILING: usize = 4_096;
pub(crate) fn detect_redos(pattern: &str, flags: &str) -> ReDoSResult {
let key = (pattern.to_string(), flags.to_string());
if let Ok(cache) = DECIDED.lock()
&& let Some(hit) = cache.get(&key)
{
return hit.clone();
}
let answer = decide_uncached(pattern, flags);
if let Ok(mut cache) = DECIDED.lock()
&& cache.len() < CACHE_CEILING
{
cache.insert(key, answer.clone());
}
answer
}
fn decide_uncached(pattern: &str, flags: &str) -> ReDoSResult {
if !heuristics::is_well_formed(pattern, flags) {
return ReDoSResult {
detected: false,
severity: Severity::Low,
reason: "Pattern is invalid".to_string(),
vulnerable_groups: None,
witness: None,
};
}
match ambiguity::decide(pattern) {
Ok(Some(blowup)) => ReDoSResult {
detected: true,
severity: Severity::High,
reason: format!(
"exponential backtracking: {} steps on {} characters, against {} on {}",
blowup.high,
blowup.witness.chars().count(),
blowup.low,
blowup.witness.chars().count() / 2,
),
vulnerable_groups: None,
witness: Some(blowup.witness),
},
Ok(None) => ReDoSResult {
detected: false,
severity: Severity::Low,
reason: "no input was found that drives this into backtracking".to_string(),
vulnerable_groups: None,
witness: None,
},
Err(undecidable) => ReDoSResult {
detected: false,
severity: Severity::Low,
reason: format!("not decided: {}", undecidable.reason()),
vulnerable_groups: None,
witness: None,
},
}
}
#[cfg(test)]
mod tests {
use super::{Severity, detect_redos};
#[test]
fn a_finding_is_demonstrated_and_a_safe_pattern_is_not_flagged() {
let bad = detect_redos(r"(.*a){20}", "");
assert!(bad.detected, "{bad:?}");
assert_eq!(bad.severity, Severity::High);
assert!(bad.witness.is_some(), "a finding carries its input");
let safe = detect_redos(r"^\w+(?:\.\w+)*$", "");
assert!(!safe.detected, "{safe:?}");
assert!(safe.witness.is_none());
}
#[test]
fn what_cannot_be_decided_says_so() {
let result = detect_redos(r"(a)\1+", "");
assert!(!result.detected);
assert!(result.reason.starts_with("not decided:"), "{result:?}");
}
#[test]
fn an_invalid_pattern_is_not_a_finding() {
let result = detect_redos("(", "");
assert!(!result.detected);
assert_eq!(result.reason, "Pattern is invalid");
}
#[test]
fn the_reason_states_the_cost() {
let result = detect_redos(r"(a+)+b", "");
assert!(result.reason.contains("steps"), "{}", result.reason);
}
}