From 4791c1d8dd3f32c9e270b07c8413c580ca304790 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 19:58:26 -0500 Subject: [PATCH 1/4] GREP: refuse subroutine calls and bound the empty-match scan A GREP style's subroutine calls ((?1), (?R), \g) are inlined by the regex compiler, so a short pattern whose groups call each other twice compiles exponentially. The dialect has no subroutines; they are now refused before compiling. find_nonempty skipped empty matches without counting them, so a pattern that only matches empty ran one search per character. Each search now counts: the scan stops after limit x SEARCHES_PER_MATCH searches. --- crates/compose/src/grep.rs | 45 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 61546d162..e0e018a77 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -13,6 +13,9 @@ //! - Lookahead, lookbehind, atomic groups, possessive quantifiers, backreferences and `\K` are //! supported. Backtracking is capped ([`BACKTRACK_LIMIT`]), so a pathological pattern stops //! with an error instead of hanging. +//! - Subroutine calls and recursion (`(?1)`, `(?R)`, `\g`, `(?&name)`) are not part of the +//! dialect and are refused: the regex engine inlines each call while compiling, so a short +//! pattern whose groups call each other more than once expands exponentially. use std::fmt; use std::ops::Range; @@ -30,6 +33,11 @@ pub const BACKTRACK_LIMIT: usize = 200_000; /// on every relayout (#1598). A paragraph cannot usefully carry more styled runs than this, and /// a scan that reaches it stops with what it has. pub const MAX_MATCHES_PER_PARAGRAPH: usize = 4096; +/// Searches [`Grep::find_nonempty`] may run per match it is allowed to return. Empty matches +/// don't count toward that match limit, but each one is still a search (a lookaround can scan the +/// rest of the paragraph): without this a pattern that only ever matches empty searched once per +/// character, quadratic in the paragraph's length. +pub const SEARCHES_PER_MATCH: usize = 4; /// Compiled program size cap (bytes) for the underlying regex engine. const SIZE_LIMIT: usize = 4 << 20; @@ -363,7 +371,13 @@ impl Grep { (false, true) => "(?m)", (false, false) => "", }; - let re = fancy_regex::RegexBuilder::new(&format!("{flags}{pat}")) + let pat = format!("{flags}{pat}"); + // Refused before compiling: the compiler inlines every subroutine call (to a fixed depth), + // so `(a(?1)(?1))` costs time and memory exponential in that depth. + if fancy_regex::Expr::parse_tree(&pat).is_ok_and(|t| t.contains_subroutines) { + return Err(err("GREP: subroutine calls and recursion are not supported")); + } + let re = fancy_regex::RegexBuilder::new(&pat) .backtrack_limit(BACKTRACK_LIMIT) .delegate_size_limit(SIZE_LIMIT) .delegate_dfa_size_limit(SIZE_LIMIT) @@ -396,10 +410,12 @@ impl Grep { } /// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it - /// doesn't count toward a GREP style's cap. + /// doesn't count toward a GREP style's cap. Empty matches still cost a search each, so the + /// scan stops after `limit` × [`SEARCHES_PER_MATCH`] searches in all. pub fn find_nonempty(&self, text: &str, limit: usize) -> Result>, GrepError> { self.re .find_iter(text) + .take(limit.saturating_mul(SEARCHES_PER_MATCH)) .filter(|m| !m.as_ref().is_ok_and(|m| m.range().is_empty())) .take(limit) .map(|m| m.map(|m| m.range()).map_err(run_err)) @@ -497,6 +513,31 @@ mod tests { assert_eq!(g.replacements("5 kg", &tpl, false).unwrap(), [(0..4, "kg\u{A0}5x\n$$".to_string())]); } + #[test] + fn subroutine_calls_are_refused() { + // Each group calls itself twice: compiling inlines 2^depth copies. + for p in [r"(a(?1)(?1))", r"(?a\g\g)", r"(a|(?R))", r"(?a)(?&x)", r"(a)\g<1>"] { + let t = std::time::Instant::now(); + let e = Grep::new(p, GrepOptions::default()).unwrap_err(); + assert!(e.0.contains("subroutine"), "{p}: {e}"); + assert!(t.elapsed().as_secs() < 5, "{p}"); + } + // Backreferences and groups are still fine. + assert_eq!(found(r"(a)\1", "xaa"), ["aa"]); + assert_eq!(found(r"(?a)\k", "xaa"), ["aa"]); + } + + #[test] + fn empty_matches_use_up_the_search_budget() { + // A pattern that matches empty at every character: the non-empty match at the end is + // found within the budget and not past it. + let g = grep("a*"); + let near = format!("{}a", "b".repeat(10 * SEARCHES_PER_MATCH - 2)); + assert_eq!(g.find_nonempty(&near, 10).unwrap().len(), 1); + let far = format!("{}a", "b".repeat(10 * SEARCHES_PER_MATCH + 50)); + assert!(g.find_nonempty(&far, 10).unwrap().is_empty(), "the scan stops at the search budget"); + } + #[test] fn catastrophic_backtracking_stops() { // Exponential in the backtracking engine (the backreference forces it): without the limit From e8b96e7e02803aa43bf82ace101aa842a8e81afa Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 20:28:32 -0500 Subject: [PATCH 2/4] GREP: test the subroutine syntax the regex engine parses (\g<...>) --- crates/compose/src/grep.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index e0e018a77..7e75699e4 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -13,9 +13,9 @@ //! - Lookahead, lookbehind, atomic groups, possessive quantifiers, backreferences and `\K` are //! supported. Backtracking is capped ([`BACKTRACK_LIMIT`]), so a pathological pattern stops //! with an error instead of hanging. -//! - Subroutine calls and recursion (`(?1)`, `(?R)`, `\g`, `(?&name)`) are not part of the -//! dialect and are refused: the regex engine inlines each call while compiling, so a short -//! pattern whose groups call each other more than once expands exponentially. +//! - Subroutine calls and recursion (`\g`, `\g<1>`, `\g<-1>`) are not part of the dialect +//! and are refused: the regex engine inlines each call while compiling, so a short pattern whose +//! groups call each other more than once expands exponentially. use std::fmt; use std::ops::Range; @@ -373,7 +373,7 @@ impl Grep { }; let pat = format!("{flags}{pat}"); // Refused before compiling: the compiler inlines every subroutine call (to a fixed depth), - // so `(a(?1)(?1))` costs time and memory exponential in that depth. + // so `(a\g<1>\g<1>)` costs time and memory exponential in that depth. if fancy_regex::Expr::parse_tree(&pat).is_ok_and(|t| t.contains_subroutines) { return Err(err("GREP: subroutine calls and recursion are not supported")); } @@ -516,7 +516,7 @@ mod tests { #[test] fn subroutine_calls_are_refused() { // Each group calls itself twice: compiling inlines 2^depth copies. - for p in [r"(a(?1)(?1))", r"(?a\g\g)", r"(a|(?R))", r"(?a)(?&x)", r"(a)\g<1>"] { + for p in [r"(a\g<1>\g<1>)", r"(?a\g\g)", r"(?a|b\g)", r"(a)(b)\g<-1>", r"(a)\g<1>"] { let t = std::time::Instant::now(); let e = Grep::new(p, GrepOptions::default()).unwrap_err(); assert!(e.0.contains("subroutine"), "{p}: {e}"); From 8ef09e8ed92ecbf98842cb3b934d2bf88746f454 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 19:42:30 +0000 Subject: [PATCH 3/4] address review: report GREP search-budget exhaustion to the overlay warning --- crates/compose/src/grep.rs | 31 +++++++++++++++++++++---------- crates/compose/src/overlay.rs | 5 ++++- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 7e75699e4..edb8f73fe 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -412,14 +412,24 @@ impl Grep { /// Non-empty matches in `text`, at most `limit` of them: an empty match styles nothing, so it /// doesn't count toward a GREP style's cap. Empty matches still cost a search each, so the /// scan stops after `limit` × [`SEARCHES_PER_MATCH`] searches in all. - pub fn find_nonempty(&self, text: &str, limit: usize) -> Result>, GrepError> { - self.re - .find_iter(text) - .take(limit.saturating_mul(SEARCHES_PER_MATCH)) - .filter(|m| !m.as_ref().is_ok_and(|m| m.range().is_empty())) - .take(limit) - .map(|m| m.map(|m| m.range()).map_err(run_err)) - .collect() + /// + /// The flag is true when the search budget ran out with text left to scan, so the matches may + /// be incomplete (reaching `limit` is not flagged: the caller sees the full vector). + pub fn find_nonempty(&self, text: &str, limit: usize) -> Result<(Vec>, bool), GrepError> { + let mut it = self.re.find_iter(text); + let mut out = Vec::new(); + for _ in 0..limit.saturating_mul(SEARCHES_PER_MATCH) { + if out.len() >= limit { + return Ok((out, false)); + } + let Some(m) = it.next() else { return Ok((out, false)) }; + let r = m.map_err(run_err)?.range(); + if !r.is_empty() { + out.push(r); + } + } + let more = out.len() < limit && it.next().is_some(); + Ok((out, more)) } /// Matches in `text` and their replacements, `template` expanded per match (`$1`…; see @@ -533,9 +543,10 @@ mod tests { // found within the budget and not past it. let g = grep("a*"); let near = format!("{}a", "b".repeat(10 * SEARCHES_PER_MATCH - 2)); - assert_eq!(g.find_nonempty(&near, 10).unwrap().len(), 1); + let (ms, exhausted) = g.find_nonempty(&near, 10).unwrap(); + assert_eq!((ms.len(), ms[0].clone(), exhausted), (1, near.len() - 1..near.len(), false)); let far = format!("{}a", "b".repeat(10 * SEARCHES_PER_MATCH + 50)); - assert!(g.find_nonempty(&far, 10).unwrap().is_empty(), "the scan stops at the search budget"); + assert_eq!(g.find_nonempty(&far, 10).unwrap(), (vec![], true), "the scan stops at the search budget and says so"); } #[test] diff --git a/crates/compose/src/overlay.rs b/crates/compose/src/overlay.rs index b09ef908e..ccf086501 100644 --- a/crates/compose/src/overlay.rs +++ b/crates/compose/src/overlay.rs @@ -107,7 +107,10 @@ pub(crate) fn overlays(text: &str, range: Range, nested: &[NestedStyle], with_pattern(&g.pattern, |slot| { let Some(re) = slot else { return }; match re.find_nonempty(text.get(range.start..end).unwrap_or(""), crate::grep::MAX_MATCHES_PER_PARAGRAPH) { - Ok(ms) => { + Ok((ms, exhausted)) => { + if exhausted { + log::warn!("GREP style {:?} ran out of search budget in one paragraph; the rest may not be styled", g.pattern); + } if ms.len() == crate::grep::MAX_MATCHES_PER_PARAGRAPH { log::warn!("GREP style {:?} matched more than {} times in one paragraph; the rest are not styled", g.pattern, ms.len()); } From 97bb82816e8036d53cd033bed3e5d5ff5101d863 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Tue, 3 Nov 2026 19:57:14 +0000 Subject: [PATCH 4/4] Apply review fixes - crates/compose/src/grep.rs --- crates/compose/src/grep.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index edb8f73fe..b387384f9 100644 --- a/crates/compose/src/grep.rs +++ b/crates/compose/src/grep.rs @@ -428,7 +428,7 @@ impl Grep { out.push(r); } } - let more = out.len() < limit && it.next().is_some(); + let more = out.len() < limit && it.next().transpose().map_err(run_err)?.is_some(); Ok((out, more)) }