From 2bbe253425beabe44b055fc622e1d8ddcfbabaf7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 21:41:15 +0000 Subject: [PATCH] fix(automation): clip each text_find match and bound the response's text --- crates/automation/src/pages.rs | 28 +++++++++++++++++++++++++++- crates/automation/src/tests.rs | 10 ++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 8c793be153..81f1350d33 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -20,6 +20,21 @@ pub(crate) const FIND_MAX_WARNINGS: usize = 100; pub(crate) const FIND_MAX_WARNING_BYTES: usize = 1024; +/// Longest text one `text_find` match returns. A match is normally about the query's length, but +/// one glyph can stand for a large `/ActualText`, and many matches can name that one glyph. +pub(crate) const FIND_MAX_MATCH_TEXT_BYTES: usize = 64 << 10; + +/// Text bytes one `text_find` response holds in all; the search continues from `next` past it. +pub(crate) const FIND_MAX_RESPONSE_TEXT_BYTES: usize = 8 << 20; + +/// `text` cut to [`FIND_MAX_MATCH_TEXT_BYTES`] at a character boundary, and whether it was cut. +pub(crate) fn clip_match_text(mut text: String) -> (String, bool) { + let end = text.floor_char_boundary(FIND_MAX_MATCH_TEXT_BYTES.min(text.len())); + let cut = end < text.len(); + text.truncate(end); + (text, cut) +} + impl Automation { // ---- pages and text ---------------------------------------------------------------------- @@ -245,6 +260,7 @@ impl Automation { let mut next = None; let mut warnings = Vec::new(); let mut warning_count = 0usize; + let mut response_text = 0usize; let (mut first, mut batch) = (start, FIND_FIRST_BATCH); 'search: while first < n { let end = first.saturating_add(batch).min(n); @@ -273,7 +289,17 @@ impl Automation { next = Some(json!({ "start_page": p + 1, "skip": k })); break 'search; } - matches.push(json!({ "page": p + 1, "text": text.text_of(r.clone()), "rects": text.line_rects(r) })); + let (matched, cut) = clip_match_text(text.text_of(r.clone())); + if response_text.saturating_add(matched.len()) > FIND_MAX_RESPONSE_TEXT_BYTES && !matches.is_empty() { + next = Some(json!({ "start_page": p + 1, "skip": k })); + break 'search; + } + response_text += matched.len(); + let mut found = json!({ "page": p + 1, "text": matched, "rects": text.line_rects(r) }); + if let (true, Some(o)) = (cut, found.as_object_mut()) { + o.insert("text_truncated".into(), json!(true)); + } + matches.push(found); } } first = end; diff --git a/crates/automation/src/tests.rs b/crates/automation/src/tests.rs index fd4efa79da..0b7223bacb 100644 --- a/crates/automation/src/tests.rs +++ b/crates/automation/src/tests.rs @@ -550,3 +550,13 @@ fn tool_file_reads_refuse_inputs_exceeding_512_mib() { } let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_match_text_is_clipped_at_a_character_boundary() { + use crate::pages::{FIND_MAX_MATCH_TEXT_BYTES, clip_match_text}; + assert_eq!(clip_match_text("short".into()), ("short".into(), false)); + // A multi-byte character straddling the limit is dropped whole. + let long = format!("{}é", "a".repeat(FIND_MAX_MATCH_TEXT_BYTES - 1)); + let (clipped, cut) = clip_match_text(long); + assert!(cut && clipped.len() == FIND_MAX_MATCH_TEXT_BYTES - 1); +}