fix(audit): page content and forms share one decode budget per page

The cover walk decoded every /Contents entry and every form draw with
a fresh 1 GiB allowance; the operator bound does not count whitespace,
so a large blank form drawn many times meant hours of decoding. The
walk charges a per-page DecodeBudget with the strict decoder, and
spending it fails the audit.
This commit is contained in:
aiongg
2026-10-22 16:57:55 +00:00
parent ea4939cd08
commit b5cddc884d
2 changed files with 28 additions and 5 deletions
+9 -5
View File
@@ -16,7 +16,7 @@
//! Positions compare lexicographically in paint order.
use pdfcraft_content::{Matrix, Op, Pieces, parse};
use pdfcraft_cos::{Dict, Document, Object};
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
use crate::AuditError;
@@ -109,6 +109,8 @@ struct Walker<'a> {
pieces: &'a Pieces,
out: Vec<FilledRect>,
ops_seen: usize,
/// Decoded bytes left for the page, its forms included: spending it fails the audit.
budget: DecodeBudget,
}
impl<'a> Walker<'a> {
@@ -272,7 +274,7 @@ impl<'a> Walker<'a> {
.unwrap_or(Matrix::IDENTITY);
let sub_resources = dict.get(b"Resources").map(|o| self.doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
// A form stream that cannot be decoded fails the audit: it might hide a cover.
let data = stream.decoded_strict()?;
let data = self.budget.decode_strict(stream)?;
let ops = parse(&data).ops;
// The XObject matrix maps form space into the user space active at `Do`.
let mut sub_gs = gs.clone();
@@ -304,7 +306,8 @@ fn ext_alpha(doc: &Document, resources: &Dict, name: &[u8]) -> f64 {
/// Every opaque filled rectangle drawn by a page's content (and its form XObjects), with paint
/// positions. A page's content streams are walked once, joined, with one graphics state, so
/// state set in an earlier stream is still in force in the later ones. Any content stream that
/// cannot be decoded is an audit failure, never an empty result.
/// cannot be decoded, or content and forms that decode past one page's [`DecodeBudget`] in all,
/// is an audit failure, never an empty result.
pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, AuditError> {
let resources = page.get(b"Resources").map(|o| doc.resolve(o)).and_then(|o| o.as_dict().cloned()).unwrap_or_default();
let contents: Vec<Object> = match page.get(b"Contents") {
@@ -314,15 +317,16 @@ pub fn filled_rects(doc: &Document, page: &Dict) -> Result<Vec<FilledRect>, Audi
_ => vec![c.clone()],
},
};
let mut budget = DecodeBudget::page();
let mut streams = Vec::with_capacity(contents.len());
for c in &contents {
if let Object::Stream(s) = &*doc.resolve(c) {
streams.push(s.decoded_strict()?);
streams.push(budget.decode_strict(s)?);
}
}
let pieces = Pieces::join(&streams);
let parsed = pieces.parse();
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0 };
let mut w = Walker { doc, pieces: &pieces, out: Vec::new(), ops_seen: 0, budget };
let mut gs = Gs::default();
let mut stack = Vec::new();
w.walk_ops(&parsed.ops, &mut gs, &mut stack, &resources, 0, None)?;
+19
View File
@@ -592,3 +592,22 @@ fn reading_blocks_respect_stream_comments_and_split_operands() {
assert_eq!(text, "Replacement text", "{}", String::from_utf8_lossy(s1));
}
}
#[test]
fn repeated_stream_references_share_one_decode_budget() {
use pdfcraft_cos::{Dict, Document, Object, Stream};
// One 1 MiB stream of spaces (a kilobyte on disk), named 300 times: by the page's /Contents,
// and by 300 draws of a form holding it.
let mut doc = Document::new_empty();
let spaces = doc.add(Stream::flate(Dict::new(), &vec![b' '; 1 << 20]));
let contents: Dict = [(b"Contents".to_vec(), Object::Array(vec![Object::Ref(spaces); 300]))].into_iter().collect();
assert!(crate::cover::filled_rects(&doc, &contents).is_err(), "repeated content streams");
let form_dict: Dict = [(b"Subtype".to_vec(), Object::Name(b"Form".to_vec()))].into_iter().collect();
let form = doc.add(Stream::flate(form_dict, &vec![b' '; 1 << 20]));
let draws = doc.add(Stream::from_raw(Dict::new(), "/X Do ".repeat(300).into_bytes()));
let xobjects: Dict = [(b"X".to_vec(), Object::Ref(form))].into_iter().collect();
let resources: Dict = [(b"XObject".to_vec(), Object::Dict(xobjects))].into_iter().collect();
let page: Dict = [(b"Contents".to_vec(), Object::Ref(draws)), (b"Resources".to_vec(), Object::Dict(resources))].into_iter().collect();
assert!(crate::cover::filled_rects(&doc, &page).is_err(), "repeated form draws");
}