From 0d5a149093d6b0de4c0c2cff730261f3c4aeb7fa Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:09:04 +0100 Subject: [PATCH] #3692: PNG/JPEG export refuses pixels that don't match the page size Finding 26. Rendered::to_png resized its pixel buffer to width*height*4 when the lengths differed, so a Rendered claiming a huge size (its fields are public) allocated that much with no budget; to_jpeg asserted inside the encoder on the same mismatch. Both now encode only pixels that are exactly width*height RGBA within the raster cap, and return empty (logged) otherwise. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 41 ++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 959a801ea..88a6bcd53 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -57,15 +57,21 @@ impl Rendered { } out } + /// Whether the pixels are exactly `width`×`height` RGBA, within a page raster's budget + /// ([`wordcraft_geom::MAX_RASTER_PIXELS`]): the fields are public, so an encoder is never asked + /// to pad a short buffer out to a huge claimed size (or, for JPEG, to assert on a mismatch). + fn encodable(&self) -> bool { + let budget = wordcraft_geom::MAX_RASTER_PIXELS.saturating_mul(4); + wordcraft_geom::rgba_fits(self.width, self.height, budget) + && wordcraft_geom::rgba_bytes(self.width, self.height).is_some_and(|b| b == self.pixels.len() as u64) + } /// PNG bytes (empty on failure, logged). pub fn to_png(&self) -> Vec { - let mut px = self.to_straight(); - let Some(len) = wordcraft_geom::rgba_bytes(self.width, self.height).and_then(|b| usize::try_from(b).ok()) else { - log::error!("PNG encode: size {}×{} overflows", self.width, self.height); + if !self.encodable() { + log::error!("PNG encode: {} bytes for {}×{} pixels", self.pixels.len(), self.width, self.height); return Vec::new(); - }; - px.resize(len, 0); - let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else { + } + let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()) else { log::error!("PNG encode: bad size {}×{}", self.width, self.height); return Vec::new(); }; @@ -76,7 +82,12 @@ impl Rendered { } buf } + /// JPEG bytes on white (empty on failure, logged). pub fn to_jpeg(&self, quality: u8) -> Vec { + if !self.encodable() { + log::error!("JPEG encode: {} bytes for {}×{} pixels", self.pixels.len(), self.width, self.height); + return Vec::new(); + } let rgba = self.to_straight(); let rgb: Vec = rgba .as_chunks::<4>() @@ -1740,6 +1751,24 @@ mod tests { assert!(decode_pixmap(&hdr).is_none()); } + /// A `Rendered` whose pixels don't match its claimed size isn't padded out to that size: a + /// 60000×60000 claim over no pixels would allocate 14 GB. It, and any mismatch, encodes to + /// nothing (JPEG used to assert on the length). + #[test] + fn encoders_refuse_pixels_that_dont_match_the_size() { + let huge = Rendered { width: 60_000, height: 60_000, pixels: Vec::new() }; + assert!(huge.to_png().is_empty()); + assert!(huge.to_jpeg(80).is_empty()); + let short = Rendered { width: 2, height: 2, pixels: vec![255; 4] }; + assert!(short.to_png().is_empty()); + assert!(short.to_jpeg(80).is_empty()); + let long = Rendered { width: 1, height: 1, pixels: vec![255; 8] }; + assert!(long.to_png().is_empty()); + let ok = Rendered { width: 2, height: 2, pixels: vec![255; 16] }; + assert!(!ok.to_png().is_empty()); + assert!(!ok.to_jpeg(80).is_empty()); + } + #[test] fn page_raster_is_capped_by_total_pixels() { let d = Document::new();