From f7d3eaaa6a5c3d6b15a4511e3186cb31fdd8fe48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Chenard?= Date: Sun, 5 Jul 2026 12:21:03 -0400 Subject: [PATCH] automata: fix leftmost-first miss in the reverse suffix optimization The reverse suffix optimization prefilters on the longest common suffix and processes candidates in order of match end, yielding the first reverse+forward confirmed match. That is only correct when the leftmost starting match also has the smallest candidate end. When one exact suffix literal (an entire match of one alternation branch) is a proper suffix of another suffix literal, a match can be nested inside the tail of an overlapping match that ends later but starts earlier: the first confirmed candidate is then not leftmost, and the (correct in itself) anti-quadratic min_start clamp makes the missed match permanent. For example, 'a|.aa' on '-#aa' reported [(2,3), (3,4)] instead of the leftmost-first [(1,4)]. Fix conservatively: decline the optimization when any exact suffix literal is a proper suffix of another suffix literal, falling back to the core engine. Common beneficiaries (single branches, all-inexact seqs such as '\w+@\w+', non-nested alternations like 'foo|bar') keep the optimization. Found by a differential fuzzer (REAL-regex vs this crate); minimized by hand. --- regex-automata/src/meta/strategy.rs | 29 +++++++++++++++++++++++++++++ tests/regression_fuzz.rs | 17 +++++++++++++++++ 2 files changed, 46 insertions(+) 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)]); +}