mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-09 12:32:52 +00:00
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:
1 parent
5925539221
commit
589c4af8e7
3 files changed
+56
-15
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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: ®ex::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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user