From d2e7ce72f045bc85e1bcc9e784d43708190a8305 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:17:10 +0000 Subject: [PATCH] fix(redact): form entries and page content share one budget per page The interpreter entered a form again at every Do with only a nesting limit, so a few shared forms drawing each other produced exponential work in apply, sanitize and verify. Scope carries a per-page DecodeBudget for form entries and form bytes; past it, apply refuses the page and verification counts the form as possible residue. Page content streams are decoded against a page budget too. --- crates/redact/src/interp.rs | 25 +++++++++++++++++++++++-- crates/redact/src/lib.rs | 6 ++++-- crates/redact/src/tests.rs | 17 +++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index c9a404b93..5b3742385 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -21,7 +21,7 @@ use std::collections::{HashMap, HashSet}; use std::rc::Rc; use pdfcraft_content::{Matrix, Op, Pieces, contains, num, overlaps, serialize_ops, string}; -use pdfcraft_cos::{CosError, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_cos::{CosError, DecodeBudget, Dict, Document, MAX_PAGE_FORMS, ObjRef, Object, Stream}; use crate::{RedactError, Report, image, page_streams, tags}; use pdfcraft_fonts::pdf::Metrics; @@ -102,6 +102,9 @@ pub(crate) struct Scope<'a> { fonts: HashMap, Rc>, used_names: Vec>, depth: usize, + /// Form entries and decoded form bytes left for this page: shared forms drawn from other + /// shared forms would otherwise multiply the work with every level. + budget: DecodeBudget, } impl<'a> Scope<'a> { @@ -120,6 +123,7 @@ impl<'a> Scope<'a> { fonts: HashMap::new(), used_names: Vec::new(), depth: 0, + budget: DecodeBudget::page(), } } @@ -1201,8 +1205,25 @@ fn xobject( out.doomed.push(r); return Some(None); } - let data = match s.decoded() { + let data = if scope.budget.enter_form() { + scope.budget.decode(s) + } else { + Err(CosError::Filter(format!("the page draws more than {MAX_PAGE_FORMS} form XObjects"))) + }; + let data = match data { Ok(d) => d, + // Past the page's budget nothing more is read: apply refuses the page, and + // verification counts the form as content that may remain. + Err(e) if scope.budget.exhausted() => { + if scope.mode == Mode::Apply { + out.error = Some(e); + } else if scope.everywhere() { + scope.layer_blocks += 1; + } else { + out.residue += 1; + } + return None; + } Err(_) if scope.mode == Mode::Apply => { scope.report.forms_removed += 1; out.replaced.push(name); diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index 6df5102af..8407e0c8f 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -219,7 +219,8 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec, Vec) { (out, unreadable) } -/// A page's content streams: (the references or inline objects as listed, their decoded data). +/// A page's content streams: (the references or inline objects as listed, their decoded data), +/// decoded within one page's [`pdfcraft_cos::DecodeBudget`]. pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result<(Vec, Vec>), RedactError> { let list: Vec = match page.get(b"Contents") { None => Vec::new(), @@ -229,9 +230,10 @@ pub(crate) fn page_streams(doc: &Document, page: &Dict, index: usize) -> Result< }, }; let mut data = Vec::new(); + let mut budget = pdfcraft_cos::DecodeBudget::page(); for o in &list { match &*doc.resolve(o) { - Object::Stream(s) => data.push(s.decoded().map_err(|_| RedactError::Unreadable(index + 1))?), + Object::Stream(s) => data.push(budget.decode(s).map_err(|_| RedactError::Unreadable(index + 1))?), Object::Null => data.push(Vec::new()), _ => return Err(RedactError::Unreadable(index + 1)), } diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index 286667937..11cae9bb9 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -2472,3 +2472,20 @@ fn a_tj_surplus_arrays_undrawn_glyphs_are_not_seen_under_a_mark() { assert_eq!(under(&mut doc, 0, &[[45.0, 95.0, 50.0, 110.0]]), 0, "nothing is drawn past LASTARR's 35pt width"); assert_eq!(under(&mut doc, 0, &[[10.0, 95.0, 45.0, 110.0]]), 7, "LASTARR's 7 glyphs, the one the page draws"); } + +#[test] +fn form_entries_share_one_budget_per_page() { + // 20 draws of a form that draws the next 20 times, four levels down: 160,000 glyphs from a + // few hundred bytes. + let draws = "/X Do ".repeat(20); + let mut objs = vec![stream("/Type /XObject /Subtype /Form /Resources << /Font << /F1 5 0 R >> >>", b"BT /F1 10 Tf 10 100 Td (A) Tj ET")]; + for inner in 7..10 { + objs.push(stream(&format!("/Type /XObject /Subtype /Form /Resources << /XObject << /X {inner} 0 R >> >>"), draws.as_bytes())); + } + let mut doc = one_page(draws.as_bytes(), "/XObject << /X 10 0 R >>", objs); + let residue = under(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]]); + assert!(residue > 0 && residue <= pdfcraft_cos::MAX_PAGE_FORMS, "{residue} glyphs counted"); + // Applying refuses the page rather than leave forms past the budget unread. + mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], ""); + assert!(apply(&mut doc, None).is_err()); +}