PDF pages: bound the page walk, saved states, text, tiles and image placements

The page-tree walk had no visit budget and copied the inherited resources
for every node; content streams were joined without a size limit; every q
copied the dash array without a nesting limit; Type 3 glyphs copied their
procedure and resources for every character; glyph outlines joined the text
and clip paths unchecked; every tile copied the cell without charging the
copies; every image placement decoded the image again; and object-stream
recovery built an object before the value budget applied.

Pages share one Rc per Resources dictionary and the walk stops at 100,000
visits; a page runs at most 256 MiB of content; q nests at most 4,096 deep
and dash arrays keep 256 entries; Type 3 procedures are borrowed and charged
by length; outlines are checked against the path limit and charged; a tiling
fill is charged tiles × cell shapes; images are cached by object and fill and
charged against a page pixel budget before decoding; and a recovered object
longer than the values left is not built. layer_doc copies one child instead
of the whole document.
This commit is contained in:
Dylan M. Taylor
2026-11-03 04:34:15 +00:00
parent 7870b5916a
commit 1f90317268
4 changed files with 206 additions and 37 deletions
+14 -3
View File
@@ -190,9 +190,20 @@ pub fn layer_names(doc: &Doc) -> Vec<String> {
/// 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.
+120 -34
View File
@@ -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<Dict>,
/// 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<Page> {
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<Dict>, Option<Vec<f64>>, Option<Vec<f64>>, i32), out: &mut Vec<Page>, depth: usize) {
if depth > 32 || out.len() > 10_000 {
fn walk(
file: &File,
node: &Dict,
inherited: (Option<Rc<Dict>>, Option<Vec<f64>>, Option<Vec<f64>>, i32),
out: &mut Vec<Page>,
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<Page> {
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<Page> {
}
}
}
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<u8> {
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<u8> {
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<BezPath>,
fonts: HashMap<String, Rc<Font>>,
/// 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<Vec<u8>>)>,
}
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<u8>),
@@ -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::<Vec<f64>>();
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<String> {
@@ -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<String>) {
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);
+4
View File
@@ -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);
+68
View File
@@ -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<u8>) -> Result<Doc, Error> {
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)"]);
}