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.
This commit is contained in:
aiongg
2026-10-22 16:57:55 +00:00
parent bb8ea65ffd
commit d2e7ce72f0
3 changed files with 44 additions and 4 deletions
+23 -2
View File
@@ -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<Vec<u8>, Rc<Metrics>>,
used_names: Vec<Vec<u8>>,
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);
+4 -2
View File
@@ -219,7 +219,8 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec<Mark>, Vec<usize>) {
(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<Object>, Vec<Vec<u8>>), RedactError> {
let list: Vec<Object> = 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)),
}
+17
View File
@@ -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());
}