From 5eb82d1f68cf263a866b18ce76208276f42e9ca6 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:52:07 +0100 Subject: [PATCH] M2.6: an image pays for the pixels its data holds, not those its size declares --- crates/cos/src/budget.rs | 62 +++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 14 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 75ed03ea1a..270fcdac21 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -46,8 +46,10 @@ type Decoder = fn(&Stream, usize) -> Result<(Vec, usize), (FilterError, usiz /// aren't decoded. A caller then treats them as it does an image it can't read, and /// [`ImageBudget::refused`] says how many there were. /// -/// An image also pays for its pixels, as what reads it goes through them one by one however few -/// bytes hold them: blank rows of fax or Flate take almost none. A JPEG pays for its samples when +/// An image also pays for its pixels once decoded, as what reads it goes through them one by one +/// however few bytes coded them: blank rows of fax or Flate take almost none. It pays for no more +/// than its data holds, a bit a pixel at least, so an image declaring a size its data doesn't fill +/// costs what a smaller one would, not the run's allowance. A JPEG pays for its samples when /// it is decoded ([`ImageBudget::pay_jpeg`]), and is otherwise not charged, as JPEG 2000 isn't: /// their decoders don't run here. #[derive(Debug)] @@ -70,22 +72,19 @@ impl ImageBudget { /// Image `s` decoded like [`Document::decoded_image_within`], charged to the allowance. pub fn decode(&mut self, doc: &Document, s: &Stream, max: usize) -> Result, CosError> { - self.pay_size(s)?; let decoded = doc.decoded_image_spending(s, max, &mut self.work); self.count_refused(s, &decoded); - decoded + self.pay_size(s, decoded?) } /// Image `s` through its filters alone, charged to the allowance: [`Stream::decoded_within`] /// with its `/DecodeParms` given by reference read too ([`Document::with_direct_parms`]), for an /// image that has no fax or JBIG2 decoder to go through. pub fn decode_filters(&mut self, doc: &Document, s: &Stream, max: usize) -> Result, CosError> { - if s.filters().iter().all(|(f, _)| f.is_decodable()) { - self.pay_size(s)?; - } let decoded = doc.with_direct_parms(s).decoded_spending(max, &mut self.work); self.count_refused(s, &decoded); - decoded + let decoded = decoded?; + if s.filters().iter().all(|(f, _)| f.is_decodable()) { self.pay_size(s, decoded) } else { Ok(decoded) } } /// `data` through `chain`, any corruption being an error ([`pdfcraft_filters::decode_within`]), @@ -121,14 +120,20 @@ impl ImageBudget { self.pay_pixels(samples.saturating_mul(JPEG_WORK).saturating_add(reading)) } - /// Pays for the pixels of image `s`, unless it is JBIG2, whose decoder pays for its page, or - /// isn't decoded here. - fn pay_size(&mut self, s: &Stream) -> Result<(), CosError> { + /// Pays for the pixels of image `s` that its `decoded` data holds, those of its `/Width` and + /// `/Height` but no more than a bit of the data each, unless it is JBIG2, whose decoder pays for + /// its page, or isn't decoded here. What can't be paid for refuses the image. + fn pay_size(&mut self, s: &Stream, decoded: Vec) -> Result, CosError> { if !charged(s) || matches!(s.filters().last(), Some((pdfcraft_filters::Filter::Jbig2, _))) { - return Ok(()); + return Ok(decoded); } let side = |key: &[u8]| s.dict.int(key).and_then(|v| u64::try_from(v).ok()).unwrap_or(0); - if self.pay_pixels(side(b"Width").saturating_mul(side(b"Height"))) { Ok(()) } else { Err(CosError::Filter(FilterError::Spent.to_string())) } + let held = u64::try_from(decoded.len()).unwrap_or(u64::MAX).saturating_mul(8); + if self.pay_pixels(side(b"Width").saturating_mul(side(b"Height")).min(held)) { + Ok(decoded) + } else { + Err(CosError::Filter(FilterError::Spent.to_string())) + } } /// An image that failed with nothing left was refused, unless it has a filter that costs @@ -498,7 +503,7 @@ mod image_tests { let mut budget = ImageBudget::with_work(cost); assert_eq!(budget.decode_filters(&doc, &flate, 1 << 24).unwrap(), rows); assert_eq!(budget.work_left(), 0); - // Short of its pixels it isn't read: this much would have paid for the filters. + // Short of its pixels it is refused, having paid for its filters. let mut budget = ImageBudget::with_work(4096 * 1024 - 1); assert!(budget.decode(&doc, &flate, 1 << 24).unwrap_err().to_string().contains("more image decoding than")); assert_eq!((budget.work_left(), budget.refused()), (0, 1)); @@ -527,6 +532,35 @@ mod image_tests { assert_eq!((budget.work_left(), budget.refused()), (0, 1)); } + /// An image pays for the pixels its data holds, not those its size declares: one claiming + /// 131072 by 131072 pixels with a few bytes of Flate costs what those bytes give, and the + /// images after it are decoded as they would be without it. + #[test] + fn an_image_pays_for_no_more_pixels_than_its_data_holds() { + let doc = Document::new_empty(); + let sized = |side: i64, data: &[u8]| { + let mut dict = Dict::new(); + dict.set(b"Width".to_vec(), Object::Int(side)); + dict.set(b"Height".to_vec(), Object::Int(side)); + Stream::flate(dict, data) + }; + let claims = sized(131_072, &[0; 16]); + let image = sized(64, &[7; 64 * 64]); + let cost = |s: &Stream| { + let mut budget = ImageBudget::with_work(u64::MAX); + budget.decode(&doc, s, 1 << 24).unwrap(); + u64::MAX - budget.work_left() + }; + assert_eq!(cost(&claims), (claims.raw.len() + 16 + 16 * 8) as u64, "its bytes, and a pixel a bit of them"); + assert_eq!(cost(&image), (image.raw.len() + 64 * 64) as u64 + 64 * 64, "its bytes, and its pixels"); + for decode in [ImageBudget::decode, ImageBudget::decode_filters] { + let mut budget = ImageBudget::with_work(cost(&claims) + cost(&image)); + assert_eq!(decode(&mut budget, &doc, &claims, 1 << 24).unwrap(), [0; 16]); + assert_eq!(decode(&mut budget, &doc, &image, 1 << 24).unwrap(), [7; 64 * 64], "the image after it is decoded"); + assert_eq!((budget.work_left(), budget.refused()), (0, 0)); + } + } + /// A stream a run rewrites is read strictly and charged, and what the caller does with it after /// comes out of the same allowance: what can't be paid for spends all of it. #[test]