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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn
This commit is contained in:
mtlcl410andClaude Opus 5.5 committed 2026-11-05 07:02:51 +08:00
1 parent 7c83ff748e
commit e21c600cdb
3 files changed
+118 -28

No files matched your search

+1 -1
View File
@@ -521,7 +521,7 @@ pub fn verify(doc: &Document, level: Level) -> Vec<Issue> {
}
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);
}
+77 -27
View File
@@ -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<Object>)], content: &HashSet<ObjRef>) -> 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<Object>)], content: &HashSet<ObjRef>) -> 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<ObjRef> = 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<ObjRef> {
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<f64> = 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))
})
}
+40
View File
@@ -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<String> { 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"]);
}