Redact regex: fail when a page exceeds the match cap

UserRegex::find now returns an error instead of silently stopping at
10,000 matches, so redact_mark cannot report success while later matches
stay readable. Empty-match detection probes several short texts so
patterns like \b are refused with the clear message. Schema notes that
the 1024 limit is in bytes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Sonnet 5.5 committed 2026-10-25 10:19:17 +01:00
1 parent 5925539221
commit 589c4af8e7
3 files changed
+56 -15

No files matched your search

+18 -1
View File
@@ -126,7 +126,24 @@ impl Automation {
let info = self.doc(a)?.info.clone();
for (&p, text) in pages.iter().zip(&texts) {
let hits: Vec<(Option<usize>, _)> = match (&user_re, pattern) {
(Some(re), _) => text.find_with(|chars| re.find(chars)).into_iter().map(|h| (None, h)).collect(),
(Some(re), _) => {
// A page with too many matches fails the call: marking only the first ones
// would leave the rest readable after redact_apply.
let too_many = std::cell::Cell::new(false);
let found = text.find_with(|chars| {
re.find(chars).unwrap_or_else(|_| {
too_many.set(true);
Vec::new()
})
});
if too_many.get() {
return Err(ToolError::InvalidArgs(format!(
"regex has more than 10000 matches on page {}; nothing was marked. Narrow the expression so that every match can be marked",
p + 1
)));
}
found.into_iter().map(|h| (None, h)).collect()
}
(None, Some(pat)) => text.find_with(|chars| find_pattern(pat, chars)).into_iter().map(|h| (None, h)).collect(),
(None, None) => needles
.iter()
+2 -2
View File
@@ -486,7 +486,7 @@ pub fn tools() -> Vec<ToolDef> {
t(
"redact_mark",
"Mark for redaction",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); regex (your own regular expression, every match, on the page text with words and lines separated by one space; Rust regex syntax: linear-time, no look-around or back-references, case-sensitive unless (?i), at most 1024 bytes, matches of the empty string are refused) with optional validate (luhn, mod97, iban: keep a match only if its checksum holds); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
"Mark content for redaction (nothing is removed until redact_apply). One of: rect [x0, y0, x1, y1] (points from the top-left of the displayed page) with page; find (text, every match); words (a list of words or phrases, every match of each; the result's matched_words says how many marks each made); pattern (phone, email, credit-card, ssn, date: every match, Acrobat's Search & Redact patterns); regex (your own regular expression, every match, on the page text with words and lines separated by one space; Rust regex syntax: linear-time, no look-around or back-references, case-sensitive unless (?i), at most 1024 bytes, more than 10000 matches on one page fail the call, matches of the empty string are refused) with optional validate (luhn, mod97, iban: keep a match only if its checksum holds); whole_pages: true. find and words accept case_sensitive and whole_words (both default false). Select one page with page or multiple with pages (default all); do not pass both. rect requires page. overlay: text shown on the box once applied, or code_set (foia: U.S. FOIA (b)(1)(A)…(b)(9); privacy-act: U.S. Privacy Act (d)(5), (j)(1)…(k)(7)) with codes, shown as \"(b)(6), (b)(7)(C)\"; fill: box colour (default black). Undoable.",
)
.with(schema(
json!({
@@ -498,7 +498,7 @@ pub fn tools() -> Vec<ToolDef> {
"case_sensitive": { "type": "boolean", "description": "Match letter case for find or words (default false)." },
"whole_words": { "type": "boolean", "description": "Match whole words for find or words (default false)." },
"pattern": { "type": "string", "enum": ["phone", "email", "credit-card", "ssn", "date"] },
"regex": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Regular expression (Rust regex syntax); every match is marked." },
"regex": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Regular expression (Rust regex syntax), at most 1024 bytes (maxLength counts characters, the limit is bytes); every match is marked." },
"validate": { "type": "string", "enum": ["luhn", "mod97", "iban"], "description": "With regex: keep a match only if its checksum is valid (spaces and hyphens ignored)." },
"whole_pages": { "type": "boolean" },
"pages": pages("to search or mark (default: all); mutually exclusive with page, and not valid with rect"),
+36 -12
View File
@@ -13,7 +13,8 @@ use thiserror::Error;
/// Longest expression accepted, in bytes.
pub const MAX_REGEX_LEN: usize = 1024;
/// Most matches returned for one page; the rest are ignored.
/// Most matches accepted for one page; one more makes [`UserRegex::find`] fail, so a page is never
/// marked only in part.
pub const MAX_MATCHES_PER_TEXT: usize = 10_000;
/// Cap on the compiled program and on the lazy-DFA cache, in bytes.
const SIZE_LIMIT: usize = 1 << 20;
@@ -30,6 +31,8 @@ pub enum RegexError {
Invalid(String),
#[error("regex matches the empty string, so it would mark nothing useful; require at least one character")]
MatchesEmpty,
#[error("regex has more than {MAX_MATCHES_PER_TEXT} matches on one page; narrow it so that every match can be marked")]
TooManyMatches,
}
/// Checksum a match must pass to be kept.
@@ -106,6 +109,12 @@ fn mod97(s: impl Iterator<Item = char>) -> bool {
n >= 2 && rem == 1
}
/// Whether `re` can match the empty string, which `is_match("")` alone misses for assertions such
/// as `\b`. Tries a few short texts with letters, digits, space, newline and a non-ASCII letter.
fn can_match_empty(re: &regex::Regex) -> bool {
["", " ", "a", "1", "a a", "a\n", "\n", "é", "aa 11"].iter().any(|p| re.find_iter(p).any(|m| m.is_empty()))
}
/// A compiled caller expression with an optional checksum.
#[derive(Debug, Clone)]
pub struct UserRegex {
@@ -127,14 +136,17 @@ impl UserRegex {
.nest_limit(NEST_LIMIT)
.build()
.map_err(|e| RegexError::Invalid(e.to_string()))?;
if re.is_match("") {
if can_match_empty(&re) {
return Err(RegexError::MatchesEmpty);
}
Ok(Self { re, validator })
}
/// Character ranges of `text` the expression matches (non-overlapping, left to right).
pub fn find(&self, text: &[char]) -> Vec<Range<usize>> {
///
/// Fails with [`RegexError::TooManyMatches`] when more than [`MAX_MATCHES_PER_TEXT`] matches
/// are kept, instead of returning a partial list the caller would take for complete.
pub fn find(&self, text: &[char]) -> Result<Vec<Range<usize>>, RegexError> {
let s: String = text.iter().collect();
// Char index of every byte offset that starts a char (and of the end).
let mut byte_to_char = vec![usize::MAX; s.len().saturating_add(1)];
@@ -148,9 +160,6 @@ impl UserRegex {
}
let mut out = Vec::new();
for m in self.re.find_iter(&s) {
if out.len() >= MAX_MATCHES_PER_TEXT {
break;
}
if m.is_empty() || self.validator.is_some_and(|v| !v.check(m.as_str())) {
continue;
}
@@ -158,10 +167,13 @@ impl UserRegex {
&& a < b
&& b != usize::MAX
{
if out.len() >= MAX_MATCHES_PER_TEXT {
return Err(RegexError::TooManyMatches);
}
out.push(a..b);
}
}
out
Ok(out)
}
}
@@ -177,7 +189,7 @@ mod tests {
fn finds_matches_as_char_ranges_with_multibyte_text() {
let r = UserRegex::new(r"\d{2}( \d{2}){2}", None).unwrap();
let t = chars("Ré 85 05 78 et 91 12 34 fin");
let found: Vec<String> = r.find(&t).into_iter().map(|g| t[g].iter().collect()).collect();
let found: Vec<String> = r.find(&t).unwrap().into_iter().map(|g| t[g].iter().collect()).collect();
assert_eq!(found, ["85 05 78", "91 12 34"]);
}
@@ -185,10 +197,10 @@ mod tests {
fn validators_filter_matches() {
let t = chars("ok 4539 1488 0343 6467 bad 4539 1488 0343 6468");
let r = UserRegex::new(r"\d{4}( \d{4}){3}", Some(Validator::Luhn)).unwrap();
assert_eq!(r.find(&t).len(), 1);
assert_eq!(r.find(&t).unwrap().len(), 1);
let t = chars("GB82 WEST 1234 5698 7654 32 and GB83 WEST 1234 5698 7654 32");
let r = UserRegex::new(r"GB\d\d WEST( \d{4}){3} \d\d", Some(Validator::Iban)).unwrap();
assert_eq!(r.find(&t).len(), 1);
assert_eq!(r.find(&t).unwrap().len(), 1);
assert!(Validator::Mod97.check("98") && !Validator::Mod97.check("99"));
assert!(!Validator::Luhn.check("12a4") && !Validator::Mod97.check("é1") && !Validator::Luhn.check("0"));
}
@@ -199,13 +211,25 @@ mod tests {
assert!(matches!(UserRegex::new(&"a".repeat(MAX_REGEX_LEN + 1), None), Err(RegexError::TooLong(_))));
assert!(matches!(UserRegex::new("(unclosed", None), Err(RegexError::Invalid(_))));
assert!(matches!(UserRegex::new(r"(a)\1", None), Err(RegexError::Invalid(_))), "no back-references");
assert_eq!(UserRegex::new("a*", None).unwrap_err(), RegexError::MatchesEmpty);
for empty in ["a*", r"\b", "(?m)^", r"\B", "(?m)$", r"\d*|x"] {
assert_eq!(UserRegex::new(empty, None).unwrap_err(), RegexError::MatchesEmpty, "{empty}");
}
assert!(UserRegex::new(r"\bfoo\b", None).is_ok());
assert!(matches!(UserRegex::new(r"\p{L}{1000}{1000}", None), Err(RegexError::Invalid(_))), "compiled size is capped");
assert!(matches!(UserRegex::new(&format!("{}a{}", "(".repeat(100), ")".repeat(100)), None), Err(RegexError::Invalid(_))));
// Classic catastrophic pattern finishes at once on a long non-matching input.
let r = UserRegex::new(r"(a+)+$", None).unwrap();
let mut t = chars(&"a".repeat(50_000));
t.push('!');
assert!(r.find(&t).is_empty());
assert!(r.find(&t).unwrap().is_empty());
}
#[test]
fn more_than_the_cap_of_matches_is_an_error_not_a_partial_list() {
let r = UserRegex::new("x", None).unwrap();
let at_cap = chars(&"x ".repeat(MAX_MATCHES_PER_TEXT));
assert_eq!(r.find(&at_cap).unwrap().len(), MAX_MATCHES_PER_TEXT);
let over = chars(&"x ".repeat(MAX_MATCHES_PER_TEXT + 1));
assert_eq!(r.find(&over).unwrap_err(), RegexError::TooManyMatches);
}
}