From 6154d957f89a72350a2d37d57bb0bb97b42e9afa Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 06:26:53 -0500 Subject: [PATCH] plate-test: bound text sampling, image size, resource depth and plate bytes (#544) * plate-test: bound text sampling, image size, resource depth and plate bytes - judge: sample text regions over integer pixel ranges clipped to the plate, so a huge or NaN region cannot stall the float counter. - reference: the writer enforces the scene validator's image pixel cap (MAX_IMAGE_PX, shared with scene.rs) before generating pixels. - structure: Walk::content follows nested Form/pattern/soft-mask resources at most MAX_RESOURCE_DEPTH (16) levels deep. - tiff: a plate may hold at most MAX_PLATE_PIXELS (1 GiB) and its buffer is reserved fallibly. * address review: report resource-depth truncation as an error; re-expand shared streams reached shallower * address review: list shared groups and graphics states once; accept empty terminal Resources --- crates/plate-test/src/judge.rs | 27 ++++--- crates/plate-test/src/reference.rs | 6 +- crates/plate-test/src/scene.rs | 7 +- crates/plate-test/src/structure.rs | 111 +++++++++++++++++++++++++++-- crates/plate-test/src/tiff.rs | 10 ++- 5 files changed, 139 insertions(+), 22 deletions(-) diff --git a/crates/plate-test/src/judge.rs b/crates/plate-test/src/judge.rs index 693106cb..84f41974 100644 --- a/crates/plate-test/src/judge.rs +++ b/crates/plate-test/src/judge.rs @@ -153,19 +153,16 @@ fn point_pixels(point: &SamplePoint, plates: &BTreeMap, res: f32) (PointKind::Text, Some([x, y, w, h])) => { let glyph = point.plate.as_deref().unwrap_or(TEXT_PLATE); let plate = plates.get(glyph).ok_or_else(|| format!("no {glyph} plate to find the text on"))?; - let (x0, y0, x1, y1) = (px(x).max(0.0), px(y).max(0.0), px(x + w), px(y + h)); + // Integer pixel ranges clipped to the plate: a huge or NaN region (`NaN.max(0.0)` is 0, + // `as usize` saturates) would otherwise stall a float counter or loop for ages. + let clip = |v: f32, max: usize| px(v).max(0.0).min(max as f32) as usize; + let (x0, x1) = (clip(x, width), clip(x + w, width)); + let (y0, y1) = (clip(y, height), clip(y + h, height)); let mut region = Vec::new(); - let mut row = y0; - while row < y1 { - let mut col = x0; - while col < x1 { - if inside(col, row) { - let (cx, cy) = (col as usize, row as usize); - region.push(((cx, cy), plate.ink(cx, cy).unwrap_or(0.0))); - } - col += 1.0; + for cy in y0..y1 { + for cx in x0..x1 { + region.push(((cx, cy), plate.ink(cx, cy).unwrap_or(0.0))); } - row += 1.0; } let darkest = region.iter().map(|(_, ink)| *ink).fold(0.0, f32::max); if darkest < TEXT_MIN_INK { @@ -302,6 +299,14 @@ mod tests { assert_eq!(skipped.failed_points(), 0); } + #[test] + fn a_huge_text_region_samples_only_the_plate() { + let region = r#"{"at": [15, 20], "kind": "text", "region": [-3e38, -3e38, 3.4e38, 3.4e38], "inks": {"Black": 100}, "tol": 2}"#; + let huge = scene(&SCENE.replacen(r#"{"at": [45, 20], "inks": {"Cyan": 100, "Gold": 50}, "tol": 2}"#, region, 1)); + let judgement = judge(&huge, &plates(true, None), 72.0, true); + assert_eq!(judgement.points[0].how.as_deref(), Some("200 glyph pixels on Black"), "{judgement:#?}"); + } + #[test] fn medians() { assert_eq!(median(&mut []), None); diff --git a/crates/plate-test/src/reference.rs b/crates/plate-test/src/reference.rs index 2bae10ff..45c7aa76 100644 --- a/crates/plate-test/src/reference.rs +++ b/crates/plate-test/src/reference.rs @@ -26,7 +26,7 @@ use krilla::surface::Surface; use krilla::text::{Font, TextDirection}; use crate::Error; -use crate::scene::{Blend, GroupCs, ImageSource, Kind, MAX_NESTING, MaskKind, Named, Object, Paint, Scene, Stroke}; +use crate::scene::{Blend, GroupCs, ImageSource, Kind, MAX_IMAGE_PX, MAX_NESTING, MaskKind, Named, Object, Paint, Scene, Stroke}; /// The font used for text objects, from `scripts/fetch-pdf-test-fonts.py`. pub const FONT_FILE: &str = "Inter-Regular.ttf"; @@ -413,6 +413,10 @@ impl Resolver<'_, '_> { fn image(&self, source: &ImageSource, [x0, y0, w, h]: [f32; 4]) -> Result { let [px, py] = source.px; + // The writer takes scenes that skipped `Scene::validate`, so the pixel cap is enforced here too. + if !(1..=MAX_IMAGE_PX).contains(&px) || !(1..=MAX_IMAGE_PX).contains(&py) { + return Err(self.bad(&format!("image px must be 1–{MAX_IMAGE_PX}"))); + } let (width, height) = (px as usize, py as usize); let image = if let Some(radius) = source.feather { let mut data = Vec::with_capacity(width * height); diff --git a/crates/plate-test/src/scene.rs b/crates/plate-test/src/scene.rs index 422db7dd..ad71d2bc 100644 --- a/crates/plate-test/src/scene.rs +++ b/crates/plate-test/src/scene.rs @@ -15,6 +15,9 @@ use crate::structure::StructureExpect; /// How deep placed scenes may nest. pub const MAX_NESTING: usize = 4; +/// The most pixels an image source may ask for on either side. +pub const MAX_IMAGE_PX: u32 = 4096; + /// The plate text points find their glyphs on unless they name another. pub const TEXT_PLATE: &str = "Black"; @@ -471,8 +474,8 @@ impl Scene { if sources.iter().filter(|s| **s).count() != 1 { out.push("image needs exactly one of ramp, split or feather".into()); } - if image.px[0] == 0 || image.px[1] == 0 || image.px[0] > 4096 || image.px[1] > 4096 { - out.push("image px must be 1–4096".into()); + if image.px.iter().any(|px| !(1..=MAX_IMAGE_PX).contains(px)) { + out.push(format!("image px must be 1–{MAX_IMAGE_PX}")); } if image.alpha.as_ref().is_some_and(|a| image.feather.is_some() || !(0.0..=1.0).contains(&a.opacity)) { out.push("image alpha needs a color image and an opacity of 0–1".into()); diff --git a/crates/plate-test/src/structure.rs b/crates/plate-test/src/structure.rs index c51ebf0b..9b63aa89 100644 --- a/crates/plate-test/src/structure.rs +++ b/crates/plate-test/src/structure.rs @@ -5,7 +5,7 @@ //! `Separation(Gold, alt DeviceCMYK)`, `DeviceN(Cyan,Black, alt DeviceCMYK)`, `Indexed(…)`, //! `Pattern`. Transparency group color spaces are reported per group, not in `color_spaces`. -use std::collections::{BTreeSet, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::Arc; use hayro_syntax::Pdf; @@ -96,6 +96,9 @@ pub fn summarize(pdf: &[u8]) -> Result { shadings: res.shadings.clone(), }); } + if !walk.truncated.is_empty() { + return Err(Error::Read(format!("resources nested more than {MAX_RESOURCE_DEPTH} levels deep were not read"))); + } let mut summary = walk.summary; summary.device_rgb = summary.color_spaces.iter().any(|cs| cs.contains("DeviceRGB")); Ok(summary) @@ -122,16 +125,35 @@ impl<'a> Resources<'a> { } } +/// How deep forms, patterns and soft masks may reference each other's resources. Each level is +/// a recursive `Walk::content`, so this bounds the native stack a crafted PDF can use. +const MAX_RESOURCE_DEPTH: usize = 16; + #[derive(Default)] struct Walk { summary: Summary, - seen: HashSet, + /// The shallowest depth each indirect object was expanded at. + seen: HashMap, + /// Streams whose `/Resources` were left unread at the depth limit, and not expanded since. + truncated: HashSet, + /// Graphics states and form groups already listed, so a re-expansion does not list them twice. + recorded: HashSet<(ObjectIdentifier, String)>, + depth: usize, } impl Walk { - /// True the first time an indirect object is visited. + /// True the first time an indirect object is visited, and again when it is reached at a shallower + /// depth than before (its expansion may have been cut at the depth limit). Each object is + /// expanded at most `MAX_RESOURCE_DEPTH + 1` times. fn first_visit(&mut self, id: Option) -> bool { - id.is_none_or(|id| self.seen.insert(id)) + let Some(id) = id else { return true }; + match self.seen.get(&id) { + Some(&at) if at <= self.depth => false, + _ => { + self.seen.insert(id, self.depth); + true + } + } } fn group(&mut self, at: &str, group: &Dict<'_>) { @@ -233,7 +255,9 @@ impl Walk { self.x_object(&source, "soft mask"); } } - self.summary.ext_g_states.push(state); + if gs.obj_id().is_none_or(|id| self.recorded.insert((id, String::new()))) { + self.summary.ext_g_states.push(state); + } } fn shading(&mut self, shading: &Object<'_>) { @@ -258,7 +282,9 @@ impl Walk { } } Some("Form") => { - if let Some(group) = dict.get::>(b"Group".as_slice()) { + if let Some(group) = dict.get::>(b"Group".as_slice()) + && self.recorded.insert((xobject.obj_id(), at.to_owned())) + { self.group(at, &group); } self.content(xobject); @@ -272,7 +298,17 @@ impl Walk { self.operators(&mut UntypedIter::new(&data)); } if let Some(res) = stream.dict().get::>(b"Resources".as_slice()) { + if self.depth >= MAX_RESOURCE_DEPTH { + // An empty dictionary names nothing, so nothing was left unread. + if res.keys().next().is_some() { + self.truncated.insert(stream.obj_id()); + } + return; + } + self.truncated.remove(&stream.obj_id()); + self.depth += 1; self.resources(&Resources::of(&res)); + self.depth -= 1; } } } @@ -476,3 +512,66 @@ impl StructureExpect { out } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A PDF whose page paints `/A` and `/B` (form XObjects given by object number), with `bodies` + /// as objects 4 onward and one more graphics-state object after them; the form objects are streams carrying their own dictionary entries. + fn pdf(a: usize, b: usize, forms: &[String]) -> Vec { + let mut out = b"%PDF-1.7\n".to_vec(); + let mut offsets = Vec::new(); + let mut objs = vec![ + "<>".to_owned(), + "<>".to_owned(), + format!("<>>>>>"), + ]; + for f in forms { + objs.push(format!("<>\nstream\n\nendstream")); + } + objs.push("<>".to_owned()); // the graphics state the last form names + for (i, o) in objs.iter().enumerate() { + offsets.push(out.len()); + out.extend(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes()); + } + let xref = out.len(); + out.extend(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes()); + for o in offsets { + out.extend(format!("{o:010} 00000 n \n").as_bytes()); + } + out.extend(format!("trailer\n<>\nstartxref\n{xref}\n%%EOF", objs.len() + 1).as_bytes()); + out + } + + /// `n` chained forms (objects 4..4+n); the last paints with Multiply. Returns the form bodies. + fn chain(n: usize) -> Vec { + let mut forms: Vec = (0..n - 1).map(|i| format!("/Resources<>>>", 5 + i)).collect(); + forms.push(format!("/Resources<>>>", 4 + n)); + forms + } + + #[test] + fn nesting_past_the_depth_limit_is_an_error_not_an_absence() { + let bytes = pdf(4, 4, &chain(3)); + let s = summarize(&bytes).unwrap(); + assert_eq!(s.ext_g_states.len(), 1, "{s:?}"); + assert!(summarize(&pdf(4, 4, &chain(MAX_RESOURCE_DEPTH + 1))).is_err()); + // An empty terminal /Resources at the limit leaves nothing unread. + let mut forms = chain(MAX_RESOURCE_DEPTH + 1); + *forms.last_mut().unwrap() = "/Resources<<>>".to_owned(); + assert!(summarize(&pdf(4, 4, &forms)).is_ok()); + } + + #[test] + fn a_shared_stream_reached_shallower_later_is_still_expanded() { + let n = MAX_RESOURCE_DEPTH + 1; + let last = 4 + n - 1; + // Deep path first, then the direct one; and the reverse (keys are visited in dictionary order). + for (a, b) in [(4, last), (last, 4)] { + // One path is the whole chain, ending at the last form at the depth limit; the other is direct. + let summary = summarize(&pdf(a, b, &chain(n))).unwrap(); + assert_eq!(summary.ext_g_states.len(), 1, "{a} {b}"); + } + } +} diff --git a/crates/plate-test/src/tiff.rs b/crates/plate-test/src/tiff.rs index 0d673106..3e7c5ac1 100644 --- a/crates/plate-test/src/tiff.rs +++ b/crates/plate-test/src/tiff.rs @@ -36,6 +36,9 @@ impl Plate { } } +/// The most pixels a plate may hold (one byte each): A2 at 1200 dpi, with room to spare. +const MAX_PLATE_PIXELS: usize = 1 << 30; + const WIDTH: u16 = 256; const HEIGHT: u16 = 257; const BITS_PER_SAMPLE: u16 = 258; @@ -88,8 +91,9 @@ pub fn read(data: &[u8]) -> Result { }; let rows_per_strip = one(ROWS_PER_STRIP, Some(height))?.clamp(1, height.max(1)); let offsets = get(STRIP_OFFSETS)?.ok_or_else(|| bad("missing strip offsets"))?; - let size = width.checked_mul(height).ok_or_else(|| bad("plate too large"))?; - let mut ink = Vec::with_capacity(size); + let size = width.checked_mul(height).filter(|size| *size <= MAX_PLATE_PIXELS).ok_or_else(|| bad("plate too large"))?; + let mut ink = Vec::new(); + ink.try_reserve_exact(size).map_err(|_| bad("plate too large"))?; for (strip, offset) in offsets.iter().enumerate() { let rows = rows_per_strip.min(height.saturating_sub(strip * rows_per_strip)); let bytes = data.get(*offset..offset.saturating_add(rows * width)).ok_or_else(|| bad("strip outside the file"))?; @@ -215,5 +219,7 @@ mod tests { // Compression (the fourth entry's value) set to PackBits. packed[8 + 2 + 12 * 3 + 8] = 5; assert!(read(&packed).unwrap_err().to_string().contains("uncompressed")); + // 65535 × 65535 bytes asks for more than the plate budget: refused before any allocation. + assert!(read(&encode(u16::MAX, u16::MAX, &[], 1, false)).unwrap_err().to_string().contains("too large")); } }