M11: 6.1.13 also reports reals past ±3.403e38 and CMaps mapping to CIDs above 65535

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 <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:16:15 +08:00
1 parent 31e67cbfec
commit 8d948e1dd0
3 files changed
+113 -5

No files matched your search

+8
View File
@@ -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<Issue> {
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);
}
+64 -4
View File
@@ -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 `<low> <high> cid` reaches
/// `cid + high − low`, a `begincidchar` entry `<code> 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<Object>)]) -> bool {
let mut budget = DecodeBudget::new(MAX_CMAP_BYTES, 0);
let mut read: HashSet<ObjRef> = 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<u64> {
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,
})
}
+41 -1
View File
@@ -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<String> { 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");
}