Redaction charges a page's content streams and its forms to one decode budget

This commit is contained in:
aiongg
2026-10-22 19:48:33 +00:00
parent e0e7d4462e
commit e2a6771641
4 changed files with 62 additions and 14 deletions
+7 -4
View File
@@ -106,13 +106,16 @@ 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.
/// Form entries and decoded bytes left for this page, its content streams already charged:
/// shared forms drawn from other shared forms would otherwise multiply the work with every
/// level.
budget: DecodeBudget,
}
impl<'a> Scope<'a> {
pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report) -> Self {
/// A scope whose form walk spends `budget`, what is left of the page's allowance after its
/// content streams were decoded.
pub fn new(rects: &'a [[f64; 4]], mode: Mode, report: &'a mut Report, budget: DecodeBudget) -> Self {
Scope {
rects,
mode,
@@ -127,7 +130,7 @@ impl<'a> Scope<'a> {
fonts: HashMap::new(),
used_names: Vec::new(),
depth: 0,
budget: DecodeBudget::page(),
budget,
}
}
+17 -5
View File
@@ -230,6 +230,17 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec<Mark>, Vec<usize>) {
/// 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> {
page_streams_within(doc, page, index, &mut pdfcraft_cos::DecodeBudget::page())
}
/// [`page_streams`], decoded against `budget`, which the walk of the page's forms then goes on
/// spending.
pub(crate) fn page_streams_within(
doc: &Document,
page: &Dict,
index: usize,
budget: &mut pdfcraft_cos::DecodeBudget,
) -> Result<(Vec<Object>, Vec<Vec<u8>>), RedactError> {
let list: Vec<Object> = match page.get(b"Contents") {
None => Vec::new(),
Some(c) => match &*doc.resolve(c) {
@@ -238,7 +249,6 @@ 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) {
// Strict: a tolerant decode keeps what precedes corruption and drops the rest, which
@@ -396,10 +406,11 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect();
// 1. Content.
let (list, data) = page_streams(doc, &page.dict, pi)?;
let mut budget = pdfcraft_cos::DecodeBudget::page();
let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?;
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let out = {
let mut scope = Scope::new(&rects, Mode::Apply, &mut report);
let mut scope = Scope::new(&rects, Mode::Apply, &mut report, budget);
process(doc, &mut scope, &data, &resources, pdfcraft_content::Matrix::IDENTITY)
};
// Fail closed: the interpreter lays vertical-writing text out horizontally, so it cannot
@@ -601,13 +612,14 @@ pub fn apply(doc: &mut Document, pages: Option<&[usize]>) -> Result<Report, Reda
for &(pi, page_marks) in &groups {
let page = pdfcraft_model::pages(doc).swap_remove(pi);
let rects: Vec<[f64; 4]> = page_marks.iter().flat_map(|m| m.rects.iter().copied()).collect();
let (list, data) = page_streams(doc, &page.dict, pi)?;
let mut budget = pdfcraft_cos::DecodeBudget::page();
let (list, data) = page_streams_within(doc, &page.dict, pi, &mut budget)?;
// The overlay stream draws text of its own: the overlay label is allowed.
let original: Vec<Vec<u8>> =
list.iter().zip(data).filter(|(o, _)| !o.as_ref().is_some_and(|r| report.overlays.contains(&r))).map(|(_, d)| d).collect();
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut scratch = Report::default();
let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch);
let mut scope = Scope::new(&rects, Mode::Verify, &mut scratch, budget);
residue += process(doc, &mut scope, &original, &resources, pdfcraft_content::Matrix::IDENTITY).residue;
}
if residue > 0 {
+4 -3
View File
@@ -8,7 +8,7 @@ use pdfcraft_content::Matrix;
use pdfcraft_cos::{Dict, Document, ObjRef, Object};
use crate::interp::{Mode, Scope, process};
use crate::{RedactError, Report, annots_of, listed_annotations, page_streams};
use crate::{RedactError, Report, annots_of, listed_annotations, page_streams, page_streams_within};
/// The categories of Acrobat's Remove Hidden Information panel that PdfCraft handles.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -356,7 +356,8 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
let n = pdfcraft_model::pages(doc).len();
for pi in 0..n {
let page = pdfcraft_model::pages(doc).swap_remove(pi);
let Ok((list, data)) = page_streams(doc, &page.dict, pi) else {
let mut budget = pdfcraft_cos::DecodeBudget::page();
let Ok((list, data)) = page_streams_within(doc, &page.dict, pi, &mut budget) else {
if write {
return Err(RedactError::Unreadable(pi + 1));
}
@@ -368,7 +369,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re
let resources = page.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut report = Report::default();
let mode = if write { Mode::Apply } else { Mode::Verify };
let mut scope = Scope::new(&[], mode, &mut report);
let mut scope = Scope::new(&[], mode, &mut report, budget);
if text {
let c = page.crop(doc);
scope.hidden_text = Some([c[0].min(c[2]), c[1].min(c[3]), c[0].max(c[2]), c[1].max(c[3])]);
+34 -2
View File
@@ -48,10 +48,11 @@ fn content(doc: &Document, page: usize) -> String {
/// Glyphs (and inline images) of page `page` under `rects` (the verifier's count).
fn under(doc: &mut Document, page: usize, rects: &[[f64; 4]]) -> usize {
let p = pdfcraft_model::pages(doc).swap_remove(page);
let (_, data) = page_streams(doc, &p.dict, page).unwrap();
let mut budget = pdfcraft_cos::DecodeBudget::page();
let (_, data) = page_streams_within(doc, &p.dict, page, &mut budget).unwrap();
let res = p.dict.get(b"Resources").and_then(|r| doc.resolve(r).as_dict().cloned()).unwrap_or_default();
let mut rep = Report::default();
let mut scope = Scope::new(rects, Mode::Verify, &mut rep);
let mut scope = Scope::new(rects, Mode::Verify, &mut rep, budget);
process(doc, &mut scope, &data, &res, pdfcraft_content::Matrix::IDENTITY).residue
}
@@ -2713,3 +2714,34 @@ fn repeated_name_tree_kids_count_towards_the_node_budget() {
let counts: std::collections::HashMap<Hidden, usize> = scan(&doc).into_iter().collect();
assert_eq!(counts[&Hidden::Attachments], 1, "the walk went past its node budget to the last kid");
}
/// Content streams and the forms they draw spend one page allowance: 130 MiB of content and a
/// 130 MiB form are each under it, together past it.
#[test]
fn content_and_forms_share_one_budget_per_page() {
use pdfcraft_cos::{Dict, Stream};
let mut doc = one_page(b"", "", vec![]);
let mut content = b"BT /F1 10 Tf 10 100 Td (SECRET) Tj ET /X Do\n%".to_vec();
content.resize(130 << 20, b'x');
let content = doc.add(Object::Stream(Stream::flate(Dict::new(), &content)));
let mut form = Dict::new();
form.set(b"Type".to_vec(), Object::name("XObject"));
form.set(b"Subtype".to_vec(), Object::name("Form"));
let mut body = b"%".to_vec();
body.resize(130 << 20, b'x');
let form = doc.add(Object::Stream(Stream::flate(form, &body)));
doc.update_dict(ObjRef::new(3, 0), |d| {
let mut xobjects = Dict::new();
xobjects.set(b"X".to_vec(), Object::Ref(form));
let mut fonts = Dict::new();
fonts.set(b"F1".to_vec(), Object::Ref(ObjRef::new(5, 0)));
let mut res = Dict::new();
res.set(b"Font".to_vec(), Object::Dict(fonts));
res.set(b"XObject".to_vec(), Object::Dict(xobjects));
d.set(b"Resources".to_vec(), Object::Dict(res));
d.set(b"Contents".to_vec(), Object::Ref(content));
})
.unwrap();
mark(&mut doc, 0, &[[0.0, 90.0, 50.0, 120.0]], "");
assert!(apply(&mut doc, None).is_err(), "the form past the page's allowance was read");
}