diff --git a/regex-automata/src/meta/strategy.rs b/regex-automata/src/meta/strategy.rs index ebb876b2b8..7c184a25a1 100644 --- a/regex-automata/src/meta/strategy.rs +++ b/regex-automata/src/meta/strategy.rs @@ -1168,6 +1168,35 @@ impl ReverseSuffix { } let kind = core.info.config().get_match_kind(); let suffixes = crate::util::prefilter::suffixes(kind, hirs); + // An exact suffix literal that is also a proper suffix of another + // suffix literal means one alternation branch's complete match can be + // nested inside the tail of another branch's match. Candidate *ends* + // are then not ordered like match *starts*: the first confirmed + // candidate can yield a match that starts *later* than a still + // undiscovered overlapping match ending further right, violating + // leftmost-first. (Repro: `a|.aa` on `-#aa` reported [(2,3),(3,4)] + // instead of [(1,4)].) Decline the optimization in that case. + if let Some(lits) = suffixes.literals() { + for (i, a) in lits.iter().enumerate() { + if !a.is_exact() { + continue; + } + for (j, b) in lits.iter().enumerate() { + if i != j + && b.as_bytes().len() > a.as_bytes().len() + && b.as_bytes().ends_with(a.as_bytes()) + { + debug!( + "skipping reverse suffix optimization because \ + an exact suffix literal is a proper suffix of \ + another suffix literal (nested-end overlaps \ + defeat leftmost-first candidate ordering)" + ); + return Err(core); + } + } + } + } let lcs = match suffixes.longest_common_suffix() { None => { debug!( diff --git a/tests/regression_fuzz.rs b/tests/regression_fuzz.rs index f90ad4cb20..e3ff572e6f 100644 --- a/tests/regression_fuzz.rs +++ b/tests/regression_fuzz.rs @@ -59,3 +59,20 @@ fn fail_branch_prevents_match() { let re = Regex::new(pat).unwrap(); assert!(re.is_match(hay)); } + +// This was caught by a differential fuzzer (REAL-regex vs this crate), and +// then minimized by hand. +// +// The reverse suffix optimization processes suffix-prefilter candidates in +// order of match *end*. When one alternation branch's entire match (an exact +// suffix literal) is a proper suffix of another branch's suffix, a match can +// be nested inside the tail of an overlapping match that ends later but +// starts *earlier*. Yielding the first confirmed candidate then skips the +// true leftmost match, and the anti-quadratic clamp makes the miss permanent. +#[test] +fn reverse_suffix_nested_overlap_misses_leftmost() { + let re = Regex::new("a|.aa").unwrap(); + let spans: Vec<(usize, usize)> = + re.find_iter("-#aa").map(|m| (m.start(), m.end())).collect(); + assert_eq!(spans, vec![(1, 4)]); +}