diff --git a/crates/pdf/src/build.rs b/crates/pdf/src/build.rs index 0b1586f89..540a8183a 100644 --- a/crates/pdf/src/build.rs +++ b/crates/pdf/src/build.rs @@ -7,6 +7,9 @@ use effectcraft_svg::{Affine, BezPath, FillRule, Geom, Group, Node, Paint, Shape /// PostScript program, or the PDF content stream. pub(crate) const MAX_PATH: usize = 1 << 20; +/// The deepest the open groups nest (the render tree and its consumers recurse through them). +const MAX_DEPTH: usize = 256; + #[derive(Clone, Copy, PartialEq, Eq)] enum Kind { Plain, @@ -62,23 +65,33 @@ impl Builder { } } + /// Open `g` as the group drawn into from now on; past [`MAX_DEPTH`] the group is dropped + /// (what would have been drawn inside it goes to the open group) and noted as skipped. + fn push_group(&mut self, g: Group, kind: Kind) { + if self.stack.len() >= MAX_DEPTH { + self.skip("group nesting limit"); + return; + } + self.stack.push((g, kind)); + } + /// Children drawn from now on are clipped by `path` (page space) until the group closes. pub fn clip(&mut self, path: BezPath, rule: FillRule) { let mut g = group("Clip Group"); g.clip.push((path, rule)); // Clipping is not a transparency group: blend modes inside reach the backdrop. g.isolated = false; - self.stack.push((g, Kind::Clip)); + self.push_group(g, Kind::Clip); } /// Open an optional-content layer (or a named group). pub fn open(&mut self, name: &str, layer: bool) { - self.stack.push((group(name), if layer { Kind::Layer } else { Kind::Plain })); + self.push_group(group(name), if layer { Kind::Layer } else { Kind::Plain }); } /// Open a group (blend mode, soft mask) closed with the graphics state. pub fn open_group(&mut self, g: Group) { - self.stack.push((g, Kind::Plain)); + self.push_group(g, Kind::Plain); } /// Add a finished node (an image, pattern tiles). diff --git a/crates/pdf/src/ps/builtin.rs b/crates/pdf/src/ps/builtin.rs index dcafa9417..a19aeb6da 100644 --- a/crates/pdf/src/ps/builtin.rs +++ b/crates/pdf/src/ps/builtin.rs @@ -1,4 +1,5 @@ use super::eps::find_from; +use super::exec::MAX_STACK; use super::scanner::Src; use super::text::encoding_array; use super::value::{Flow, V, array, matrix_of, matrix_val, new_dict}; @@ -181,8 +182,13 @@ impl Interp { match self.pop() { V::Num(k) => { let k = (k.max(0.0) as usize).min(self.st.len()); - let tail: Vec = self.st[self.st.len() - k..].to_vec(); - self.st.extend(tail); + // A copy past the operand limit copies nothing, and the values copied + // count as operations (one for eight): repeated doubling spends the budget. + if self.st.len().saturating_add(k) <= MAX_STACK { + self.charge(k / 8); + let tail: Vec = self.st[self.st.len() - k..].to_vec(); + self.st.extend(tail); + } } V::Array(dst) => { let s = self.pop().items().unwrap_or_default(); @@ -507,12 +513,16 @@ impl Interp { fn try_collection_op(&mut self, n: &str) -> bool { match n { // Arrays and strings. + // The storage made counts as operations (a value, or eight bytes, each), so a loop + // of large constructors spends the budget rather than the memory. "array" => { let k = self.popn().clamp(0.0, 1e6) as usize; + self.charge(k); self.push(array(vec![V::Null; k])); } "string" => { let k = self.popn().clamp(0.0, 1e7) as usize; + self.charge(k / 8); self.push(V::Str(Rc::new(RefCell::new(vec![0; k])))); } "length" => { diff --git a/crates/pdf/src/ps/eps.rs b/crates/pdf/src/ps/eps.rs index 90d60cfbe..aed984bc8 100644 --- a/crates/pdf/src/ps/eps.rs +++ b/crates/pdf/src/ps/eps.rs @@ -19,7 +19,8 @@ pub fn eps_parts(bytes: &[u8]) -> Option<(&[u8], [f64; 4])> { if v.len() == 4 && v[2] > v[0] && v[3] > v[1] { return Some([v[0], v[1], v[2], v[3]]); } - from = s; + // A rejected comment is done with: the scan goes on past its line, not over it again. + from = e; } None }; @@ -31,8 +32,15 @@ pub(super) fn find_from(hay: &[u8], needle: &[u8], from: usize) -> Option hay.get(from..)?.windows(needle.len()).position(|w| w == needle).map(|p| p + from) } -fn rfind_before(hay: &[u8], needle: &[u8], to: usize) -> Option { - hay.get(..to)?.windows(needle.len()).rposition(|w| w == needle) +/// Every position of `needle` in `hay`, in order (one pass over the file). +fn positions(hay: &[u8], needle: &[u8]) -> Vec { + let mut out = vec![]; + let mut from = 0; + while let Some(p) = find_from(hay, needle, from) { + out.push(p); + from = p + needle.len(); + } + out } /// The name after `key` (`/FontName /Name`). @@ -48,20 +56,25 @@ fn name_after(b: &[u8], key: &[u8]) -> Option { /// … cleartomark`, hexadecimal or binary) and CFF font sets (`… StartData `). pub(super) fn embedded_fonts(ps: &[u8]) -> HashMap>, bool)> { let mut out = HashMap::new(); + // The headers are found once, in one pass each; every `eexec` then takes the last header + // before it that no earlier `eexec` has used, so no prefix of the file is searched again. + let mut heads = positions(ps, b"%!PS-AdobeFont"); + heads.extend(positions(ps, b"%!FontType1")); + heads.sort_unstable(); + let names = positions(ps, b"/FontName"); let mut from = 0; + let mut used = 0; while let Some(ee) = find_from(ps, b"eexec", from) { from = ee + 5; - let start = [b"%!PS-AdobeFont".as_slice(), b"%!FontType1"] - .iter() - .filter_map(|h| rfind_before(ps, h, ee)) - .max() - .or_else(|| rfind_before(ps, b"/FontName", ee).map(|p| p.saturating_sub(512))); + let last_before = |v: &[usize]| v[..v.partition_point(|&p| p < ee)].last().copied().filter(|&p| p >= used); + let start = last_before(&heads).or_else(|| last_before(&names).map(|p| p.saturating_sub(512))); let Some(start) = start else { continue }; let end = find_from(ps, b"cleartomark", ee).map_or(ps.len(), |p| p + 11); if let Some(name) = name_after(&ps[start..ee], b"/FontName") { out.insert(name, (Rc::new(ps[start..end].to_vec()), false)); } from = end; + used = end; } let mut from = 0; while let Some(sd) = find_from(ps, b"StartData", from) { @@ -77,3 +90,20 @@ pub(super) fn embedded_fonts(ps: &[u8]) -> HashMap>, bool)> } out } + +#[cfg(test)] +mod tests { + use super::embedded_fonts; + + #[test] + fn font_discovery_looks_back_once_per_header() { + // 20,000 `eexec` words before any font header: each used to search the whole prefix + // for a header again. The font after them is still found. + let mut ps = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec(); + ps.extend_from_slice("eexec ".repeat(20_000).as_bytes()); + ps.extend_from_slice(b"\n%!PS-AdobeFont-1.0: Real\n/FontName /Real def\ncurrentfile eexec\n0000\ncleartomark\n"); + let fonts = embedded_fonts(&ps); + assert_eq!(fonts.len(), 1); + assert!(fonts.contains_key("Real")); + } +} diff --git a/crates/pdf/src/ps/exec.rs b/crates/pdf/src/ps/exec.rs index 27697527c..233ff82b0 100644 --- a/crates/pdf/src/ps/exec.rs +++ b/crates/pdf/src/ps/exec.rs @@ -6,6 +6,10 @@ use super::{Affine, BezPath, Builder, Cap, HashMap, Interp, Join}; /// The deepest procedure nesting run (deeper recursion stops the program). const MAX_CALL_DEPTH: usize = 256; +/// The most values the operand stack holds: a push past it is dropped, and `copy` copies +/// nothing rather than grow the stack past it. +pub(super) const MAX_STACK: usize = 100_000; + impl Interp { pub fn new(page: [f64; 4]) -> Interp { Interp { @@ -56,11 +60,17 @@ impl Interp { } pub(super) fn push(&mut self, v: V) { - if self.st.len() < 100_000 { + if self.st.len() < MAX_STACK { self.st.push(v); } } + /// Count `n` units of work (values copied, storage allocated) against the budget without + /// stopping the program here: once it is spent, the next operator stops it. + pub(super) fn charge(&mut self, n: usize) { + self.budget = self.budget.saturating_sub(n); + } + pub(super) fn lookup(&self, k: &str) -> Option { self.dicts.iter().rev().find_map(|d| d.borrow().get(k).cloned()) } diff --git a/crates/pdf/src/ps/paths.rs b/crates/pdf/src/ps/paths.rs index b1fe07092..30b5a7087 100644 --- a/crates/pdf/src/ps/paths.rs +++ b/crates/pdf/src/ps/paths.rs @@ -22,7 +22,7 @@ impl Interp { } /// Stop the program when a path of `len` elements would be longer than [`MAX_PATH`]. - fn path_fits(&mut self, len: usize) -> Result<(), Flow> { + pub(super) fn path_fits(&mut self, len: usize) -> Result<(), Flow> { if len <= MAX_PATH { return Ok(()); } diff --git a/crates/pdf/src/ps/text.rs b/crates/pdf/src/ps/text.rs index b138965bb..8ddf6ce85 100644 --- a/crates/pdf/src/ps/text.rs +++ b/crates/pdf/src/ps/text.rs @@ -96,6 +96,11 @@ impl Interp { for (i, &code) in bytes.iter().enumerate() { let code = code as u32; if let Some(g) = font.glyph(code) { + // Each outline element counts against the path limit and the budget before it + // is copied, so a long string cannot expand into unbounded geometry. + let n = g.elements().len(); + self.path_fits(path.elements().len().saturating_add(n))?; + self.spend(n)?; path.extend(ctm * Affine::translate(p.to_vec2()) * m * (*g).clone()); } text.push_str(&font.unicode(code)); diff --git a/crates/pdf/src/tests/import.rs b/crates/pdf/src/tests/import.rs index c7dcbee57..516ff054d 100644 --- a/crates/pdf/src/tests/import.rs +++ b/crates/pdf/src/tests/import.rs @@ -513,6 +513,9 @@ fn paths_stop_at_the_path_limit() { // The outlines of 100,000 H's, 13 elements each. "/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \ 0 0 moveto s false charpath", + // The same outlines shown: the text path is bounded like the current path. + "/Helvetica findfont 10 scalefont setfont /s 100000 string def 0 1 99999 { s exch 72 put } for \ + 0 0 moveto s show", // 210,000 rectangles of five elements each. "/a 840000 array def 0 1 839999 { a exch 0 put } for a rectfill", ] { @@ -528,6 +531,45 @@ fn paths_stop_at_the_path_limit() { assert_eq!(doc.flatten().len(), 1); } +#[test] +fn copies_past_the_operand_limit_copy_nothing() { + // `count copy` doubles the operand stack: 40 doublings would be 2^40 values. A copy that would + // pass the operand limit (100,000) copies nothing, so the program runs on to its end. + let doc = eps_soon("0 0 10 10 rectfill 1 40 { count copy } repeat clear 20 20 10 10 rectfill"); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + assert_eq!(doc.flatten().len(), 2); +} + +#[test] +fn storage_made_in_a_loop_runs_out_of_the_budget() { + // Each value of an array and each eight bytes of a string count as an operation when they + // are made, so a loop keeping large arrays or strings spends the budget, not the memory. + for body in ["/a 1000 array def 0 1 999 { a exch 1000000 array put } for", "/a 1000 array def 0 1 999 { a exch 10000000 string put } for"] { + runs_out_of_budget(body); + } +} + +#[test] +fn groups_stop_nesting_at_the_depth_limit() { + // Every `rectclip` opens a clipping group inside the last: past 256 open groups the group is + // dropped (and noted), and what is drawn inside goes to the open one. + let doc = eps_soon("300 { 0 0 100 100 rectclip } repeat 0 0 10 10 rectfill"); + assert_eq!(doc.skipped, ["group nesting limit"]); + assert_eq!(doc.flatten().len(), 1); +} + +#[test] +fn bounding_box_comments_are_scanned_once() { + // A rejected `%%BoundingBox:` comment is not scanned again from each later marker on its + // line: 20,000 markers on one line would otherwise take quadratic work. + let mut src = b"%!PS-Adobe-3.0 EPSF-3.0\n".to_vec(); + src.extend_from_slice("%%BoundingBox: x ".repeat(20_000).as_bytes()); + src.extend_from_slice(b"\n%%BoundingBox: 0 0 50 50\n0 0 10 10 rectfill\n"); + let doc = parse(&src).unwrap(); + assert_eq!((doc.width, doc.height), (50.0, 50.0)); + assert_eq!(doc.flatten().len(), 1); +} + #[test] fn pdf_paths_stop_at_the_path_limit() { // A content stream's current path holds at most 1,048,576 elements: `re` (five elements each)