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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY
This commit is contained in:
Grzegorz MartensandClaude Opus 5.5 committed 2026-11-07 04:37:45 +01:00
1 parent 02360536a7
commit fb01bbfe77
2 files changed
+78 -1

No files matched your search

+20 -1
View File
@@ -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<Vec<u8>> {
// 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)) {
+58
View File
@@ -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<u8> = 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.