From e21c600cdba36edfa417011371238573a059ae07 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:02:51 +0800 Subject: [PATCH] M11: 6.1.13 reads the trailer, joined page contents and deep values, within one budget From the review of #6202: - the trailer is scanned with the indirect objects; - a page's /Contents streams are read joined, as one content stream: operands may end one stream and their operator start the next (ISO 32000-1 7.8.2); - values are followed as deep as the parser nests them (128); - content is decoded within one 256 MiB allowance for the document; - a page box whose size overflows to infinity is reported; - the test covers a long name used as a content operand. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 2 +- crates/preflight/src/limits.rs | 104 ++++++++++++++++++++++++--------- crates/preflight/src/tests.rs | 40 +++++++++++++ 3 files changed, 118 insertions(+), 28 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 63ceaf9aa1..059fff4a36 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -521,7 +521,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec { } check_objects(doc, level, &objs, &page_of, &mut issue); // 6.1.13: the implementation limits of ISO 32000-1 Annex C. - let over = limits::exceeded(&objs, &referenced_content(doc, &objs)); + let over = limits::exceeded(doc, &objs, &referenced_content(doc, &objs)); if over.int { issue("6.1.13", "An integer is outside the 32-bit range", None, None, false); } diff --git a/crates/preflight/src/limits.rs b/crates/preflight/src/limits.rs index 0c86e78d46..7930cb9bc4 100644 --- a/crates/preflight/src/limits.rs +++ b/crates/preflight/src/limits.rs @@ -2,13 +2,16 @@ //! and the page size limits after it): integers that fit in 32 bits, strings of at most 32767 //! bytes, names of at most 127 bytes, and page boundaries between 3 and 14400 units wide and high. //! -//! The values are read from the indirect objects and from the content streams, as parsed: a name -//! written with `#xx` escapes counts its decoded bytes. Character identifiers above 65535 (in a -//! CMap) and the real-number range are not checked here. +//! The values are read from the trailer, the indirect objects and the content streams, as parsed: +//! a name written with `#xx` escapes counts its decoded bytes. A page's `/Contents` streams are read +//! joined, as one content stream (an operator's operands may end one stream and the operator start +//! the next, ISO 32000-1 7.8.2). Content is decoded within one allowance for the whole document. +//! Character identifiers above 65535 (in a CMap) and the real-number range are not checked here, +//! nor an integer token too long for 64 bits, which the parser reads as a real. use std::collections::HashSet; -use pdfcraft_cos::{Dict, Document, ObjRef, Object}; +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object}; /// Table C.1: the smallest and largest integer values. const MIN_INT: i64 = i32::MIN as i64; @@ -20,8 +23,11 @@ const MAX_NAME: usize = 127; /// The smallest and largest page boundary, in default user space units. const MIN_PAGE: f64 = 3.0; const MAX_PAGE: f64 = 14400.0; -/// How deep a direct object is followed (arrays and dictionaries inside one another). -const MAX_DEPTH: u8 = 32; +/// How deep a direct object is followed (arrays and dictionaries inside one another): as deep as +/// the parser nests them. +const MAX_DEPTH: u8 = 128; +/// The decoded content, across all the document's content streams, the scan reads. +const MAX_CONTENT_BYTES: usize = 256 << 20; /// The page boundaries, of which only the media and crop boxes are inherited. const BOXES: [&[u8]; 5] = [b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"ArtBox"]; @@ -62,6 +68,21 @@ impl Exceeded { } } + /// The operands of one content stream, inline images' dictionaries included. + fn content(&mut self, data: &[u8]) { + for op in pdfcraft_content::ops(data) { + if self.all() { + return; + } + for v in &op.operands { + self.value(v, 0); + } + if let Some((image, _)) = &op.inline { + self.dict(image, 0); + } + } + } + fn dict(&mut self, d: &Dict, depth: u8) { for (k, v) in d.iter() { self.name(k); @@ -70,36 +91,64 @@ impl Exceeded { } } -/// The limits that the indirect objects `objs`, and the content streams among them (`content`: -/// the ones only a reference says hold content, besides form XObjects and appearance streams), go -/// past. -pub(crate) fn exceeded(objs: &[(ObjRef, std::sync::Arc)], content: &HashSet) -> Exceeded { +/// The limits that the trailer, the indirect objects `objs` and the content streams go past. +/// `content` holds the content streams only a reference identifies (a page's `/Contents`, Type 3 +/// glyph procedures), besides form XObjects and appearance streams, which say so themselves. +pub(crate) fn exceeded(doc: &Document, objs: &[(ObjRef, std::sync::Arc)], content: &HashSet) -> Exceeded { let mut found = Exceeded::default(); - for (r, o) in objs { + found.dict(doc.trailer(), 0); + for (_, o) in objs { if found.all() { - break; + return found; } found.value(o, 0); - if let Object::Stream(s) = &**o - && (content.contains(r) || crate::is_content(&s.dict)) - && let Ok(data) = s.decoded() - { - for op in pdfcraft_content::ops(&data) { - if found.all() { - break; - } - for v in &op.operands { - found.value(v, 0); - } - if let Some((image, _)) = &op.inline { - found.dict(image, 0); - } + } + let mut budget = DecodeBudget::new(MAX_CONTENT_BYTES, 0); + let mut read: HashSet = HashSet::new(); + for page in pdfcraft_model::pages(doc) { + if found.all() || budget.remaining() == 0 { + return found; + } + let refs = contents(doc, &page.dict); + let mut joined = Vec::new(); + for r in &refs { + if let Object::Stream(s) = &*doc.get(*r) + && let Ok(data) = budget.decode(s) + { + joined.extend_from_slice(&data); + joined.push(b' '); } } + read.extend(refs); + found.content(&joined); + } + for (r, o) in objs { + if found.all() || budget.remaining() == 0 { + break; + } + if let Object::Stream(s) = &**o + && !read.contains(r) + && (content.contains(r) || crate::is_content(&s.dict)) + && let Ok(data) = budget.decode(s) + { + found.content(&data); + } } found } +/// A page's `/Contents` streams, in order. +fn contents(doc: &Document, page: &Dict) -> Vec { + match page.get(b"Contents") { + Some(Object::Ref(r)) => match &*doc.get(*r) { + Object::Array(a) => a.iter().filter_map(Object::as_ref).collect(), + _ => vec![*r], + }, + Some(Object::Array(a)) => a.iter().filter_map(Object::as_ref).collect(), + _ => Vec::new(), + } +} + /// Whether one of a page's boundaries (with the media and crop boxes it inherits) is narrower or /// lower than 3 units, or wider or higher than 14400. pub(crate) fn page_box_out_of_range(doc: &Document, page: &Dict) -> bool { @@ -108,6 +157,7 @@ pub(crate) fn page_box_out_of_range(doc: &Document, page: &Dict) -> bool { let v: Vec = b.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect()).unwrap_or_default(); let [x0, y0, x1, y1] = v.as_slice() else { return false }; let (w, h) = ((x1 - x0).abs(), (y1 - y0).abs()); - [w, h].iter().any(|s| s.is_finite() && !(MIN_PAGE..=MAX_PAGE).contains(s)) + // A size past `f64` (corners near ±1e308) is as much too large as any other. + [w, h].iter().any(|s| !(MIN_PAGE..=MAX_PAGE).contains(s)) }) } diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 5adf411714..0255348361 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -521,6 +521,7 @@ fn implementation_limits_are_reported() { let name = "n".repeat(128); assert_eq!(limits(&doc(&format!("/V /{name}"), "", ok, "")), ["A name is longer than 127 bytes"]); assert_eq!(limits(&doc(&format!("/{name} 1"), "", ok, "")), ["A name is longer than 127 bytes"]); + assert_eq!(limits(&doc("", &format!("/{name} Do"), ok, "")), ["A name is longer than 127 bytes"]); // 200 bytes as written, 66 once the escapes are read. let escaped = format!("A{}", "#41".repeat(65) + &"B".repeat(4)); assert!(limits(&doc(&format!("/V /{escaped}"), "", ok, "")).is_empty()); @@ -528,3 +529,42 @@ fn implementation_limits_are_reported() { assert_eq!(limits(&doc("", "", ok, "/CropBox [0 0 14401 200]")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); assert!(limits(&doc("", "", "[0 0 2 2]", "/MediaBox [0 0 3 14400]")).is_empty(), "the page's own box overrides its parent's"); } + +/// 6.1.13 also reads the trailer, a page's `/Contents` streams joined (operands may end one +/// stream and their operator start the next, ISO 32000-1 7.8.2), values nested as deep as the +/// parser allows, and a page box whose size overflows to infinity. +#[test] +fn implementation_limits_reach_the_trailer_split_content_deep_values_and_huge_boxes() { + let limits = + |doc: &Document| -> Vec { verify(doc, Level::A2b).into_iter().filter(|i| i.clause == "6.1.13").map(|i| i.message).collect() }; + let stream = |data: &str| { + format!( + "<< /Length {} >> +stream +{data} +endstream", + data.len() + ) + }; + let page = |contents: &str, catalog_extra: &str, media: &str, trailer: &str| { + let (first, second) = contents.split_once('|').unwrap_or((contents, "")); + build( + &[ + &format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + &format!("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox {media} >>"), + "<< /Type /Page /Parent 2 0 R /Contents [4 0 R 5 0 R] >>", + &stream(first), + &stream(second), + ], + trailer, + ) + }; + let ok = "[0 0 200 200]"; + assert!(limits(&page("|", "", ok, "")).is_empty()); + assert_eq!(limits(&page("|", "", ok, "/Custom 2147483648")), ["An integer is outside the 32-bit range"]); + let long = "x".repeat(32768); + assert_eq!(limits(&page(&format!("BT ({long})|Tj ET"), "", ok, "")), ["A string is longer than 32767 bytes"]); + let deep = format!("/Deep {}2147483648{}", "[".repeat(40), "]".repeat(40)); + assert_eq!(limits(&page("|", &deep, ok, "")), ["An integer is outside the 32-bit range"]); + assert_eq!(limits(&page("|", "", "[-1e308 0 1e308 200]", "")), ["Page 1 has a boundary smaller than 3 or larger than 14400 units"]); +}