diff --git a/crates/compose/src/grep.rs b/crates/compose/src/grep.rs index 61546d162..b387384f9 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 (`\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; @@ -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\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")); + } + let re = fancy_regex::RegexBuilder::new(&pat) .backtrack_limit(BACKTRACK_LIMIT) .delegate_size_limit(SIZE_LIMIT) .delegate_dfa_size_limit(SIZE_LIMIT) @@ -396,14 +410,26 @@ 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. - pub fn find_nonempty(&self, text: &str, limit: usize) -> Result>, GrepError> { - self.re - .find_iter(text) - .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() + /// 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. + /// + /// 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().transpose().map_err(run_err)?.is_some(); + Ok((out, more)) } /// Matches in `text` and their replacements, `template` expanded per match (`$1`…; see @@ -497,6 +523,32 @@ 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\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}"); + 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)); + 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_eq!(g.find_nonempty(&far, 10).unwrap(), (vec![], true), "the scan stops at the search budget and says so"); + } + #[test] fn catastrophic_backtracking_stops() { // Exponential in the backtracking engine (the backreference forces it): without the limit diff --git a/crates/compose/src/overlay.rs b/crates/compose/src/overlay.rs index 6cd3df72f..558bf5bec 100644 --- a/crates/compose/src/overlay.rs +++ b/crates/compose/src/overlay.rs @@ -116,7 +116,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()); }