From a69ddea930c320c121a70bd1e94c0695278c10c3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 14 Oct 2026 23:09:40 -0700 Subject: [PATCH] Importers: a PDF dictionary value the reader can't parse no longer crashes the nesting check The shared PDF reader (craft_pdf::syntax, craft-libs ca50d63b) indexes a dictionary by skipping its values. Its skip accepts some values that its parse refuses, such as a name with a broken # escape (/#zz) and a number such as 12-.5. Dict::entries unwraps the parse of each value (vendor/hayro-syntax/src/object/dict.rs:104). The check in pages.rs that refuses functions and color spaces built on one another without end (nests_without_end) calls entries on every dictionary of a file when the file opens. So document.pdfInfo, PDF import, clipboard.importPdf and loading a .ai panicked on such a file, and three import fuzz tests fail on main with PROPTEST_RNG_SEED=1182 and PROPTEST_CASES=20000. The check now reads a dictionary's values with keys and get_raw and leaves out a value that doesn't parse. A test in crates/pdf puts each of those values in a file with a plain function, which opens, and in a file with a function that stitches itself, which is still refused. --- crates/pdf/src/pages.rs | 7 ++++++- crates/pdf/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index 17618280e..7bc47c9bd 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -46,6 +46,11 @@ const MAX_LINKS: usize = 16; /// The application's calculator-procedure depth policy, layered over the shared reader's limits. const MAX_PROCEDURES: usize = 64; +/// The values of `d` that the reader can parse (`Dict::entries` panics on one it can't). +fn values<'a>(d: &Dict<'a>) -> impl Iterator>> { + d.keys().filter_map(|k| d.get_raw::>(k.as_ref())) +} + /// What the function or colour space `o` is built on: the functions a stitching function joins; /// the base, alternate and tint of a colour space; the alternate of a colour profile. Nothing for /// any other object. @@ -144,7 +149,7 @@ fn nests_without_end(pdf: &Pdf) -> bool { } match &o { Object::Array(a) => pending.extend(a.raw_iter().filter_map(direct)), - _ => pending.extend(dict_or_stream(&o).into_iter().flat_map(|(d, _)| d.entries()).filter_map(|(_, v)| direct(v))), + _ => pending.extend(dict_or_stream(&o).into_iter().flat_map(|(d, _)| values(d)).filter_map(direct)), } } false diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index a868dec79..28e02d633 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -796,3 +796,26 @@ fn functions_and_colour_spaces_built_on_themselves_are_refused() { assert!(d.layers.iter().any(|l| l.children().is_some_and(|c| !c.is_empty())), "{what}: its art is read"); } } + +/// A dictionary value the reader can't parse (a name with a broken `#` escape, a number with a +/// stray `-`) is left out of the nesting check, which goes on with the rest of the file: a file +/// whose shading has a plain function is read, and one whose function stitches itself is refused. +#[test] +fn dictionary_values_the_reader_cannot_parse_are_skipped() { + use vectorcraft_testkit::pdf::{PdfPage, first_extra, pdf_with}; + let me = format!("{} 0 R", first_extra(1)); + let shaded = PdfPage { + resources: format!("/Shading << /S << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function {me} >> >>"), + ..PdfPage::new(100.0, 100.0, "/S sh") + }; + let ramp = "<< /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >>"; + let stitch = format!("<< /FunctionType 3 /Domain [0 1] /Functions [{me}] /Bounds [] /Encode [0 1] >>"); + for junk in ["<< /A /#zz >>", "<< /A 12-.5 >>"] { + let [plain, looped] = [ramp, stitch.as_str()].map(|f| pdf_with(std::slice::from_ref(&shaded), &[f, junk], None)); + assert_eq!(info(&plain, None).map(|i| i.pages.len()).ok(), Some(1), "{junk}"); + let d = import(&plain).unwrap_or_else(|e| panic!("{junk}: {e}")); + assert!(d.layers.iter().any(|l| l.children().is_some_and(|c| !c.is_empty())), "{junk}: its art is read"); + let e = import(&looped).err().map(|e| e.to_string()); + assert!(e.as_deref().is_some_and(|e| e.contains("built on one another without end")), "{junk}: {e:?}"); + } +}