diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 08654692d5..0067304f66 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -246,12 +246,14 @@ pub struct DecodeBudget { bytes: usize, forms: usize, seen: HashSet, + /// Decodes refused for lack of allowance ([`DecodeBudget::refused`]). + refusals: usize, } impl DecodeBudget { /// An allowance of `bytes` decoded bytes and `forms` form entries. pub fn new(bytes: usize, forms: usize) -> Self { - Self { limit: bytes, bytes, forms, seen: HashSet::new() } + Self { limit: bytes, bytes, forms, seen: HashSet::new(), refusals: 0 } } /// The allowance for one page: [`MAX_PAGE_DECODED`] bytes and [`MAX_PAGE_FORMS`] forms. @@ -264,6 +266,22 @@ impl DecodeBudget { self.bytes } + /// How many decodes were refused for lack of allowance: one that came once it was spent, or + /// ran past what was left. A decode that spent exactly what was left wasn't refused, so an + /// allowance used up to the byte has none. + pub fn refused(&self) -> usize { + self.refusals + } + + /// Whether the allowance is spent and `stream`, which has data, would be refused, noting the + /// refusal ([`DecodeBudget::refused`]): a caller asks this before resolving the stream's + /// parameters, work the allowance doesn't count. A stream without data still reads. + pub fn refuses(&mut self, stream: &Stream) -> bool { + let refuses = self.bytes == 0 && !stream.raw.is_empty(); + self.refusals += usize::from(refuses); + refuses + } + /// Whether a decode was refused for lack of allowance or a form entry for lack of visits. pub fn exhausted(&self) -> bool { self.bytes == 0 || self.forms == 0 @@ -284,16 +302,21 @@ impl DecodeBudget { } /// Like [`DecodeBudget::decode_within`], with every filter stage held to `max`, as - /// [`Stream::decoded_within`] holds it, and the stages together to `max` for each of them. A + /// [`Stream::decoded_within`] holds it, and the stages together to `max` for each of them, the + /// stream's `/DecodeParms` given by reference read in `doc` through a view of it that shares + /// its data and copies no more of its dictionary than its filters and parameters + /// ([`DecodeBudget::decode_with_damage`] reads it so). A /// stream so reads here when it reads without an allowance and the allowance can pay for /// what its stages decode, the output of one stage on top of another's. A stage past `max` /// is an error that charges what the stages before it decoded and `max`, the work it took to /// find out; past the stages' total it charges that total, and past what is left it spends /// all of it, as [`DecodeBudget::decode_within`] does. - pub fn decode_stages_within(&mut self, stream: &Stream, max: usize) -> Result, CosError> { - if self.bytes == 0 && !stream.raw.is_empty() { + pub fn decode_stages_within(&mut self, doc: &Document, stream: &Stream, max: usize) -> Result, CosError> { + // Before the parameters are resolved, work the allowance doesn't count. + if self.refuses(stream) { return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } + let stream = doc.decoding_view(stream); let total = max.saturating_mul(stream.filters().len().max(1)); let room = total.min(self.bytes); match stream.decoded_within_each_counted(max, room) { @@ -313,6 +336,7 @@ impl DecodeBudget { } Err((FilterError::LimitExceeded(_), _)) => { self.bytes = 0; + self.refusals += 1; Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) } Err((e, spent)) => { @@ -343,7 +367,7 @@ impl DecodeBudget { /// pays for the bytes decoded before the error, so referencing a stream again and again /// spends the allowance by the work each decode does. fn charge(&mut self, stream: &Stream, max: usize, decode: Decoder) -> Result<(Vec, bool), CosError> { - if self.bytes == 0 && !stream.raw.is_empty() { + if self.refuses(stream) { return Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())); } if max < self.bytes { @@ -363,6 +387,7 @@ impl DecodeBudget { } Err((FilterError::LimitExceeded(_), _)) => { self.bytes = 0; + self.refusals += 1; Err(CosError::Filter(FilterError::LimitExceeded(self.limit).to_string())) } Err((e, spent)) => { @@ -1087,20 +1112,20 @@ mod tests { let max = 1 << 16; let (fits, inner) = hexed(&vec![0u8; max]); let mut budget = DecodeBudget::new(1 << 20, 0); - assert_eq!(budget.decode_stages_within(&fits, max).map(|d| d.len()).ok(), Some(max)); + assert_eq!(budget.decode_stages_within(&Document::new_empty(), &fits, max).map(|d| d.len()).ok(), Some(max)); assert_eq!(budget.remaining(), (1 << 20) - inner - max); let (bomb, inner) = hexed(&vec![0u8; 8 * max]); let mut budget = DecodeBudget::new(1 << 20, 0); - assert!(budget.decode_stages_within(&bomb, max).is_err()); + assert!(budget.decode_stages_within(&Document::new_empty(), &bomb, max).is_err()); assert_eq!(budget.remaining(), (1 << 20) - inner - max, "the Flate stage stopped at the limit"); // Flate making 1.2 times the limit of hex text, which ASCIIHex halves: the stages together // make 1.8 times the limit, within twice it, but the Flate stage alone is past it. let text = "00".repeat(max * 6 / 10).into_bytes(); let wide = chained(&Stream::flate(Dict::new(), &text).raw, ["FlateDecode", "ASCIIHexDecode"]); assert!(wide.decoded_within(max).is_err()); - assert!(DecodeBudget::new(1 << 20, 0).decode_stages_within(&wide, max).is_err()); + assert!(DecodeBudget::new(1 << 20, 0).decode_stages_within(&Document::new_empty(), &wide, max).is_err()); let mut budget = DecodeBudget::new(max, 0); - assert!(budget.decode_stages_within(&fits, max).is_err()); + assert!(budget.decode_stages_within(&Document::new_empty(), &fits, max).is_err()); assert_eq!(budget.remaining(), 0); } @@ -1128,12 +1153,12 @@ mod tests { // A byte more of the room than the hex stage and the limit: the Flate stage stops at the // limit, so that is what it charges. let mut budget = DecodeBudget::new(inner + max + 1, 0); - assert!(says(budget.decode_stages_within(&two(&bomb), max), max)); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), max)); assert_eq!(budget.remaining(), 1); // Exactly the hex stage and the limit: what was left stopped it as much as the limit did, // so it is past the allowance, spends it and reports the allowance's limit. let mut budget = DecodeBudget::new(inner + max, 0); - assert!(says(budget.decode_stages_within(&two(&bomb), max), inner + max)); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&bomb), max), inner + max)); assert_eq!(budget.remaining(), 0); // The same data padded to exactly the limit: the hex stage makes all of it, and the Flate // stage, at the limit, reaches the stages' total. With more of the allowance left, that @@ -1141,22 +1166,22 @@ mod tests { let mut padded = bomb.clone(); padded.resize(max, 0); let mut budget = DecodeBudget::new(10 * max, 0); - assert!(says(budget.decode_stages_within(&two(&padded), max), 2 * max)); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 2 * max)); assert_eq!(budget.remaining(), 8 * max); // With exactly the total left (of an allowance of three times the limit), it is past the // allowance: all of it is spent, and the allowance's limit reported. let mut budget = DecodeBudget::new(3 * max, 0); budget.decode(&Stream::from_raw(Dict::new(), vec![0; max])).unwrap(); - assert!(says(budget.decode_stages_within(&two(&padded), max), 3 * max)); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), 3 * max)); assert_eq!(budget.remaining(), 0); // With less than the total left, the same. let mut budget = DecodeBudget::new(max, 0); - assert!(says(budget.decode_stages_within(&two(&padded), max), max)); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &two(&padded), max), max)); assert_eq!(budget.remaining(), 0); // With nothing left, a stream with data is refused before it is decoded, even one that // decodes to nothing; an empty one reads. let mut budget = DecodeBudget::new(0, 0); - assert!(says(budget.decode_stages_within(&Stream::flate(Dict::new(), b""), max), 0)); - assert_eq!(budget.decode_stages_within(&Stream::from_raw(Dict::new(), Vec::new()), max).ok(), Some(Vec::new())); + assert!(says(budget.decode_stages_within(&Document::new_empty(), &Stream::flate(Dict::new(), b""), max), 0)); + assert_eq!(budget.decode_stages_within(&Document::new_empty(), &Stream::from_raw(Dict::new(), Vec::new()), max).ok(), Some(Vec::new())); } } diff --git a/crates/fonts/src/pdf.rs b/crates/fonts/src/pdf.rs index e70683d55c..b67152684e 100644 --- a/crates/fonts/src/pdf.rs +++ b/crates/fonts/src/pdf.rs @@ -122,14 +122,10 @@ 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 }; - // 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; - } - // As in `Reads::decode`: each stage within the limit, the program, the last, too. - let Ok(mut bytes) = budget.decode_stages_within(&doc.with_direct_parms(stream), MAX_SFNT) else { continue }; + // As in `Reads::decode`: each stage within the limit, the program, the last, too, read + // through a view of the stream; once the allowance is spent a program with data is refused + // before its parameters are resolved. + let Ok(mut bytes) = budget.decode_stages_within(doc, stream, MAX_SFNT) else { 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; @@ -408,8 +404,8 @@ impl<'a> Reads<'a> { } /// `stream` decoded within `max` and the allowance, its `/DecodeParms` read where they are given - /// by reference too ([`Document::with_direct_parms`]); `None` when it can't be, noting when the - /// allowance is why. + /// by reference too, through a view of the stream that copies no more of its dictionary than + /// its filters and parameters; `None` when it can't be, noting when the allowance is why. /// /// Each of its filter stages is held to `max`, as a decode without an allowance holds it, and /// the stages together to `max` for each of them ([`DecodeBudget::decode_stages_within`]): a @@ -421,13 +417,7 @@ impl<'a> Reads<'a> { /// the stages' total (behind one filter, `max`), it is charged that total and isn't refused; /// with no more left than that total, it spends all of 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; - } - match self.budget.decode_stages_within(&self.doc.with_direct_parms(stream), max) { + match self.budget.decode_stages_within(self.doc, stream, max) { Ok(data) => Some(data), Err(_) => { self.refused |= self.budget.remaining() == 0; @@ -488,8 +478,15 @@ fn type3_glyph_box(doc: &Document, font: &Dict, reads: &mut Reads<'_>) -> Option let mut union: Option<[f64; 4]> = None; for proc in chosen { let Object::Stream(s) = &*doc.resolve(proc) else { continue }; + // Once the reading's allowance is spent the rest go unread, before their parameters are + // resolved. + if reads.budget.refuses(s) { + reads.refused = true; + complete = false; + break; + } let before = reads.budget.remaining(); - let read = reads.budget.decode_within(&doc.with_direct_parms(s), MAX_TYPE3_GLYPH_BYTES.min(left)); + let read = reads.budget.decode_with_damage(doc, s, MAX_TYPE3_GLYPH_BYTES.min(left)).map(|(data, _)| data); left = left.saturating_sub(before.saturating_sub(reads.budget.remaining())); let Ok(data) = read else { if reads.budget.remaining() == 0 { diff --git a/crates/fonts/src/pdf/thai.rs b/crates/fonts/src/pdf/thai.rs index e15799ed04..f34111cd5d 100644 --- a/crates/fonts/src/pdf/thai.rs +++ b/crates/fonts/src/pdf/thai.rs @@ -173,7 +173,7 @@ fn embedded_truetype(doc: &Document, descendant: &Dict) -> Option> { let descriptor = dict(doc, descendant.get(b"FontDescriptor"))?; let program = doc.resolve(descriptor.get(b"FontFile2")?); let Object::Stream(program) = program.as_ref() else { return None }; - DecodeBudget::new(super::MAX_SFNT, 0).decode_within(&doc.with_direct_parms(program), super::MAX_SFNT).ok() + DecodeBudget::new(super::MAX_SFNT, 0).decode_with_damage(doc, program, super::MAX_SFNT).ok().map(|(data, _)| data) } /// Source glyph positions and the original PDF character codes that draw them. Empty codes diff --git a/crates/fonts/tests/stream_views.rs b/crates/fonts/tests/stream_views.rs new file mode 100644 index 0000000000..de210637ae --- /dev/null +++ b/crates/fonts/tests/stream_views.rs @@ -0,0 +1,62 @@ +//! Memory regression for the font reads charged to an allowance: a stream whose `/DecodeParms` +//! is given by reference is read through a view that shares its data and copies no more of its +//! dictionary than its filters and parameters, not through a copy of the whole stream. A test +//! binary of its own, so that the one test is all the process runs while its peak is measured. + +use pdfcraft_cos::{DecodeBudget, Dict, Document, ObjRef, Object, Stream}; +use pdfcraft_fonts::pdf::{Metrics, embedded_sfnt_within}; + +/// How much the process's peak resident memory grows, in KiB, while `f` runs. `None` where the +/// peak can't be reset (`/proc/self/clear_refs` is Linux's). +fn peak_growth(f: impl FnOnce() -> T) -> Option<(T, u64)> { + fn peak_kib() -> Option { + let status = std::fs::read_to_string("/proc/self/status").ok()?; + status.lines().find_map(|l| l.strip_prefix("VmHWM:"))?.split_whitespace().next()?.parse().ok() + } + std::fs::write("/proc/self/clear_refs", "5").ok()?; + let before = peak_kib()?; + let out = f(); + Some((out, peak_kib()?.saturating_sub(before))) +} + +/// A stream of `data`, its `/DecodeParms` the reference `parms`, its dictionary holding `junk`. +fn stream(doc: &mut Document, data: &[u8], parms: ObjRef, junk: &Object) -> Object { + let mut s = Stream::flate(Dict::new(), data); + s.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms)); + s.dict.set(b"Junk".to_vec(), junk.clone()); + Object::Ref(doc.add(Object::Stream(s))) +} + +/// A font whose program and ToUnicode each hold an unrelated array of a million numbers in their +/// dictionaries, with `/DecodeParms` given by reference, read eight times within an allowance by +/// the editor's program read and by the metrics read: neither copies an array. A read through a +/// copy of the stream would add about as much as one array holds. +#[test] +fn reading_a_fonts_streams_copies_no_dictionary() { + let junk = Object::Array(vec![Object::Int(0); 1 << 20]); + let array_kib = (std::mem::size_of::() << 20) as u64 >> 10; + let mut doc = Document::new_empty(); + let parms = doc.add(Object::Dict(Dict::from_iter([(b"Predictor".to_vec(), Object::Int(1))]))); + let program = stream(&mut doc, include_bytes!("../../../assets/fonts/Inter-Regular.ttf"), parms, &junk); + let unicode = stream(&mut doc, b"1 beginbfchar <41> <0058> endbfchar", parms, &junk); + drop(junk); + let mut descriptor = Dict::new(); + descriptor.set(b"FontFile2".to_vec(), program); + let mut font = Dict::new(); + font.set(b"Type".to_vec(), Object::name("Font")); + font.set(b"Subtype".to_vec(), Object::name("TrueType")); + font.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor)); + font.set(b"ToUnicode".to_vec(), unicode); + let Some(((), growth)) = peak_growth(|| { + for _ in 0..8 { + let mut budget = DecodeBudget::new(64 << 20, 0); + assert!(embedded_sfnt_within(&doc, &font, &mut budget).is_some()); + let m = Metrics::from_dict_within(&doc, &font, &mut budget); + assert_eq!(m.text_of(0x41).as_deref(), Some("X")); + } + }) else { + eprintln!("the peak can't be measured here"); + return; + }; + assert!(growth < array_kib / 2, "the peak grew {growth} KiB, an array is {array_kib} KiB"); +}