From ade98773701713ec0cc9ee2224be8d852ad8ac74 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Wed, 4 Nov 2026 22:06:46 +0100 Subject: [PATCH] M7: a font's stream behind several filters reads as it did without an allowance A DecodeBudget bounds what a stream's filter stages make together, and the first commit passed it each stream's own limit, so a /CIDToGIDMap of 65,536 CIDs behind ASCIIHex and Flate (131,072 bytes, with the hex stage's output on top) went unread, where a decode without an allowance bounds each stage alone and read it. Every Metrics::from_dict caller saw the font as missing those glyphs. The 16 MiB limits on CMaps and programs had the same edge. Each stage may now make up to the stream's own limit, as before, and the allowance pays for all of them; what the stream decodes to stays within its limit. Found by the Consumers review. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/fonts/src/pdf.rs | 11 +++++++++-- crates/fonts/src/pdf/tests.rs | 37 +++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index a2f28702ab..b51bb0a657 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -316,9 +316,16 @@ impl<'a> Reads<'a> { /// `stream` decoded within `max` and the allowance; `None` when it can't be, noting when the /// allowance is why. A stream past `max` is unread as before allowances, not refused. + /// + /// `max` bounds what the stream decodes to. Its filter stages are paid for together, up to + /// `max` for each, the most a decode without an allowance lets them make: a map of 65,536 CIDs + /// behind ASCIIHex and Flate makes all of `MAX_CID_TO_GID` in its last stage, with the hex + /// stage's output on top. fn decode(&mut self, stream: &Stream, max: usize) -> Option> { - match self.budget.decode_within(stream, max) { - Ok(data) => Some(data), + let stages = stream.filters().len().max(1); + match self.budget.decode_within(stream, max.saturating_mul(stages)) { + Ok(data) if data.len() <= max => Some(data), + Ok(_) => None, Err(_) => { self.refused |= self.budget.remaining() == 0; None diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index 798c619018..dbcf367bd4 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -297,6 +297,43 @@ fn a_cmap_past_its_own_limit_is_not_past_the_allowance() { assert_eq!(usize::MAX / 2 - budget.remaining(), sizes[..3].iter().sum::() + MAX_CMAP_BYTES); } +/// A `/CIDToGIDMap` as long as a map can be reads the same behind no filter, one or two: each +/// stage may make as much as the map's limit, so the hex stage's output on top of it doesn't +/// leave it unread. A map past the limit is unread behind any of them. +#[test] +fn a_full_cid_map_reads_behind_any_number_of_filters() { + let read = |entries: usize, filters: usize| { + let mut doc = Document::new_empty(); + let (mut f, _) = subset_type0(&mut doc, b""); + let map: Vec = (0..entries).flat_map(|cid| u16::try_from(cid % 2000).unwrap().to_be_bytes()).collect(); + let flate = Stream::flate(Dict::new(), &map); + let stream = match filters { + 0 => Stream::from_raw(Dict::new(), map), + 1 => flate, + _ => { + let hex: String = flate.raw.iter().map(|b| format!("{b:02X}")).chain([">".to_string()]).collect(); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, hex.into_bytes()) + } + }; + let map = Object::Ref(doc.add(Object::Stream(stream))); + let Some(Object::Array(mut descendants)) = f.get(b"DescendantFonts").cloned() else { panic!("descendant") }; + if let Some(Object::Dict(d)) = descendants.get_mut(0) { + d.set(b"CIDToGIDMap".to_vec(), map); + } + f.set(b"DescendantFonts".to_vec(), Object::Array(descendants)); + Metrics::from_dict(&doc, &f).outlined_cids + }; + let full = read(1 << 16, 1); + assert!(!full.is_empty()); + assert_eq!(read(1 << 16, 0), full); + assert_eq!(read(1 << 16, 2), full); + for filters in 0..=2 { + assert!(read((1 << 16) + 1, filters).is_empty(), "{filters} filters"); + } +} + /// Fonts that each name one large ToUnicode share the allowance they are read within: it pays for /// as many readings as it holds, and the rest are left unread, where each reading used to decode /// it afresh.