From 8d948e1dd0e948ea7e35578d2e03e35194cbf229 Mon Sep 17 00:00:00 2001 From: mtlcl410 Date: Thu, 5 Nov 2026 07:16:15 +0800 Subject: [PATCH] =?UTF-8?q?M11:=206.1.13=20also=20reports=20reals=20past?= =?UTF-8?q?=20=C2=B13.403e38=20and=20CMaps=20mapping=20to=20CIDs=20above?= =?UTF-8?q?=2065535?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finishes the clause. Table C.1 of ISO 32000-1 Annex C bounds real values at about ±3.403 × 10^38 and character identifiers at 65535. A real past that range (an integer token too long even for a real reads as one) is reported, and so is an embedded CMap of a Type 0 font, or one it uses through /UseCMap, whose begincidrange entry reaches past CID 65535 or whose begincidchar entry names one (ISO 32000-1 9.7.5.4). A CMap named rather than embedded is a predefined one, within the limit. The CMaps are decoded within one 16 MiB allowance. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pq7HyDBXygTf7jcqdsjcfn --- crates/preflight/src/lib.rs | 8 ++++ crates/preflight/src/limits.rs | 68 ++++++++++++++++++++++++++++++++-- crates/preflight/src/tests.rs | 42 ++++++++++++++++++++- 3 files changed, 113 insertions(+), 5 deletions(-) diff --git a/crates/preflight/src/lib.rs b/crates/preflight/src/lib.rs index 059fff4a36..40f0bb78d1 100644 --- a/crates/preflight/src/lib.rs +++ b/crates/preflight/src/lib.rs @@ -117,6 +117,8 @@ pub const WORDINGS: &[&str] = &[ // The document as a whole. "The document is encrypted", "An integer is outside the 32-bit range", + "A real number is outside ±3.403 × 10³⁸", + "A CMap maps a character code to a CID above 65535", "A string is longer than 32767 bytes", "A name is longer than 127 bytes", "Page {} has a boundary smaller than 3 or larger than 14400 units", @@ -525,9 +527,15 @@ pub fn verify(doc: &Document, level: Level) -> Vec { if over.int { issue("6.1.13", "An integer is outside the 32-bit range", None, None, false); } + if over.real { + issue("6.1.13", "A real number is outside ±3.403 × 10³⁸", None, None, false); + } if over.string { issue("6.1.13", "A string is longer than 32767 bytes", None, None, false); } + if limits::cid_over_limit(doc, &objs) { + issue("6.1.13", "A CMap maps a character code to a CID above 65535", None, None, false); + } if over.name { issue("6.1.13", "A name is longer than 127 bytes", None, None, false); } diff --git a/crates/preflight/src/limits.rs b/crates/preflight/src/limits.rs index 7930cb9bc4..ec9886dcf5 100644 --- a/crates/preflight/src/limits.rs +++ b/crates/preflight/src/limits.rs @@ -6,8 +6,9 @@ //! 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. +//! Real numbers beyond ±3.403 × 10³⁸ are reported, and so is an embedded CMap of a Type 0 font +//! (or one it uses through `/UseCMap`) that maps a code to a CID above 65535. An integer token too +//! long even for a real reads as one past that range, and is reported as a real. use std::collections::HashSet; @@ -16,6 +17,14 @@ 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; const MAX_INT: i64 = i32::MAX as i64; +/// Table C.1: the largest real value (approximate). +const MAX_REAL: f64 = 3.403e38; +/// Table C.1: the largest character identifier. +const MAX_CID: u64 = 65535; +/// The decoded CMap data, across all the document's embedded CMaps, the scan reads. +const MAX_CMAP_BYTES: usize = 16 << 20; +/// How many CMaps a `/UseCMap` chain is followed through. +const MAX_CMAP_CHAIN: usize = 8; /// Table C.1: the longest string, in bytes. const MAX_STRING: usize = 32767; /// Table C.1: the longest name, in bytes. @@ -35,13 +44,14 @@ const BOXES: [&[u8]; 5] = [b"MediaBox", b"CropBox", b"BleedBox", b"TrimBox", b"A #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) struct Exceeded { pub(crate) int: bool, + pub(crate) real: bool, pub(crate) string: bool, pub(crate) name: bool, } impl Exceeded { fn all(self) -> bool { - self.int && self.string && self.name + self.int && self.real && self.string && self.name } fn name(&mut self, n: &[u8]) { @@ -55,6 +65,7 @@ impl Exceeded { } match o { Object::Int(i) => self.int |= !(MIN_INT..=MAX_INT).contains(i), + Object::Real(r) => self.real |= !r.is_finite() || r.abs() > MAX_REAL, Object::String(s) => self.string |= s.bytes.len() > MAX_STRING, Object::Name(n) => self.name(n), Object::Array(a) => { @@ -64,7 +75,7 @@ impl Exceeded { } Object::Dict(d) => self.dict(d, depth), Object::Stream(s) => self.dict(&s.dict, depth), - Object::Null | Object::Bool(_) | Object::Real(_) | Object::Ref(_) => {} + Object::Null | Object::Bool(_) | Object::Ref(_) => {} } } @@ -161,3 +172,52 @@ pub(crate) fn page_box_out_of_range(doc: &Document, page: &Dict) -> bool { [w, h].iter().any(|s| !(MIN_PAGE..=MAX_PAGE).contains(s)) }) } + +/// Whether an embedded CMap of a Type 0 font among `objs`, or one it uses through `/UseCMap`, maps +/// a code to a CID above 65535: a `begincidrange` entry ` cid` reaches +/// `cid + high − low`, a `begincidchar` entry ` cid` reaches `cid` (ISO 32000-1 9.7.5.4). A +/// CMap named rather than embedded is a predefined one, within the limit. The CMaps are decoded +/// within one allowance for the document. +pub(crate) fn cid_over_limit(doc: &Document, objs: &[(ObjRef, std::sync::Arc)]) -> bool { + let mut budget = DecodeBudget::new(MAX_CMAP_BYTES, 0); + let mut read: HashSet = HashSet::new(); + for (_, o) in objs { + let Some(d) = o.as_dict() else { continue }; + if d.name(b"Subtype") != Some(b"Type0") { + continue; + } + let mut next = d.get(b"Encoding").and_then(Object::as_ref); + for _ in 0..MAX_CMAP_CHAIN { + let Some(r) = next.filter(|r| read.insert(*r)) else { break }; + if budget.remaining() == 0 { + return false; + } + let Object::Stream(cmap) = &*doc.get(r) else { break }; + if budget.decode(cmap).is_ok_and(|data| maps_past_limit(&data)) { + return true; + } + next = cmap.dict.get(b"UseCMap").and_then(Object::as_ref); + } + } + false +} + +/// Whether the CMap program `data` maps a code to a CID above [`MAX_CID`]. The entries of a +/// `begincidrange` … `endcidrange` block are the operands of its `endcidrange`, read as content is. +fn maps_past_limit(data: &[u8]) -> bool { + let value = |o: &Object| -> Option { + match o { + Object::String(s) if s.bytes.len() <= 4 => Some(s.bytes.iter().fold(0u64, |v, b| (v << 8) | u64::from(*b))), + Object::Int(i) => u64::try_from(*i).ok(), + _ => None, + } + }; + pdfcraft_content::ops(data).any(|op| match op.op.as_slice() { + b"endcidrange" => op.operands.as_chunks::<3>().0.iter().any(|[low, high, cid]| match (value(low), value(high), value(cid)) { + (Some(low), Some(high), Some(cid)) => cid.saturating_add(high.saturating_sub(low)) > MAX_CID, + _ => false, + }), + b"endcidchar" => op.operands.as_chunks::<2>().0.iter().any(|[_, cid]| value(cid).is_some_and(|cid| cid > MAX_CID)), + _ => false, + }) +} diff --git a/crates/preflight/src/tests.rs b/crates/preflight/src/tests.rs index 0255348361..6893a42e2f 100644 --- a/crates/preflight/src/tests.rs +++ b/crates/preflight/src/tests.rs @@ -566,5 +566,45 @@ endstream", 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"]); + // Corners near ±1e308 are reals past the limit too, and the width overflows to infinity. + assert_eq!( + limits(&page("|", "", "[-1e308 0 1e308 200]", "")), + ["A real number is outside ±3.403 × 10³⁸", "Page 1 has a boundary smaller than 3 or larger than 14400 units"] + ); +} + +/// 6.1.13's two remaining limits of ISO 32000-1 Annex C: a real number beyond ±3.403 × 10³⁸, and +/// a CID above 65535 that an embedded CMap of a Type 0 font maps to (a `begincidrange` reaching +/// past it, a `begincidchar` naming one, or a CMap it uses through `/UseCMap`). +#[test] +fn implementation_limits_cover_reals_and_cids() { + 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 = |dict: &str, data: &str| format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()); + let doc = |catalog_extra: &str, cmap: &str, used: &str| { + let first = stream("/Type /CMap /CMapName /Test-A /UseCMap 6 0 R", cmap); + let second = stream("/Type /CMap /CMapName /Test-B", used); + build( + &[ + &format!("<< /Type /Catalog /Pages 2 0 R {catalog_extra} >>"), + "<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 200 200] >>", + "<< /Type /Page /Parent 2 0 R /Resources << /Font << /F 4 0 R >> >> >>", + "<< /Type /Font /Subtype /Type0 /BaseFont /Test /Encoding 5 0 R /DescendantFonts [] >>", + &first, + &second, + ], + "", + ) + }; + let range = |low: &str, high: &str, cid: u32| format!("1 begincidrange <{low}> <{high}> {cid} endcidrange"); + let near = format!("34{}.0", "0".repeat(37)); + let far = format!("4{}.0", "0".repeat(38)); + assert!(limits(&doc(&format!("/R {near} /S -{near}"), &range("0000", "00ff", 65280), "")).is_empty(), "both at their limits"); + assert_eq!(limits(&doc(&format!("/R {far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸"]); + assert_eq!(limits(&doc(&format!("/R -{far}"), "", "")), ["A real number is outside ±3.403 × 10³⁸"]); + let cid = ["A CMap maps a character code to a CID above 65535"]; + assert_eq!(limits(&doc("", &range("3f00", "3fff", 65536), "")), cid); + assert_eq!(limits(&doc("", &range("0000", "0100", 65280), "")), cid, "the range reaches 65536"); + assert_eq!(limits(&doc("", "1 begincidchar <01> 70000 endcidchar", "")), cid); + assert_eq!(limits(&doc("", &range("0000", "00ff", 0), &range("3f00", "3fff", 65536))), cid, "through /UseCMap"); }