diff --git a/crates/xfa/src/fixtures.rs b/crates/xfa/src/fixtures.rs index 3fe12b7af0..68b6dabc2e 100644 --- a/crates/xfa/src/fixtures.rs +++ b/crates/xfa/src/fixtures.rs @@ -750,6 +750,11 @@ pub fn linked_image_shell() -> Vec { /// [`linked_image_shell`] with its own name tree: `root` is object 6 (the tree the catalog names), /// `kid` object 7, and object 8 the PNG. pub fn linked_image_shell_with(root: &[u8], kid: &[u8]) -> Vec { + linked_image_shell_holding(root, kid, TINY_PNG) +} + +/// [`linked_image_shell_with`], object 8 a stream holding `picture` (a [`tiny_png`] there). +pub fn linked_image_shell_holding(root: &[u8], kid: &[u8], picture: &[u8]) -> Vec { let xdp = r##" "##; - let mut png = format!("<< /Length {} >>\nstream\n", TINY_PNG.len()).into_bytes(); - png.extend_from_slice(TINY_PNG); + let mut png = format!("<< /Length {} >>\nstream\n", picture.len()).into_bytes(); + png.extend_from_slice(picture); png.extend_from_slice(b"\nendstream"); let objs: Vec> = vec![ b"<< /Type /Catalog /Pages 2 0 R /NeedsRendering true /AcroForm << /XFA 5 0 R /Fields [] >> /Names << /XFAImages 6 0 R >> >>".to_vec(), diff --git a/crates/xfa/src/linked_images.rs b/crates/xfa/src/linked_images.rs index c60d465d52..9fd0e6f789 100644 --- a/crates/xfa/src/linked_images.rs +++ b/crates/xfa/src/linked_images.rs @@ -13,10 +13,20 @@ use crate::model::{Field, Node, Subform, Template, Value}; /// Most nodes of the name tree visited, and most names kept from it. const MAX_TREE_NODES: usize = 4096; const MAX_NAMES: usize = 4096; +/// Most name-and-value pairs looked at, a name repeated counted each time (only a new name is +/// kept, so [`MAX_NAMES`] alone doesn't end a `Names` array that repeats one). +const MAX_PAIRS: usize = 4 * MAX_NAMES; +/// Longest name kept, in bytes: a template's link is a short path or URI, and a longer name +/// could only be matched by an equally long link. +const MAX_NAME_LEN: usize = 4096; /// Deepest the name tree is followed. const MAX_DEPTH: usize = 32; -/// Largest picture taken from the file, as for a picture written into the template. +/// Largest picture taken from the file, as for a picture written into the template. Small in +/// tests, so a test can pass it. +#[cfg(not(test))] const MAX_IMAGE: usize = 32 << 20; +#[cfg(test)] +const MAX_IMAGE: usize = 512; /// Most bytes of pictures taken from the file in all, each place a picture is used counted (a /// picture is held once per place). Small in tests, so a test can reach it. #[cfg(not(test))] @@ -37,7 +47,7 @@ fn with_resolved(doc: &Document, o: &Object, f: impl FnOnce(&Object) -> R) -> /// The `XFAImages` name tree: each name, as text, and the object it names. fn names(doc: &Document) -> HashMap { - let mut walk = Walk { doc, out: HashMap::new(), seen: HashSet::new(), visited: 0 }; + let mut walk = Walk { doc, out: HashMap::new(), seen: HashSet::new(), visited: 0, pairs: 0 }; let Some(root) = doc.root() else { return walk.out }; let catalog = doc.get(root); if let Some(names) = catalog.as_dict().and_then(|c| c.get(b"Names")) { @@ -57,11 +67,13 @@ struct Walk<'a> { out: HashMap, seen: HashSet, visited: usize, + /// Pairs looked at so far, repeats included. + pairs: usize, } impl Walk<'_> { fn full(&self) -> bool { - self.visited >= MAX_TREE_NODES || self.out.len() >= MAX_NAMES + self.visited >= MAX_TREE_NODES || self.out.len() >= MAX_NAMES || self.pairs >= MAX_PAIRS } fn node(&mut self, node: &Object, depth: usize) { @@ -97,11 +109,15 @@ impl Walk<'_> { fn pairs(&mut self, pairs: &Object) { let doc = self.doc; for pair in pairs.as_array().map(Vec::as_slice).unwrap_or_default().chunks(2) { - if self.out.len() >= MAX_NAMES { + // The names and pairs limits only: the node limit ends the walk at the next node, not + // the names of the node it has just come to. + if self.out.len() >= MAX_NAMES || self.pairs >= MAX_PAIRS { break; } + self.pairs += 1; + // An over-long name is left before it is turned into text. if let [key, value] = pair - && let Some(key) = with_resolved(doc, key, |k| k.as_string().map(|s| s.to_text())) + && let Some(key) = with_resolved(doc, key, |k| k.as_string().filter(|s| s.bytes.len() <= MAX_NAME_LEN).map(|s| s.to_text())) { self.out.entry(key).or_insert_with(|| value.clone()); } diff --git a/crates/xfa/src/tests.rs b/crates/xfa/src/tests.rs index a8619b4a07..f2aa1dda3d 100644 --- a/crates/xfa/src/tests.rs +++ b/crates/xfa/src/tests.rs @@ -4203,6 +4203,108 @@ fn a_hostile_xfa_images_tree_is_walked_once_and_gives_nothing() { } } +/// The picture data a draw named `name` ends up with, in the template's first subform. +fn linked_picture(tpl: &crate::model::Template, name: &str) -> Option> { + tpl.root.children.iter().find_map(|n| match n { + Node::Subform(s) => s.children.iter().find_map(|c| match c { + Node::Draw(d) if d.common.name.as_deref() == Some(name) => match &d.value { + Value::Image { data, .. } => Some(data.clone()), + _ => None, + }, + _ => None, + }), + _ => None, + }) +} + +/// #5393 review: a `Names` array that repeats one name counts every pair it holds, not only the +/// names kept: the walk stops at its limit however often the name repeats, so the name listed +/// after twenty thousand repeats is never reached. +#[test] +fn a_repeated_xfa_images_name_counts_against_the_walk() { + let kid = format!(r"<< /Names [{}(.\\logo.png) 8 0 R] >>", r"(.\\dup.png) 8 0 R ".repeat(20_000)); + let doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_with(b"<< /Kids [7 0 R] >>", kid.as_bytes()))).unwrap(); + let mut tpl = crate::script::template_of(&doc).unwrap(); + crate::linked_images::resolve(&mut tpl, &doc); + assert_eq!(linked_picture(&tpl, "logo"), Some(Vec::new()), "past the limit the logo's name isn't reached"); +} + +/// The last node the walk reaches, the 4,096th, still gives its names: the node limit stops +/// the walk from going on, not the node it has just come to. +#[test] +fn the_last_xfa_images_node_the_walk_reaches_gives_its_names() { + // The root, 4,094 empty kids, and the kid holding the logo: 4,096 nodes in all. + let root = format!("<< /Kids [{}7 0 R] >>", "<< >> ".repeat(4_094)); + let doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_with(root.as_bytes(), br"<< /Names [(.\\logo.png) 8 0 R] >>"))).unwrap(); + let mut tpl = crate::script::template_of(&doc).unwrap(); + crate::linked_images::resolve(&mut tpl, &doc); + assert_eq!(linked_picture(&tpl, "logo"), Some(crate::fixtures::tiny_png()), "the 4,096th node's name is read"); +} + +/// #5393 review: a name longer than any link is left before it is turned into text; the names +/// beside it are still read. +#[test] +fn an_over_long_xfa_images_name_is_left() { + let long = "L".repeat(5_000); + let kid = format!(r"<< /Names [({long}) 8 0 R (.\\logo.png) 8 0 R] >>"); + let doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_with(b"<< /Kids [7 0 R] >>", kid.as_bytes()))).unwrap(); + let xml = format!( + r#""# + ); + let (mut tpl, _) = crate::parse(&xml).unwrap(); + crate::linked_images::resolve(&mut tpl, &doc); + assert_eq!(linked_picture(&tpl, "long"), Some(Vec::new()), "the over-long name isn't kept"); + assert_eq!(linked_picture(&tpl, "logo"), Some(crate::fixtures::tiny_png()), "the name beside it is"); +} + +/// #5393 review: a name the tree holds whose stream isn't a picture is taken as it is, and +/// drawing it draws nothing and warns, as a picture the template holds that isn't one does. +#[test] +fn a_linked_stream_that_is_not_a_picture_draws_nothing_and_warns() { + let mut doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_holding( + b"<< /Kids [7 0 R] >>", + br"<< /Names [(.\\logo.png) 8 0 R] >>", + b"this is not a picture", + ))) + .unwrap(); + // The stream is found and taken as the logo's data. + let mut tpl = crate::script::template_of(&doc).unwrap(); + assert!(crate::linked_images::resolve(&mut tpl, &doc).is_empty()); + assert_eq!(linked_picture(&tpl, "logo"), Some(b"this is not a picture".to_vec())); + let report = render_into(&mut doc).unwrap(); + // One warning for the logo and the missing picture alike (warnings are said once). + assert!(report.warnings.iter().any(|w| w.contains("not a JPEG, PNG")), "{:?}", report.warnings); + let images = doc + .object_numbers() + .into_iter() + .filter( + |n| matches!(&*doc.get(pdfcraft_cos::ObjRef { num: *n, generation: 0 }), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Image")), + ) + .count(); + assert_eq!(images, 0, "no picture drawn"); +} + +/// #5393 review: a picture larger than the limit (small in tests) is left out with a warning, +/// not read whole. +#[test] +fn an_over_large_linked_picture_is_left_with_a_warning() { + let doc = Document::open(Arc::new(crate::fixtures::linked_image_shell_holding( + b"<< /Kids [7 0 R] >>", + br"<< /Names [(.\\logo.png) 8 0 R] >>", + &[7u8; 600], + ))) + .unwrap(); + let mut tpl = crate::script::template_of(&doc).unwrap(); + let warnings = crate::linked_images::resolve(&mut tpl, &doc); + assert!(warnings.iter().any(|w| w.contains("could not be read")), "{warnings:?}"); + assert_eq!(linked_picture(&tpl, "logo"), Some(Vec::new())); +} + /// An over-deep branch of the `XFAImages` tree is left on its own: the walk goes on to the /// branches beside it, and the picture one of them holds is found. #[test]