diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 567dca9c0..5648cf42b 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -190,9 +190,20 @@ pub fn layer_names(doc: &Doc) -> Vec { /// The document with only layer `index` (for one layer of a file imported as a composition). pub fn layer_doc(doc: &Doc, index: usize) -> Doc { - let mut d = doc.clone(); - d.root.children = doc.root.children.get(index).cloned().into_iter().collect(); - d + // Only the root's attributes and the one child are copied, not every layer. + let Group { name, transform, opacity, clip, blend, mask, isolated, knockout, .. } = &doc.root; + let root = Group { + name: name.clone(), + transform: *transform, + opacity: *opacity, + children: doc.root.children.get(index).cloned().into_iter().collect(), + clip: clip.clone(), + blend: *blend, + mask: mask.clone(), + isolated: *isolated, + knockout: *knockout, + }; + Doc { width: doc.width, height: doc.height, root, skipped: doc.skipped.clone() } } /// One layer, optionally cropped to its artwork, and its original document-space top-left. diff --git a/crates/pdf/src/page.rs b/crates/pdf/src/page.rs index b7eb70fe0..7ca35536c 100644 --- a/crates/pdf/src/page.rs +++ b/crates/pdf/src/page.rs @@ -20,22 +20,35 @@ use crate::object::{Dict, File, Lexer, Obj, decode_stream}; /// A page: its dictionary with inherited attributes resolved. pub struct Page { pub dict: Dict, - pub resources: Dict, + /// Shared with the pages that inherit it (one copy per `/Resources` dictionary). + pub resources: Rc, /// CropBox (else MediaBox) `[x0, y0, x1, y1]`. pub bbox: [f64; 4], pub rotate: i32, } +/// The most page-tree nodes visited: a node listed many times at each of several levels would +/// otherwise be walked exponentially often within the depth limit. +const MAX_PAGE_VISITS: usize = 100_000; + /// The pages in order. pub fn pages(file: &File) -> Vec { let mut out = vec![]; let Some(cat) = file.catalog() else { return out }; let Some(root) = file.get_dict(cat, "Pages") else { return out }; - fn walk(file: &File, node: &Dict, inherited: (Option, Option>, Option>, i32), out: &mut Vec, depth: usize) { - if depth > 32 || out.len() > 10_000 { + fn walk( + file: &File, + node: &Dict, + inherited: (Option>, Option>, Option>, i32), + out: &mut Vec, + depth: usize, + visits: &mut usize, + ) { + *visits += 1; + if depth > 32 || out.len() > 10_000 || *visits > MAX_PAGE_VISITS { return; } - let res = file.get_dict(node, "Resources").cloned().or(inherited.0); + let res = file.get_dict(node, "Resources").map(|r| Rc::new(r.clone())).or(inherited.0); let media = file.get(node, "MediaBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.1); let crop = file.get(node, "CropBox").map(|m| file.nums(m)).filter(|v| v.len() == 4).or(inherited.2); let rot = file.get_num(node, "Rotate").map(|r| r as i32).unwrap_or(inherited.3); @@ -43,7 +56,7 @@ pub fn pages(file: &File) -> Vec { Some(kids) if file.get(node, "Type").and_then(Obj::name) != Some("Page") => { for k in kids { if let Some(d) = file.resolve(k).dict() { - walk(file, d, (res.clone(), media.clone(), crop.clone(), rot), out, depth + 1); + walk(file, d, (res.clone(), media.clone(), crop.clone(), rot), out, depth + 1, visits); } } } @@ -54,11 +67,14 @@ pub fn pages(file: &File) -> Vec { } } } - walk(file, root, (None, None, None, 0), &mut out, 0); + walk(file, root, (None, None, None, 0), &mut out, 0, &mut 0); out } -/// The page's content streams, decoded and joined. +/// The most decoded content bytes a page runs (the parts past it are left out). +const MAX_CONTENT: usize = crate::object::MAX_OBJECT_STREAM; + +/// The page's content streams, decoded and joined, up to [`MAX_CONTENT`] bytes. pub fn page_content(file: &File, page: &Page) -> Vec { let mut out = vec![]; let parts: Vec<&Obj> = match page.dict.get("Contents").map(|c| file.resolve(c)) { @@ -70,6 +86,9 @@ pub fn page_content(file: &File, page: &Page) -> Vec { if let Obj::Stream(d, raw) = p && let Some(data) = decode_stream(file, d, raw) { + if out.len().saturating_add(data.len()) > MAX_CONTENT { + break; + } out.extend_from_slice(&data); out.push(b'\n'); } @@ -186,6 +205,10 @@ pub(crate) struct Interp<'a> { /// Glyph outlines collected for a clipping text render mode (page space). text_clip: Option, fonts: HashMap>, + /// Decoded pixels the page may still make (each distinct image is decoded once). + image_pixels: usize, + /// Decoded images by object and fill colour (stencil masks paint with it). + images: HashMap<(String, [u8; 3]), (u32, u32, std::sync::Arc>)>, } fn affine(v: &[f64]) -> Affine { @@ -198,6 +221,16 @@ fn affine(v: &[f64]) -> Affine { /// The most tiles one tiling-pattern fill draws. const MAX_TILES: usize = 4096; +/// The deepest `q` nests (the specification's implementation limit is 28; every saved state +/// keeps a copy of the dash array). +const MAX_SAVED: usize = 4096; + +/// The most entries of a dash array kept (every saved state and stroke copies it). +const MAX_DASH: usize = 256; + +/// The most pixels one page decodes across its distinct images (1 GiB of RGBA). +const MAX_IMAGE_PIXELS: usize = 256 << 20; + /// A `TJ` element. enum TextItem { Str(Vec), @@ -222,6 +255,8 @@ impl<'a> Interp<'a> { tlm: Affine::IDENTITY, text_clip: None, fonts: HashMap::new(), + image_pixels: MAX_IMAGE_PIXELS, + images: HashMap::new(), } } @@ -257,7 +292,13 @@ impl<'a> Interp<'a> { let nums = || args.iter().filter_map(Obj::num).collect::>(); let locked = self.gs.lock_color; match op.as_str() { - "q" => self.saved.push((self.gs.clone(), self.b.depth())), + "q" => { + if self.saved.len() >= MAX_SAVED { + self.b.skip("content (nesting limit)"); + return; + } + self.saved.push((self.gs.clone(), self.b.depth())) + } "Q" => { if let Some((g, d)) = self.saved.pop() { self.gs = g; @@ -270,7 +311,8 @@ impl<'a> Interp<'a> { "j" => self.gs.join = [Join::Miter, Join::Round, Join::Bevel][(n(0) as usize).min(2)], "M" => self.gs.miter = n(0), "d" => { - let arr = args.first().map(|a| self.file.nums(a)).unwrap_or_default(); + let mut arr = args.first().map(|a| self.file.nums(a)).unwrap_or_default(); + arr.truncate(MAX_DASH); self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then(|| (arr, n(1))); } "gs" => { @@ -536,7 +578,7 @@ impl<'a> Interp<'a> { p += 1; } lx.pos = (p + 2).min(data.len()); - self.image(&d, &raw, res); + self.image(&d, &raw, res, None); } fn ocg_name(&self, ocg: &Dict) -> Option { @@ -588,7 +630,8 @@ impl<'a> Interp<'a> { self.gs.fill_alpha = v.clamp(0.0, 1.0); } if let Some(Obj::Array(d)) = f.get(g, "D") { - let arr = d.first().map(|a| f.nums(a)).unwrap_or_default(); + let mut arr = d.first().map(|a| f.nums(a)).unwrap_or_default(); + arr.truncate(MAX_DASH); let ph = d.get(1).and_then(|p| f.resolve(p).num()).unwrap_or(0.0); self.gs.dash = (!arr.is_empty() && arr.iter().any(|v| *v > 0.0)).then_some((arr, ph)); } @@ -766,6 +809,7 @@ impl<'a> Interp<'a> { self.gs = saved_gs; self.saved = saved_stack; self.path = saved_path; + let cell_shapes = cell_b.shapes; let (cell, _, sk) = cell_b.finish(); for s in sk { self.b.skip(&s); @@ -790,6 +834,14 @@ impl<'a> Interp<'a> { self.b.skip("tiling pattern (too many tiles)"); return; } + // Every tile copies the cell: the shapes copied count as operations, so nested + // patterns cannot multiply a cell past the budget. + let tiles_n = ((i1 - i0 + 1.0) * (j1 - j0 + 1.0)).max(1.0) as usize; + let Some(cost) = tiles_n.checked_mul(cell_shapes.max(1)).filter(|c| *c <= self.budget) else { + self.b.skip("tiling pattern (operation limit)"); + return; + }; + self.budget -= cost; let cell_clip = kurbo::Rect::new(bx0, by0, bx1, by1).to_path(0.1); let mut tiles = Group::new("Pattern"); for j in j0 as i64..=j1 as i64 { @@ -974,36 +1026,57 @@ impl<'a> Interp<'a> { fn xobject(&mut self, res: &Dict, name: &str, depth: usize) { let f = self.file; - let Some(x) = f.get_dict(res, "XObject").and_then(|xs| xs.get(name)).map(|x| f.resolve(x)) else { return }; - let Obj::Stream(d, raw) = x else { return }; + let Some(entry) = f.get_dict(res, "XObject").and_then(|xs| xs.get(name)) else { return }; + let key = match entry { + Obj::Ref(n, g) => Some(format!("{n} {g}")), + _ => None, + }; + let Obj::Stream(d, raw) = f.resolve(entry) else { return }; match f.get(d, "Subtype").and_then(Obj::name) { Some("Form") => self.form(d, raw, res, depth), - Some("Image") => self.image(d, raw, res), + Some("Image") => self.image(d, raw, res, key), _ => {} } } - /// Paint an image: the unit square of user space, pixel (0, 0) at its top-left corner. - fn image(&mut self, d: &Dict, raw: &[u8], res: &Dict) { + /// Paint an image: the unit square of user space, pixel (0, 0) at its top-left corner. An + /// image XObject (`key`: its object) placed again shares its pixels with the first placement. + fn image(&mut self, d: &Dict, raw: &[u8], res: &Dict, key: Option) { let fill = match &self.gs.fill { Fill::Color(c) => *c, _ => [0.0; 3], }; - match crate::image::decode(self.file, d, raw, Some(res), fill) { - Ok(img) => { - let (w, h) = (img.width as f64, img.height as f64); - let transform = self.gs.ctm * Affine::new([1.0 / w, 0.0, 0.0, -1.0 / h, 0.0, 1.0]); - self.b.push(Node::Image(SvgImage { - name: "Image".into(), - transform, - width: img.width, - height: img.height, - rgba: std::sync::Arc::new(img.rgba), - opacity: self.gs.fill_alpha, - })); + let cache_key = key.map(|k| (k, fill.map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8))); + let decoded = match cache_key.as_ref().and_then(|k| self.images.get(k)) { + Some(img) => img.clone(), + None => { + // The declared size is charged before decoding, so repeated placements of + // distinct images stop at the page's pixel budget. + let w = self.file.get_num(d, "Width").unwrap_or(0.0).max(0.0) as usize; + let h = self.file.get_num(d, "Height").unwrap_or(0.0).max(0.0) as usize; + let Some(left) = w.checked_mul(h).and_then(|p| self.image_pixels.checked_sub(p)) else { + self.b.skip("image (page pixel limit)"); + return; + }; + match crate::image::decode(self.file, d, raw, Some(res), fill) { + Ok(img) => { + self.image_pixels = left; + let decoded = (img.width, img.height, std::sync::Arc::new(img.rgba)); + if let Some(k) = cache_key { + self.images.insert(k, decoded.clone()); + } + decoded + } + Err(what) => { + self.b.skip(&what); + return; + } + } } - Err(what) => self.b.skip(&what), - } + }; + let (width, height, rgba) = decoded; + let transform = self.gs.ctm * Affine::new([1.0 / width as f64, 0.0, 0.0, -1.0 / height as f64, 0.0, 1.0]); + self.b.push(Node::Image(SvgImage { name: "Image".into(), transform, width, height, rgba, opacity: self.gs.fill_alpha })); } /// The font named `name` in the resources (cached by object). @@ -1060,10 +1133,16 @@ impl<'a> Interp<'a> { let (w1y, vx, vy) = if font.vertical { font.vmetrics(code) } else { (0.0, 0.0, 0.0) }; let trm = if font.vertical { trm * Affine::translate((-vx, -vy)) } else { trm }; if let Some((proc_, t3res)) = font.type3_proc(code) { - let proc_ = proc_.to_vec(); - let r = t3res.cloned().unwrap_or_else(|| res.clone()); - self.type3_glyph(&proc_, &r, trm * font.matrix, pattern_base, depth); + self.type3_glyph(proc_, t3res.unwrap_or(res), trm * font.matrix, pattern_base, depth); } else if let Some(g) = font.glyph(code) { + // Each outline element counts against the path limit and the budget + // before it is copied. + let n = g.elements().len(); + if path.elements().len().saturating_add(n) > MAX_PATH { + self.b.skip("text (path limit)"); + return; + } + self.budget = self.budget.saturating_sub(n); path.extend(trm * (*g).clone()); } else if code != 32 { missing = true; @@ -1092,7 +1171,11 @@ impl<'a> Interp<'a> { let name = if label.trim().is_empty() { "Text".to_string() } else { format!("Text: {}", label.trim()) }; if mode >= 4 { let clip = self.text_clip.get_or_insert_with(BezPath::new); - clip.extend(self.gs.ctm * path.clone()); + if clip.elements().len().saturating_add(path.elements().len()) > MAX_PATH { + self.b.skip("text (clip path limit)"); + } else { + clip.extend(self.gs.ctm * path.clone()); + } } if fill || stroke { self.paint_path(path, fill, FillRule::NonZero, stroke, &name, depth); @@ -1104,6 +1187,9 @@ impl<'a> Interp<'a> { if depth >= 8 { return; } + // The procedure is read again for every character: its bytes count as operations + // (one for sixteen), so a long procedure shown many times spends the budget. + self.budget = self.budget.saturating_sub(content.len() / 16); self.saved.push((self.gs.clone(), self.b.depth())); self.gs.ctm *= m; let path = std::mem::take(&mut self.path); diff --git a/crates/pdf/src/read.rs b/crates/pdf/src/read.rs index b9c86d0ad..2963a798f 100644 --- a/crates/pdf/src/read.rs +++ b/crates/pdf/src/read.rs @@ -229,6 +229,10 @@ impl Reader<'_> { let Some(location) = location else { return Err(Error::Read(format!("PDF has ambiguous recovered object {number}"))) }; let bytes = Arc::clone(&location.bytes); let Some(bytes) = bytes.get(location.start..location.end) else { return Ok(None) }; + // An object of n bytes holds at most n values: one past the value budget is not built. + if bytes.len() > self.values_left { + return Ok(None); + } let mut lexer = Lexer::new(bytes, 0); let Some(object) = lexer.next() else { return Ok(None) }; let object = lexer.maybe_ref(object); diff --git a/crates/pdf/src/tests/import.rs b/crates/pdf/src/tests/import.rs index c7dcbee57..de99c1bc6 100644 --- a/crates/pdf/src/tests/import.rs +++ b/crates/pdf/src/tests/import.rs @@ -2,6 +2,7 @@ //! corrupt inputs (generated fixtures). use crate::tests::{coverage, page_pdf, render, shape_names}; +use crate::write::pdf; use crate::*; fn eps(body: &[u8]) -> Doc { @@ -584,3 +585,70 @@ fn postscript_idiv_and_mod_of_i64_min_by_minus_one_give_zero() { assert_eq!(shape_names(&doc).len(), 1, "{prog}"); } } + +/// Parses `bytes` on a thread of its own, and fails the test when parsing takes more than 20 s. +fn pdf_soon(bytes: Vec) -> Result { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || tx.send(parse(&bytes)).ok()); + rx.recv_timeout(std::time::Duration::from_secs(20)).expect("parsing did not finish") +} + +#[test] +fn a_fanning_out_page_tree_is_walked_within_the_visit_budget() { + // Three levels of Pages nodes each listing the next one 1,000 times: 10^9 visits (each + // copying the inherited resources) within the depth limit. The walk stops at 100,000. + let kids = |n: u32| format!("<< /Type /Pages /Kids [{}] /Count 1 >>", format!("{n} 0 R ").repeat(1000)); + let objs = vec![ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, format!("<< /Type /Pages /Kids [{}] /Count 1 /Resources << /Font << >> >> >>", "3 0 R ".repeat(1000)), None), + (3, kids(4), None), + (4, kids(5), None), + (5, "<< /Type /Page /Parent 4 0 R /MediaBox [0 0 200 100] /Contents 6 0 R >>".into(), None), + (6, "<< >>".into(), Some(b"0 0 10 10 re f".to_vec())), + ]; + let doc = pdf_soon(pdf(&objs, 1)).unwrap(); + assert_eq!(doc.flatten().len(), 1); +} + +#[test] +fn saved_states_stop_nesting_at_the_limit() { + // `q` nests at most 4,096 deep (each copy keeps the dash array): past it the stream stops, + // and the square painted before it stays. + let doc = parse(&page_pdf(&format!("0 0 10 10 re f [1 2] 0 d {}20 20 10 10 re f", "q ".repeat(5000)), "", vec![])).unwrap(); + assert_eq!(doc.flatten().len(), 1, "{:?}", doc.skipped); + assert_eq!(doc.skipped, ["content (nesting limit)"]); +} + +#[test] +fn text_stops_at_the_path_limit() { + // The outlines of 100,000 H's (13 elements each) pass the path limit: the text is left out + // (noted), and the square after it is still painted. + let content = format!("BT /F1 10 Tf ({}) Tj ET 20 20 10 10 re f", "H".repeat(100_000)); + let doc = pdf_soon(page_pdf(&content, "/Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >>", vec![])).unwrap(); + assert_eq!(doc.skipped, ["text (path limit)"]); + assert_eq!(doc.flatten().len(), 1); +} + +#[test] +fn an_image_placed_twice_is_decoded_once() { + let objs = vec![(10, "<< /Type /XObject /Subtype /Image /Width 2 /Height 2 /ColorSpace /DeviceRGB /BitsPerComponent 8 >>".into(), Some(vec![255; 12]))]; + let doc = parse(&page_pdf("q 50 0 0 50 0 0 cm /Im1 Do Q q 50 0 0 50 100 0 cm /Im1 Do Q", "/XObject << /Im1 10 0 R >>", objs)).unwrap(); + assert!(doc.skipped.is_empty(), "{:?}", doc.skipped); + let images: Vec<_> = doc.root.children.iter().flat_map(|n| if let Node::Group(g) = n { g.children.iter().collect() } else { vec![] }).collect(); + let [Node::Image(a), Node::Image(b)] = images.as_slice() else { panic!("two images: {images:?}") }; + assert!(std::sync::Arc::ptr_eq(&a.rgba, &b.rgba), "the second placement shares the first's pixels"); +} + +#[test] +fn a_tiling_pattern_is_charged_for_every_tile_it_copies() { + // A cell of 8,000 squares copied into 2,704 tiles would be 21.6 million nodes: more than the + // operation budget, so the fill is left out (noted) rather than expanded. + let cell = "0 0 1 1 re f ".repeat(8_000).into_bytes(); + let doc = pdf_soon(page_pdf( + "/Pattern cs /P1 scn 0 0 50 50 re f", + "/Pattern << /P1 10 0 R >>", + vec![(10, "<< /Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 1 1] /XStep 1 /YStep 1 /Resources << >> >>".into(), Some(cell))], + )) + .unwrap(); + assert_eq!(doc.skipped, ["tiling pattern (operation limit)"]); +}