From fb01bbfe771f3d5f06d1a6f202011ae12d36d870 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 7 Nov 2026 03:27:33 +0100 Subject: [PATCH] M7: the editor's program read takes its limit once for each filter stage The editor's read of a font program bounded all its filter stages together by the program's 16 MiB limit, as main does: a 12 MiB program behind ASCIIHex and Flate, its Flate data about 8 MiB, was left unread, though behind Flate alone it reads. It now takes the limit once for each stage, as the metrics reads do, which the stages share and any one of them may use, and then holds the program itself to 16 MiB. Behind eight filters a read may so decode up to 128 MiB, where main stopped at 16 MiB. Once the allowance is spent, the metrics reads refuse a stream with data, and the editor's program read passes a program over, before resolving its /DecodeParms, as the decode itself would refuse it, so each font left doesn't pay for that work the allowance doesn't count. What is read and what is refused are as before, an empty stream still read rather than refused. Tests: an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit, and an_empty_stream_reads_with_nothing_left. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/fonts/src/pdf.rs | 21 ++++++++++++- crates/fonts/src/pdf/tests.rs | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index c0ae9e8765..b960909bfb 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -115,7 +115,20 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg let Some(object) = descriptor.get(key) else { continue }; let object = doc.resolve(object); let Object::Stream(stream) = object.as_ref() else { continue }; - let Ok(mut bytes) = budget.decode_within(&doc.with_direct_parms(stream), MAX_SFNT) else { continue }; + // As in `Reads::decode`: once the allowance is spent the decode refuses a program with + // data, so it is passed over before its parameters are resolved, work the allowance doesn't + // count; an empty one is no program either way. + if budget.remaining() == 0 { + continue; + } + let stream = doc.with_direct_parms(stream); + // As in `Reads::decode`: the stages share the limit once for each of them, which any one + // may use, and the program itself must be within it. + let stages = stream.filters().len().max(1); + let Ok(mut bytes) = budget.decode_within(&stream, MAX_SFNT.saturating_mul(stages)) else { continue }; + if bytes.len() > MAX_SFNT { + continue; + } let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue }; if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 { continue; @@ -328,6 +341,12 @@ impl<'a> Reads<'a> { /// round. A stream past `max` is unread, not refused, when what is left can pay for its stages; /// when it can't, the stream spends it and counts as refused. fn decode(&mut self, stream: &Stream, max: usize) -> Option> { + // Once the allowance is spent the decode refuses the stream; refused here, before the + // parameters are resolved, which is work the allowance doesn't count. + if self.budget.remaining() == 0 && !stream.raw.is_empty() { + self.refused = true; + return None; + } let stream = self.doc.with_direct_parms(stream); let stages = stream.filters().len().max(1); match self.budget.decode_within(&stream, max.saturating_mul(stages)) { diff --git a/crates/fonts/src/pdf/tests.rs b/crates/fonts/src/pdf/tests.rs index e770cd60c2..301d90be77 100644 --- a/crates/fonts/src/pdf/tests.rs +++ b/crates/fonts/src/pdf/tests.rs @@ -282,6 +282,20 @@ fn a_fonts_streams_are_paid_for_from_its_allowance() { assert_eq!(cost - budget.remaining(), sizes[0] + sizes[3]); } +/// With nothing left, an empty stream still reads, as the allowance reads one, and is no reason to +/// call the font read past it; a stream with data is left unread, and is. +#[test] +fn an_empty_stream_reads_with_nothing_left() { + let past = |data: &[u8]| { + let mut doc = Document::new_empty(); + let unicode = Object::Ref(doc.add(Object::Stream(Stream::from_raw(Dict::new(), data.to_vec())))); + let f = font(&mut doc, vec![("Subtype", Object::name("TrueType")), ("ToUnicode", unicode)]); + Metrics::from_dict_within(&doc, &f, &mut DecodeBudget::new(0, 0)).past_allowance + }; + assert!(!past(b"")); + assert!(past(b"1 beginbfchar <41> <0058> endbfchar")); +} + /// A stream behind one filter past its own limit, with more left than its limit, is left unread as /// it always was, paying for the limit, and is no reason to call the font read past the allowance. #[test] @@ -334,6 +348,50 @@ fn a_full_cid_map_reads_behind_one_or_two_filters() { } } +/// The editor's program is held to its limit as the metrics reads' streams are: a 12 MiB program +/// whose Flate data is about 8 MiB reads behind ASCIIHex and Flate as behind Flate alone, though +/// the two stages make about 20 MiB together. A program of the limit reads behind either, and one a +/// byte past it is unread behind either. +#[test] +fn an_editors_program_reads_behind_ascii_hex_and_flate_within_its_limit() { + let program = |len: usize, noise: usize| { + let mut program = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf").to_vec(); + // Bytes Flate can't shrink, then zeros. + let mut x = 1u32; + program.extend((0..noise).map(|_| { + x = x.wrapping_mul(1_103_515_245).wrapping_add(12_345); + x.to_be_bytes()[0] + })); + program.resize(len, 0); + program + }; + let read = |program: &[u8], hex: bool| { + let mut doc = Document::new_empty(); + let flate = Stream::flate(Dict::new(), program); + let stream = if hex { + let digits = b"0123456789ABCDEF"; + let mut data: Vec = flate.raw.iter().flat_map(|b| [digits[usize::from(b >> 4)], digits[usize::from(b & 15)]]).collect(); + data.push(b'>'); + let mut dict = Dict::new(); + dict.set(b"Filter".to_vec(), Object::Array(vec![Object::name("ASCIIHexDecode"), Object::name("FlateDecode")])); + Stream::from_raw(dict, data) + } else { + flate + }; + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), Object::Ref(doc.add(Object::Stream(stream)))); + let simple = font(&mut doc, vec![("FontDescriptor", Object::Dict(descriptor))]); + embedded_sfnt(&doc, &simple) + }; + let near = program(12 << 20, 8 << 20); + let full = program(MAX_SFNT, 0); + for hex in [false, true] { + assert_eq!(read(&near, hex).as_deref(), Some(near.as_slice()), "behind ASCIIHex too: {hex}"); + assert_eq!(read(&full, hex).as_deref(), Some(full.as_slice()), "behind ASCIIHex too: {hex}"); + assert!(read(&program(MAX_SFNT + 1, 0), hex).is_none(), "behind ASCIIHex too: {hex}"); + } +} + /// `data` in rows of `columns` bytes behind Flate and a PNG predictor, each row tagged Up (the /// difference from the row above), so read without the predictor it is garbled; with its /// `/DecodeParms` given by reference or direct.