use std::sync::LazyLock;
use fancy_regex::{Captures, Regex, RegexBuilder};
use serde::Serialize;
use super::format::Language;
use super::heuristics::{self, VALID_FLAGS};
use super::js;
use super::position::PositionIndex;
use super::redos::{self, ReDoSResult};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct Pattern {
pub(crate) pattern: String,
pub(crate) flags: String,
pub(crate) line: usize,
pub(crate) column: usize,
#[serde(rename = "match")]
pub(crate) matched: String,
pub(crate) redos: ReDoSResult,
}
static LITERAL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(r"/(?:[^/\r\n\\]|\\.)+/[{VALID_FLAGS}]*"))
.expect("a constant pattern compiles")
});
const BACKTRACK_LIMIT: usize = 100_000_000;
static CONSTRUCTOR: LazyLock<Regex> = LazyLock::new(|| {
build(&format!(
r#"(?<![.\w$])(?:new[{SPACE}]+)?RegExp[{SPACE}]*\([{SPACE}]*(?:'(?<sq>(?:[^'\\\r\n]|\\.)*)'|"(?<dq>(?:[^"\\\r\n]|\\.)*)")[{SPACE}]*(?:,[{SPACE}]*(?:'(?<sqf>[{VALID_FLAGS}]*)'|"(?<dqf>[{VALID_FLAGS}]*)")[{SPACE}]*)?,?[{SPACE}]*\)"#
))
});
fn build(source: &str) -> Regex {
RegexBuilder::new(source)
.backtrack_limit(BACKTRACK_LIMIT)
.build()
.expect("a constant pattern compiles")
}
const SPACE: &str = js::JS_SPACE_CLASS;
const DOUBLE_QUOTED: &str = r#"(?:[^"\\\r\n]|\\.)*"#;
const SINGLE_QUOTED: &str = r"(?:[^'\\\r\n]|\\.)*";
const RAW_GROUPS: [&str; 4] = ["raw1", "raw2", "raw3", "raw4"];
const ESCAPED_GROUPS: [&str; 4] = ["esc1", "esc2", "esc3", "esc4"];
#[derive(Clone, Copy)]
struct CallForm {
name: &'static str,
matcher: &'static LazyLock<Regex>,
delimited: bool,
}
static PYTHON: LazyLock<Regex> = LazyLock::new(|| {
build(&format!(
r#"(?<![A-Za-z0-9_$.])re\.(?:compile|fullmatch|finditer|findall|match|search|split|subn|sub)[{SPACE}]*\([{SPACE}]*(?:[rR][bB]?"""(?<raw1>[\s\S]*?)"""|[rR][bB]?'''(?<raw2>[\s\S]*?)'''|[rR][bB]?"(?<raw3>{DOUBLE_QUOTED})"|[rR][bB]?'(?<raw4>{SINGLE_QUOTED})'|"""(?<esc1>[\s\S]*?)"""|'''(?<esc2>[\s\S]*?)'''|"(?<esc3>{DOUBLE_QUOTED})"|'(?<esc4>{SINGLE_QUOTED})')"#
))
});
static RUST: LazyLock<Regex> = LazyLock::new(|| {
build(&format!(
r#"(?<![A-Za-z0-9_$])(?:Regex|RegexBuilder)::new[{SPACE}]*\([{SPACE}]*(?:r(?<hash>#*)"(?<raw1>[\s\S]*?)"\k<hash>|"(?<esc1>{DOUBLE_QUOTED})")"#
))
});
static GO: LazyLock<Regex> = LazyLock::new(|| {
build(&format!(
r#"(?<![A-Za-z0-9_$.])regexp\.(?:MustCompilePOSIX|MustCompile|CompilePOSIX|Compile)[{SPACE}]*\([{SPACE}]*(?:`(?<raw1>[^`]*)`|"(?<esc1>{DOUBLE_QUOTED})")"#
))
});
static JAVA: LazyLock<Regex> = LazyLock::new(|| {
build(&format!(
r#"(?<![A-Za-z0-9_$])Pattern\.(?:compile|matches)[{SPACE}]*\([{SPACE}]*"(?<esc1>{DOUBLE_QUOTED})""#
))
});
static RUBY: LazyLock<Regex> = LazyLock::new(|| {
build(&format!(
r#"(?<![A-Za-z0-9_$])Regexp\.(?:new|compile)[{SPACE}]*\([{SPACE}]*(?:"(?<esc1>{DOUBLE_QUOTED})"|'(?<esc2>{SINGLE_QUOTED})')"#
))
});
static PHP: LazyLock<Regex> = LazyLock::new(|| {
build(&format!(
r#"(?<![A-Za-z0-9_$])preg_(?:match_all|match|replace_callback|replace|split|grep)[{SPACE}]*\([{SPACE}]*(?:'(?<esc1>{SINGLE_QUOTED})'|"(?<esc2>{DOUBLE_QUOTED})")"#
))
});
static CSHARP_NEW: LazyLock<Regex> = LazyLock::new(|| {
build(&format!(
r#"(?<![A-Za-z0-9_$])(?:new[{SPACE}]+)?Regex[{SPACE}]*\([{SPACE}]*(?:@"(?<raw1>(?:[^"]|"")*)"|"(?<esc1>{DOUBLE_QUOTED})")"#
))
});
static CSHARP_STATIC: LazyLock<Regex> = LazyLock::new(|| {
build(&format!(
r#"(?<![A-Za-z0-9_$])Regex\.(?:Matches|Match|IsMatch|Replace|Split|Count)[{SPACE}]*\([{SPACE}]*(?:@"(?:[^"]|"")*"|"{DOUBLE_QUOTED}"|[^,()"']*)[{SPACE}]*,[{SPACE}]*(?:@"(?<raw1>(?:[^"]|"")*)"|"(?<esc1>{DOUBLE_QUOTED})")"#
))
});
const PYTHON_FORM: CallForm = CallForm {
name: "python",
matcher: &PYTHON,
delimited: false,
};
const RUST_FORM: CallForm = CallForm {
name: "rust",
matcher: &RUST,
delimited: false,
};
const GO_FORM: CallForm = CallForm {
name: "go",
matcher: &GO,
delimited: false,
};
const JAVA_FORM: CallForm = CallForm {
name: "java",
matcher: &JAVA,
delimited: false,
};
const RUBY_FORM: CallForm = CallForm {
name: "ruby",
matcher: &RUBY,
delimited: false,
};
const PHP_FORM: CallForm = CallForm {
name: "php",
matcher: &PHP,
delimited: true,
};
const CSHARP_FORMS: [CallForm; 2] = [
CallForm {
name: "csharp",
matcher: &CSHARP_NEW,
delimited: false,
},
CallForm {
name: "csharp",
matcher: &CSHARP_STATIC,
delimited: false,
},
];
fn call_forms(language: Option<Language>) -> Vec<CallForm> {
match language {
Some(Language::JavaScript | Language::TypeScript) => Vec::new(),
Some(Language::Python) => vec![PYTHON_FORM],
Some(Language::Rust) => vec![RUST_FORM],
Some(Language::Go) => vec![GO_FORM],
Some(Language::Java) => vec![JAVA_FORM],
Some(Language::Ruby) => vec![RUBY_FORM],
Some(Language::Php) => vec![PHP_FORM],
Some(Language::CSharp) => CSHARP_FORMS.to_vec(),
None => [
PYTHON_FORM,
RUST_FORM,
GO_FORM,
JAVA_FORM,
RUBY_FORM,
PHP_FORM,
]
.into_iter()
.chain(CSHARP_FORMS)
.collect(),
}
}
fn unescape_string_literal(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(character) = chars.next() {
if character == '\\' && matches!(chars.peek(), Some('\\' | '\'' | '"')) {
out.push(chars.next().expect("peeked"));
continue;
}
out.push(character);
}
out
}
pub(crate) fn extract_patterns(
text: &str,
language: Option<Language>,
) -> Result<Vec<Pattern>, String> {
let index = PositionIndex::new(text);
let mut patterns: Vec<Pattern> = Vec::new();
let mut seen: Vec<String> = Vec::new();
let mut push = |pattern: String, flags: String, offset: usize, matched: String| {
let key = format!("{pattern}::{flags}");
if seen.contains(&key) {
return;
}
seen.push(key);
let position = index.at(offset);
patterns.push(Pattern {
redos: redos::detect_redos(&pattern, &flags),
pattern,
flags,
line: position.line,
column: position.column,
matched,
});
};
if language.is_none_or(Language::has_slash_literals) {
scan_literals(text, &mut push)?;
}
if language
.is_none_or(|language| matches!(language, Language::JavaScript | Language::TypeScript))
{
scan_constructors(text, &mut push)?;
}
for form in call_forms(language) {
scan_call_form(form, text, &mut push)?;
}
patterns.sort_by(|a, b| a.line.cmp(&b.line).then(a.column.cmp(&b.column)));
Ok(patterns)
}
type Push<'a> = dyn FnMut(String, String, usize, String) + 'a;
fn scan_literals(text: &str, push: &mut Push<'_>) -> Result<(), String> {
for found in LITERAL.find_iter(text) {
let found = found.map_err(|error| format!("the literal pattern gave up: {error}"))?;
let full = found.as_str();
let last = full.rfind('/').expect("a literal has a closing slash");
let body = &full[1..last];
let flags = &full[last + 1..];
if !heuristics::is_regex_context(text, found.start()) {
continue;
}
if !heuristics::is_valid_flag_string(flags) || !heuristics::compiles(body, flags) {
continue;
}
push(
body.to_string(),
flags.to_string(),
found.start(),
full.to_string(),
);
}
Ok(())
}
fn too_complex(form: &str) -> String {
format!(
"the {form} pattern is nested past {} groups or carries more than {} alternation \
branches, which cannot be judged without overflowing the stack",
heuristics::MAX_GROUP_DEPTH,
heuristics::MAX_ALTERNATION_BRANCHES
)
}
fn scan_constructors(text: &str, push: &mut Push<'_>) -> Result<(), String> {
for captures in CONSTRUCTOR.captures_iter(text) {
let captures =
captures.map_err(|error| format!("the constructor pattern gave up: {error}"))?;
let body = captures
.name("sq")
.or_else(|| captures.name("dq"))
.map(|found| found.as_str())
.unwrap_or_default();
if body.is_empty() {
continue;
}
let flags = captures
.name("sqf")
.or_else(|| captures.name("dqf"))
.map(|found| found.as_str())
.unwrap_or_default();
let pattern = unescape_string_literal(body);
if !heuristics::is_within_parser_limits(&pattern) {
return Err(too_complex("constructor"));
}
if !heuristics::compiles(&pattern, flags) {
continue;
}
let whole = captures.get(0).expect("a match has group zero");
push(
pattern,
flags.to_string(),
whole.start(),
whole.as_str().to_string(),
);
}
Ok(())
}
fn scan_call_form(form: CallForm, text: &str, push: &mut Push<'_>) -> Result<(), String> {
for captures in form.matcher.captures_iter(text) {
let captures =
captures.map_err(|error| format!("the {} pattern gave up: {error}", form.name))?;
let Some(body) = call_body(&captures) else {
continue;
};
let candidate = if form.delimited {
strip_php_delimiters(&body)
} else {
Some(body)
};
let Some(pattern) = candidate else {
continue;
};
if !heuristics::is_within_parser_limits(&pattern) {
return Err(too_complex(form.name));
}
if pattern.is_empty() || !heuristics::is_well_formed(&pattern, "") {
continue;
}
let whole = captures.get(0).expect("a match has group zero");
push(
pattern,
String::new(),
whole.start(),
whole.as_str().to_string(),
);
}
Ok(())
}
fn call_body(captures: &Captures<'_, str>) -> Option<String> {
if let Some(found) = RAW_GROUPS.iter().find_map(|name| captures.name(name)) {
return Some(found.as_str().to_string());
}
let found = ESCAPED_GROUPS.iter().find_map(|name| captures.name(name))?;
Some(unescape_string_literal(found.as_str()))
}
const PHP_MODIFIERS: &str = "imsxeADSUXJnu";
fn strip_php_delimiters(value: &str) -> Option<String> {
let open = value.chars().next()?;
if open.is_ascii_alphanumeric() || open == '\\' || js::is_js_whitespace(open) {
return None;
}
let close = match open {
'(' => ')',
'{' => '}',
'[' => ']',
'<' => '>',
same => same,
};
let end = value.rfind(close).filter(|at| *at > 0)?;
let modifiers = &value[end + close.len_utf8()..];
if !modifiers
.chars()
.all(|letter| PHP_MODIFIERS.contains(letter))
{
return None;
}
Some(value[open.len_utf8()..end].to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn values_in(text: &str, language: Option<Language>) -> Vec<(String, String)> {
extract_patterns(text, language)
.expect("the patterns hold")
.into_iter()
.map(|found| (found.pattern, found.flags))
.collect()
}
fn values(text: &str) -> Vec<(String, String)> {
values_in(text, None)
}
fn patterns_in(text: &str, language: Language) -> Vec<String> {
values_in(text, Some(language))
.into_iter()
.map(|(pattern, _)| pattern)
.collect()
}
#[test]
fn a_literal_is_found_with_its_flags() {
assert_eq!(values("const re = /a+b/gi;"), [("a+b".into(), "gi".into())]);
}
#[test]
fn a_constructor_is_found() {
assert_eq!(
values("new RegExp('a+b', 'g')"),
[("a+b".into(), "g".into())]
);
assert_eq!(values(r#"RegExp("x")"#), [("x".into(), String::new())]);
}
#[test]
fn a_constructor_unescapes_one_level() {
assert_eq!(
values(r"new RegExp('\\d+')"),
[(r"\d+".into(), String::new())]
);
}
#[test]
fn a_constructor_split_across_lines_is_found() {
assert_eq!(
values("new RegExp(\n 'a+',\n 'g',\n)"),
[("a+".into(), "g".into())]
);
}
#[test]
fn a_member_or_prefixed_identifier_is_not_a_constructor() {
assert!(values("foo.RegExp('a')").is_empty());
assert!(values("myRegExp('a')").is_empty());
}
#[test]
fn a_division_is_not_a_regex() {
assert!(values("const x = a / b / c;").is_empty());
assert!(values("const url = 'https://x/y';").is_empty());
}
#[test]
fn an_invalid_flag_string_is_not_a_literal() {
assert!(values("/a/gg").is_empty(), "a repeated flag");
}
#[test]
fn an_uncompilable_literal_is_skipped() {
assert!(values("const re = /a{2,1}/;").is_empty());
}
#[test]
fn a_repeated_pattern_is_reported_once_at_its_first_occurrence() {
let found = extract_patterns("const a = /x+/g;\nconst b = /x+/g;\n", None)
.expect("the patterns hold");
assert_eq!(found.len(), 1);
assert_eq!(found[0].line, 1);
}
#[test]
fn flags_are_part_of_the_identity() {
assert_eq!(values("const a = /x+/g;\nconst b = /x+/i;\n").len(), 2);
}
#[test]
fn results_come_back_in_document_order() {
let found = extract_patterns("new RegExp('z+')\nconst a = /a+/;\n", None)
.expect("the patterns hold");
assert_eq!(found[0].line, 1);
assert_eq!(found[1].line, 2);
}
#[test]
fn the_verdict_travels_with_the_pattern() {
let found = extract_patterns("const re = /(a+)+/;", None).expect("the patterns hold");
assert!(found[0].redos.detected);
assert_eq!(found[0].redos.severity, super::redos::Severity::High);
}
#[test]
fn a_long_single_line_document_is_scanned_rather_than_refused() {
let content = "const a = /x/g; const b = total / count; ".repeat(20_000);
let patterns = extract_patterns(&content, None).expect("a scan, not a refusal");
assert_eq!(patterns.len(), 1, "collapsed to its first occurrence");
assert_eq!(patterns[0].pattern, "x");
}
#[test]
fn the_exponential_shape_is_found_in_every_language() {
for (language, text) in [
(Language::Python, r#"BAD = re.compile(r"(a+)+")"#),
(Language::Rust, r#"let bad = Regex::new(r"(a+)+");"#),
(Language::Go, "var bad = regexp.MustCompile(`(a+)+`)"),
(Language::Java, r#"Pattern.compile("(a+)+");"#),
(Language::Ruby, "BAD = /(a+)+/"),
(Language::Php, "preg_match('/(a+)+/', $s);"),
(Language::CSharp, r#"var bad = new Regex(@"(a+)+");"#),
] {
let found = extract_patterns(text, Some(language)).expect("the patterns hold");
assert_eq!(found.len(), 1, "{language:?}");
assert_eq!(found[0].pattern, "(a+)+", "{language:?}");
assert_eq!(
found[0].redos.severity,
super::redos::Severity::High,
"{language:?}"
);
}
}
#[test]
fn a_raw_string_is_verbatim_and_a_quoted_one_escapes() {
assert_eq!(
patterns_in(r#"re.compile(r"\d+")"#, Language::Python),
[r"\d+"]
);
assert_eq!(
patterns_in(r#"re.compile("\\d+")"#, Language::Python),
[r"\d+"]
);
assert_eq!(
patterns_in(r#"Regex::new("\\d+")"#, Language::Rust),
[r"\d+"]
);
assert_eq!(
patterns_in(r#"regexp.Compile("\\d+")"#, Language::Go),
[r"\d+"]
);
}
#[test]
fn a_rust_raw_string_closes_on_its_own_hashes() {
assert_eq!(
patterns_in(r##"Regex::new(r#"a"b+"#)"##, Language::Rust),
[r#"a"b+"#]
);
assert_eq!(
patterns_in(r###"Regex::new(r##"a"#b+"##)"###, Language::Rust),
[r##"a"#b+"##]
);
}
#[test]
fn python_reads_its_string_forms() {
assert_eq!(
patterns_in(r"re.search(r'^[a-z]+$', s)", Language::Python),
["^[a-z]+$"]
);
assert_eq!(
patterns_in(r#"re.sub("""a|ab""", "", s)"#, Language::Python),
["a|ab"]
);
assert!(patterns_in("score.compile(r'x')", Language::Python).is_empty());
}
#[test]
fn php_delimiters_are_stripped_and_modifiers_dropped() {
assert_eq!(
values_in("preg_replace('#(a+)+#ix', '', $s);", Some(Language::Php)),
[("(a+)+".to_string(), String::new())]
);
assert_eq!(
patterns_in("preg_split('~\\d+~', $s);", Language::Php),
[r"\d+"]
);
assert_eq!(
patterns_in("preg_match('{^[a-z]+$}', $s);", Language::Php),
["^[a-z]+$"]
);
assert!(
strip_php_delimiters("abc").is_none(),
"a letter is not a delimiter PHP accepts"
);
assert!(strip_php_delimiters("/a+").is_none(), "never closed");
}
#[test]
fn csharp_reads_the_pattern_argument_not_the_subject() {
assert_eq!(
patterns_in(r#"Regex.IsMatch(input, @"(a+)+")"#, Language::CSharp),
["(a+)+"]
);
assert_eq!(
patterns_in(r#"Regex.Replace("abc", "\\s+", "")"#, Language::CSharp),
[r"\s+"]
);
}
#[test]
fn a_path_in_python_is_not_a_pattern() {
let text = "#!/usr/bin/env python\nROOT = \"/var/log/app.log\"\n";
assert!(patterns_in(text, Language::Python).is_empty());
assert!(
!values(text).is_empty(),
"with no language the slash walk still runs, as it always did"
);
}
#[test]
fn ruby_has_both_a_literal_and_a_constructor() {
assert_eq!(patterns_in("BAD = /(a+)+/", Language::Ruby), ["(a+)+"]);
assert_eq!(
patterns_in("BUILT = Regexp.new('(a|ab)+')", Language::Ruby),
["(a|ab)+"]
);
assert!(
patterns_in("ratio = a / b / 2", Language::Ruby).is_empty(),
"division is division in Ruby too"
);
}
#[test]
fn a_language_scans_only_its_own_forms() {
assert!(patterns_in("new RegExp('a+b', 'g')", Language::Python).is_empty());
assert!(patterns_in(r#"re.compile(r"(a+)+")"#, Language::Go).is_empty());
}
#[test]
fn a_pattern_javascript_cannot_parse_is_still_read() {
let found = extract_patterns(r#"re.compile(r"(?P<w>\w+)+@")"#, Some(Language::Python))
.expect("the patterns hold");
assert_eq!(found.len(), 1);
assert_eq!(found[0].pattern, r"(?P<w>\w+)+@", "reported as written");
assert_eq!(found[0].redos.severity, super::redos::Severity::High);
}
#[test]
fn a_pattern_too_deep_to_parse_is_refused_rather_than_aborted() {
let deep = format!("{}a{}", "(".repeat(20_000), ")".repeat(20_000));
let error = extract_patterns(&format!(r#"re.compile(r"{deep}")"#), Some(Language::Python))
.expect_err("a refusal");
assert!(error.contains("python"), "{error}");
assert!(error.contains("nested past"), "{error}");
let error =
extract_patterns(&format!("new RegExp('{deep}')"), None).expect_err("a refusal");
assert!(error.contains("constructor"), "{error}");
}
#[test]
fn a_slash_literal_too_deep_to_parse_is_dropped_not_refused() {
let deep = format!("{}a{}", "(".repeat(20_000), ")".repeat(20_000));
let found = extract_patterns(&format!("const p = /{deep}/;\nconst q = /(a+)+/;\n"), None)
.expect("the rest of the document still answers");
assert_eq!(found.len(), 1);
assert_eq!(found[0].pattern, "(a+)+");
}
#[test]
fn a_broken_pattern_is_still_dropped() {
assert!(patterns_in(r#"re.compile(r"a{2,1}")"#, Language::Python).is_empty());
assert!(patterns_in(r#"re.compile(r"")"#, Language::Python).is_empty());
}
}