Merge pull request #2179 from storytold/fix/sec-grep-compile-and-scan-budget

GREP: refuse subroutine calls and bound the empty-match scan
This commit is contained in:
aiongg authored and GitHub committed 2026-11-09 21:23:30 -05:00
commit 0078f832a7
2 files changed
+65 -10

No files matched your search

+61 -9
View File
@@ -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<name>`, `\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<Vec<Range<usize>>, 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<Range<usize>>, 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"(?<x>a\g<x>\g<x>)", r"(?<x>a|b\g<x>)", 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"(?<x>a)\k<x>", "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
+4 -1
View File
@@ -116,7 +116,10 @@ pub(crate) fn overlays(text: &str, range: Range<usize>, 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());
}