diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index f6bf5e3a9..6e29818d3 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3850,8 +3850,8 @@ fn pdf_a_huge_form_drawn_over_and_over_is_refused_in_bounded_time() { #[test] fn a_stream_past_the_cap_is_named() { let image = "/Type /XObject /Subtype /Image /Width 10 /Height 10 /ColorSpace /DeviceRGB /BitsPerComponent 8"; - let page = |entries: &str| { - format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 4 0 R /Resources << /XObject << /Im 5 0 R >> >> {entries} >>") + let page = |contents: &str, entries: &str| { + format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents {contents} /Resources << /XObject << /Im 5 0 R >> >> {entries} >>") .into_bytes() }; let plain = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1).into_bytes(); @@ -3864,17 +3864,27 @@ fn a_stream_past_the_cap_is_named() { let files = [ ( "an image", - pdf(page(""), vec![plain("q 10 0 0 10 0 0 cm /Im Do Q"), packed_as_is(image, &spaces)]), + pdf(page("4 0 R", ""), vec![plain("q 10 0 0 10 0 0 cm /Im Do Q"), packed_as_is(image, &spaces)]), "an image of 10 × 10 px in it (object 5) would decode to more than 256 MB", ), ( "a page's content", - pdf(page(""), vec![packed_as_is("", &spaces), packed(image, "\0\0\0")]), + pdf(page("4 0 R", ""), vec![packed_as_is("", &spaces), packed(image, "\0\0\0")]), + "a page's content in it (object 4) would decode to more than 256 MB", + ), + ( + "a page's content in an array", + pdf(page("[4 0 R]", ""), vec![packed_as_is("", &spaces), packed(image, "\0\0\0")]), + "a page's content in it (object 4) would decode to more than 256 MB", + ), + ( + "a page's content in an array of its own", + pdf(page("6 0 R", ""), vec![packed_as_is("", &spaces), packed(image, "\0\0\0"), b"[4 0 R]".to_vec()]), "a page's content in it (object 4) would decode to more than 256 MB", ), ( "the editing data", - pdf(page(editing), vec![plain("0 0 5 5 re f"), packed(image, "\0\0\0"), packed_as_is("", &spaces)]), + pdf(page("4 0 R", editing), vec![plain("0 0 5 5 re f"), packed(image, "\0\0\0"), packed_as_is("", &spaces)]), "its editing data (object 6) would decode to more than 256 MB", ), ]; @@ -3888,6 +3898,39 @@ fn a_stream_past_the_cap_is_named() { }); } +// Pages that share one `Contents` array, the array an object of its own: its stream is named as +// a page's content, and a refused stream elsewhere is still found past the pages (#4612 review). +#[test] +fn pages_sharing_a_contents_array_name_its_stream() { + let image = "/Type /XObject /Subtype /Image /Width 10 /Height 10 /ColorSpace /DeviceRGB /BitsPerComponent 8"; + let plain = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1).into_bytes(); + let spaces = spaces_past_the_cap(); + // 1 catalog, 2 pages, 3 the shared array, 4 its stream, 5 to 7 the pages, 8 an image they draw. + let pdf = |content: Vec, image: Vec| { + let page = b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Contents 3 0 R /Resources << /XObject << /Im 8 0 R >> >> >>"; + let head = [ + b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), + b"<< /Type /Pages /Kids [5 0 R 6 0 R 7 0 R] /Count 3 >>".to_vec(), + b"[4 0 R]".to_vec(), + content, + ]; + pdf_of(&head.into_iter().chain([page.to_vec(), page.to_vec(), page.to_vec(), image]).collect::>()) + }; + let files = [ + (pdf(packed_as_is("", &spaces), packed(image, "\0\0\0")), "a page's content in it (object 4) would decode to more than 256 MB"), + ( + pdf(plain("q 10 0 0 10 0 0 cm /Im Do Q"), packed_as_is(image, &spaces)), + "an image of 10 × 10 px in it (object 8) would decode to more than 256 MB", + ), + ]; + on_a_small_stack(move || { + for (bytes, why) in &files { + let e = vectorcraft_engine::cmd::fileio::load("shared.pdf", bytes).err().map(|e| e.to_string()); + assert!(e.as_deref().is_some_and(|e| e.contains(why)), "{why}: {e:?}"); + } + }); +} + #[test] fn pdf_art_that_draws_itself() { let own = "<< /XObject << /F 5 0 R >> /Pattern << /P 5 0 R >> /Font << /F1 5 0 R >> /ExtGState << /G << /SMask << /S /Alpha /G 5 0 R >> >> >> >>"; diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 34ea963ce..6a72bc51f 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -1,7 +1,7 @@ //! The pages of a PDF to import: opening with a password, the pages picked, the box each page //! is cropped to ([`CropTo`]) and [`info`]. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use craft_pdf::syntax::object::{Array, Dict, MaybeRef, Name, Object, ObjectIdentifier, Stream, dict_or_stream}; use craft_pdf::syntax::page::Page; @@ -193,9 +193,16 @@ fn stream_kind(pdf: &Pdf, id: ObjectIdentifier, dict: &Dict<'_>) -> String { } let is = |m: Option>>| matches!(m, Some(MaybeRef::Ref(r)) if ObjectIdentifier::from(r) == id); let pages = pdf.pages(); + // `Contents` is a stream or an array of them, the array in the page or an object of its own. + // Each object is read once however many pages share it, so a file can't make this quadratic. + let mut read = HashSet::new(); let content = pages.iter().any(|page| match page.raw().get_raw::>(b"Contents") { Some(MaybeRef::NotRef(Object::Array(a))) => items(&a).any(|m| is(Some(m))), - other => is(other), + Some(MaybeRef::Ref(r)) => { + let at = ObjectIdentifier::from(r); + read.insert(at) && page.raw().get::>(b"Contents").map_or(at == id, |a| items(&a).any(|m| is(Some(m)))) + } + _ => false, }); if content { return "a page's content in it".to_string();