use std::env;
use std::process::Command;
use std::time::{Duration, Instant};
use regexr::{Regex, RegexBuilder};
const CASE_VAR: &str = "REGEXR_BOUNDED_EXECUTION_CASE";
const DEADLINE: Duration = Duration::from_secs(20);
const CHILD_ADDRESS_SPACE_KIB: u64 = 4_000_000;
fn bounded(case: &str, body: impl FnOnce()) {
bounded_within(case, DEADLINE, body)
}
fn bounded_within(case: &str, deadline: Duration, body: impl FnOnce()) {
if env::var(CASE_VAR).as_deref() == Ok(case) {
body();
return;
}
let exe = env::current_exe().expect("test binary path");
let mut command = if cfg!(unix) {
let mut c = Command::new("sh");
c.arg("-c")
.arg(format!(
"ulimit -v {CHILD_ADDRESS_SPACE_KIB} 2>/dev/null; exec \"$@\""
))
.arg("sh")
.arg(&exe);
c
} else {
Command::new(&exe)
};
let mut child = command
.arg(case)
.arg("--exact")
.arg("--nocapture")
.arg("--test-threads=1")
.env(CASE_VAR, case)
.spawn()
.expect("spawn isolated case");
let started = Instant::now();
loop {
match child.try_wait().expect("poll isolated case") {
Some(status) if status.success() => return,
Some(status) => panic!("{case}: isolated run failed ({status})"),
None if started.elapsed() >= deadline => {
let _ = child.kill();
let _ = child.wait();
panic!("{case}: search did not terminate within {deadline:?}");
}
None => std::thread::sleep(Duration::from_millis(20)),
}
}
}
fn builds(pattern: &str) -> Vec<(&'static str, Regex)> {
vec![
(
"jit",
RegexBuilder::new(pattern)
.jit(true)
.build()
.expect("pattern should compile"),
),
(
"interp",
RegexBuilder::new(pattern)
.jit(false)
.build()
.expect("pattern should compile"),
),
]
}
type Spans = Vec<Option<(usize, usize)>>;
fn capture_spans(re: &Regex, text: &str) -> Option<Spans> {
re.captures(text).map(|caps| {
(0..caps.len())
.map(|i| caps.get(i).map(|m| (m.start(), m.end())))
.collect()
})
}
fn iterated_spans(re: &Regex, text: &str) -> Vec<Spans> {
re.captures_iter(text)
.map(|caps| {
(0..caps.len())
.map(|i| caps.get(i).map(|m| (m.start(), m.end())))
.collect()
})
.collect()
}
fn span(start: usize, end: usize) -> Option<(usize, usize)> {
Some((start, end))
}
#[test]
fn nested_star_over_nullable_body_terminates() {
bounded("nested_star_over_nullable_body_terminates", || {
for (label, re) in builds(r"(a*)*") {
assert_eq!(
capture_spans(&re, "a"),
Some(vec![span(0, 1), span(0, 1)]),
"{label}"
);
assert_eq!(
capture_spans(&re, "aaa"),
Some(vec![span(0, 3), span(0, 3)]),
"{label}"
);
}
});
}
#[test]
fn nested_star_over_nullable_body_matches_empty_at_start() {
bounded(
"nested_star_over_nullable_body_matches_empty_at_start",
|| {
for (label, re) in builds(r"(a*)*") {
assert_eq!(
capture_spans(&re, "b"),
Some(vec![span(0, 0), span(0, 0)]),
"{label}"
);
}
},
);
}
#[test]
fn star_over_optional_body_terminates() {
bounded("star_over_optional_body_terminates", || {
for (label, re) in builds(r"(a?)*") {
assert_eq!(
capture_spans(&re, "a"),
Some(vec![span(0, 1), span(0, 1)]),
"{label}"
);
}
});
}
#[test]
fn plus_over_empty_group_terminates() {
bounded("plus_over_empty_group_terminates", || {
for (label, re) in builds(r"()+") {
assert_eq!(
capture_spans(&re, "a"),
Some(vec![span(0, 0), span(0, 0)]),
"{label}"
);
}
});
}
#[test]
fn plus_over_nullable_body_terminates() {
bounded("plus_over_nullable_body_terminates", || {
for (label, re) in builds(r"(a*)+") {
assert_eq!(
capture_spans(&re, "a"),
Some(vec![span(0, 1), span(0, 1)]),
"{label}"
);
}
});
}
#[test]
fn star_over_bounded_nullable_body_terminates() {
bounded("star_over_bounded_nullable_body_terminates", || {
for (label, re) in builds(r"(a{0,2})*") {
assert_eq!(
capture_spans(&re, "aaa"),
Some(vec![span(0, 3), span(2, 3)]),
"{label}"
);
}
});
}
#[test]
fn nested_capture_inside_nullable_loop_terminates() {
bounded("nested_capture_inside_nullable_loop_terminates", || {
for (label, re) in builds(r"((a)*)*") {
assert_eq!(
capture_spans(&re, "a"),
Some(vec![span(0, 1), span(0, 1), span(0, 1)]),
"{label}"
);
}
});
}
#[test]
fn nullable_loop_followed_by_literal_terminates() {
bounded("nullable_loop_followed_by_literal_terminates", || {
for (label, re) in builds(r"(a*)*b") {
assert_eq!(
capture_spans(&re, "ab"),
Some(vec![span(0, 2), span(0, 1)]),
"{label}"
);
assert_eq!(capture_spans(&re, "a"), None, "{label}");
}
});
}
#[test]
fn iteration_over_nullable_loop_terminates() {
bounded("iteration_over_nullable_loop_terminates", || {
for (label, re) in builds(r"(a*)*") {
assert_eq!(
iterated_spans(&re, "ab"),
vec![vec![span(0, 1), span(0, 1)], vec![span(2, 2), span(2, 2)],],
"{label}"
);
}
});
}
#[test]
fn iteration_over_empty_group_loop_terminates() {
bounded("iteration_over_empty_group_loop_terminates", || {
for (label, re) in builds(r"()+") {
assert_eq!(
iterated_spans(&re, "ab"),
vec![
vec![span(0, 0), span(0, 0)],
vec![span(1, 1), span(1, 1)],
vec![span(2, 2), span(2, 2)],
],
"{label}"
);
}
});
}
#[test]
fn nullable_loop_captures_agree_with_find() {
bounded("nullable_loop_captures_agree_with_find", || {
for pattern in [r"(a*)*", r"(a?)*", r"()+", r"(a*)+"] {
for (label, re) in builds(pattern) {
for text in ["", "a", "aaa", "ab", "b"] {
let found = re.find(text).map(|m| (m.start(), m.end()));
let captured = capture_spans(&re, text).and_then(|s| s[0]);
assert_eq!(found, captured, "{label} {pattern:?} {text:?}");
}
}
}
});
}
#[test]
fn nested_plus_does_not_backtrack_exponentially() {
bounded("nested_plus_does_not_backtrack_exponentially", || {
let text = "a".repeat(30);
for (label, re) in builds(r"(a+)+c") {
assert_eq!(capture_spans(&re, &text), None, "{label}");
}
});
}
#[test]
fn nested_star_does_not_backtrack_exponentially() {
bounded("nested_star_does_not_backtrack_exponentially", || {
let text = "a".repeat(30);
for (label, re) in builds(r"(a*)*c") {
assert_eq!(capture_spans(&re, &text), None, "{label}");
}
});
}
#[test]
fn nested_plus_reports_the_match_it_finds() {
bounded("nested_plus_reports_the_match_it_finds", || {
let text = format!("{}c", "a".repeat(30));
for (label, re) in builds(r"(a+)+c") {
assert_eq!(
capture_spans(&re, &text),
Some(vec![span(0, 31), span(0, 30)]),
"{label}"
);
}
});
}
#[test]
fn long_greedy_run_before_a_literal_stays_in_bounds() {
bounded("long_greedy_run_before_a_literal_stays_in_bounds", || {
let matching = format!("{}@", "a".repeat(300));
let non_matching = "a".repeat(300);
for (label, re) in builds(r"([a-z]+)@") {
assert_eq!(
capture_spans(&re, &matching),
Some(vec![span(0, 301), span(0, 300)]),
"{label}"
);
assert_eq!(capture_spans(&re, &non_matching), None, "{label}");
}
});
}
#[test]
fn very_long_greedy_run_before_a_literal_stays_in_bounds() {
bounded(
"very_long_greedy_run_before_a_literal_stays_in_bounds",
|| {
let text = format!("{}!", "a".repeat(4000));
for (label, re) in builds(r"(\w+)!") {
assert_eq!(
capture_spans(&re, &text),
Some(vec![span(0, 4001), span(0, 4000)]),
"{label}"
);
}
},
);
}
#[test]
fn long_greedy_run_captures_agree_with_find() {
bounded("long_greedy_run_captures_agree_with_find", || {
let text = format!("{}@", "a".repeat(1000));
for (label, re) in builds(r"([a-z]+)@") {
let found = re.find(&text).map(|m| (m.start(), m.end()));
let captured = capture_spans(&re, &text).and_then(|s| s[0]);
assert_eq!(found, captured, "{label}");
}
});
}
const LONG_INPUT: usize = 20_000;
const SCALING_DEADLINE: Duration = Duration::from_secs(120);
const LONG_RUN_NON_MATCHING: &[&str] = &[
r"(a|a)+$",
r"(?:a|a)+$",
r"(a|aa)+$",
r"(a+)+$",
r"(a|b)+$",
r"(x+x+)+y",
r"([a-zA-Z]+)*b$",
];
#[test]
fn long_run_rejection_stays_linear() {
bounded_within("long_run_rejection_stays_linear", SCALING_DEADLINE, || {
let text = format!("{}!", "a".repeat(LONG_INPUT));
for pattern in LONG_RUN_NON_MATCHING {
for (label, re) in builds(pattern) {
assert!(!re.is_match(&text), "{label} {pattern}");
assert_eq!(
re.find(&text).map(|m| (m.start(), m.end())),
None,
"{label} {pattern}"
);
assert_eq!(capture_spans(&re, &text), None, "{label} {pattern}");
}
}
});
}
#[test]
fn long_run_iteration_stays_linear() {
bounded_within("long_run_iteration_stays_linear", SCALING_DEADLINE, || {
let text = format!("{}!", "a".repeat(LONG_INPUT));
for pattern in LONG_RUN_NON_MATCHING {
for (label, re) in builds(pattern) {
assert_eq!(re.find_iter(&text).count(), 0, "{label} {pattern}");
assert_eq!(iterated_spans(&re, &text).len(), 0, "{label} {pattern}");
}
}
});
}
const WORD_BOUNDARY_LONG_RUN_REPEATS: usize = 200_000;
#[test]
fn word_boundary_long_run_rejection_stays_linear() {
bounded_within(
"word_boundary_long_run_rejection_stays_linear",
SCALING_DEADLINE,
|| {
let text = "bb ".repeat(WORD_BOUNDARY_LONG_RUN_REPEATS);
let re = RegexBuilder::new(r"\b[a-y ]+\d")
.jit(false)
.build()
.expect("pattern should compile");
assert!(!re.is_match(&text));
assert_eq!(re.find(&text).map(|m| (m.start(), m.end())), None);
},
);
}
#[test]
fn many_unmatched_open_parens_is_rejected_not_crashed() {
bounded("many_unmatched_open_parens_is_rejected_not_crashed", || {
let pattern = "(".repeat(50_000);
let err = Regex::new(&pattern)
.expect_err("unbounded nesting must not compile")
.to_string();
assert!(
err.contains("nest"),
"expected a nesting error, got {err:?}"
);
});
}
#[test]
fn many_well_formed_non_capturing_groups_is_rejected_not_crashed() {
bounded(
"many_well_formed_non_capturing_groups_is_rejected_not_crashed",
|| {
let pattern = format!("{}{}", "(?:".repeat(50_000), ")".repeat(50_000));
let err = Regex::new(&pattern)
.expect_err("unbounded nesting must not compile")
.to_string();
assert!(
err.contains("nest"),
"expected a nesting error, got {err:?}"
);
},
);
}
#[test]
fn many_nested_classes_is_rejected_not_crashed() {
bounded("many_nested_classes_is_rejected_not_crashed", || {
let pattern = "[a".repeat(50_000);
let err = Regex::new(&pattern)
.expect_err("unbounded class nesting must not compile")
.to_string();
assert!(
err.contains("nest"),
"expected a nesting error, got {err:?}"
);
});
}
#[test]
fn many_inline_flag_changes_is_rejected_not_crashed() {
bounded("many_inline_flag_changes_is_rejected_not_crashed", || {
let pattern = "(?i)(?-i)".repeat(50_000);
let err = Regex::new(&pattern)
.expect_err("unbounded flag-scope nesting must not compile")
.to_string();
assert!(
err.contains("nest"),
"expected a nesting error, got {err:?}"
);
});
}
#[test]
fn nesting_just_under_the_limit_still_compiles() {
bounded("nesting_just_under_the_limit_still_compiles", || {
let depth = (regexr::parser::DEFAULT_NEST_LIMIT - 1) as usize;
let pattern = format!("{}a{}", "(?:".repeat(depth), ")".repeat(depth));
Regex::new(&pattern).expect("nesting one under the limit must compile");
});
}
const EXTRACTOR_DOUBLING_DEPTH: usize = 60;
fn nested_concat_doubling_pattern(depth: usize) -> String {
format!("{}ab{}", "a(?:".repeat(depth), ")".repeat(depth))
}
fn nested_alt_doubling_pattern(depth: usize) -> String {
let mut pattern = String::from(r"\d");
for _ in 0..depth {
pattern = format!(r"(?:a{pattern}|\d)");
}
pattern
}
#[test]
fn nested_concat_literal_extraction_terminates() {
bounded("nested_concat_literal_extraction_terminates", || {
let pattern = nested_concat_doubling_pattern(EXTRACTOR_DOUBLING_DEPTH);
Regex::new(&pattern).expect("well under the nesting cap, must compile");
});
}
#[test]
fn nested_alt_literal_extraction_terminates() {
bounded("nested_alt_literal_extraction_terminates", || {
let pattern = nested_alt_doubling_pattern(EXTRACTOR_DOUBLING_DEPTH);
Regex::new(&pattern).expect("well under the nesting cap, must compile");
});
}
#[test]
fn nested_concat_in_lookahead_literal_extraction_terminates() {
bounded(
"nested_concat_in_lookahead_literal_extraction_terminates",
|| {
let inner = nested_concat_doubling_pattern(EXTRACTOR_DOUBLING_DEPTH);
let pattern = format!("x(?={inner})");
Regex::new(&pattern).expect("well under the nesting cap, must compile");
},
);
}
#[test]
fn pathological_sequential_alternations_do_not_blow_up_compile_time() {
bounded(
"pathological_sequential_alternations_do_not_blow_up_compile_time",
|| {
let groups = ["(?:ab|cd)", "(?:ef|gh)", "(?:ij|kl)", "(?:mn|op)"];
let mut pattern = String::from("(?=a)");
for i in 0..32 {
pattern.push_str(groups[i % groups.len()]);
}
Regex::new(&pattern).expect("pattern is well-formed and must compile");
},
);
}
#[test]
fn nullable_repetition_does_not_blow_up_compile_time() {
bounded("nullable_repetition_does_not_blow_up_compile_time", || {
Regex::new("(?:a?){2000}").expect("pattern is well-formed and must compile");
});
}
#[test]
fn repeated_grapheme_cluster_terminates_on_the_tagged_path() {
bounded(
"repeated_grapheme_cluster_terminates_on_the_tagged_path",
|| {
let haystack = "a\u{0301}e\u{0302}i\u{0303}o\u{0304}".repeat(64);
for pattern in [r"\X{4}", r"\X{2,4}", r"\X{4}z"] {
for jit in [false, true] {
let re = RegexBuilder::new(pattern)
.jit(jit)
.build()
.expect("pattern is well-formed and must compile");
let count = re.find_iter(&haystack).count();
std::hint::black_box(count);
}
}
},
);
}