From 146c43267af02b19c0cdde6bf0255593d6693c61 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 12:48:46 -0700 Subject: [PATCH] Tests: the PDF import fuzz covers values the reader can't parse and functions inside other objects A new import fuzz test, pdf_resources_the_reader_cannot_parse_never_panic, imports a page whose resources hold: - a value the reader can't parse (/#zz, 12-.5) or one it reads (/Fine) in the color space dictionary, before a Separation space the page paints in, and in the font dictionary, before the font of its text; - a calculator function written as the shading's function, or inside an array or a dictionary in the page's /Properties: a plain one, one with Columns 0, one with a huge Columns, or one of 257 MiB of deflated spaces. The import must not panic. The file opens with its art, or it is refused with "would decode to more than 256 MB" when the function is not plain. With the nesting check, the font scan or the ink scan reading Dict::entries again, the test fails on a value the reader can't parse. With the stream check made on the file's objects alone, the test fails on every function that is not plain: the file opens instead of being refused. The calculator helper of pdf_streams_the_reader_would_fail_on_are_refused is now a function of the file, and the function of spaces is made once. --- crates/engine/tests/import_fuzz.rs | 85 +++++++++++++++++++++++++++--- 1 file changed, 78 insertions(+), 7 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index b60942e50..a6553a997 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -654,6 +654,69 @@ proptest! { } } +/// A dictionary value: one the reader reads, or one it skips over but can't parse (a name with a +/// broken `#` escape, a number with a stray `-`). +fn arb_dict_value() -> impl Strategy { + prop::sample::select(vec!["/Fine", "/#zz", "12-.5"]) +} + +/// A page's resources: `space` in the color space dictionary before a Separation space, `font` +/// in the font dictionary before a font, and a calculator function (`function`: plain, with +/// decode parameters the reader can't apply, or decoding past the cap) written as the shading's +/// function, or inside an array or a dictionary under `/Properties` (`held`). Returns the +/// resources and whether a file with them is refused. +fn unreadable_resources(space: &str, font: &str, function: &str, held: &str) -> (String, bool) { + let program = deflated(b"{ dup 1 exch sub 0 }"); + let (function, refused) = match function { + "Columns 0" => (calculator(&program, "/DecodeParms [null << /Predictor 2 /Columns 0 >>]"), true), + "a huge Columns" => (calculator(&program, "/DecodeParms [null << /Predictor 12 /Colors 4 /Columns 99999999999 >>]"), true), + "spaces past the cap" => (spaces_past_the_cap_once().to_string(), true), + _ => (calculator(&program, ""), false), + }; + let ramp = "<< /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >>"; + let (shaded, other) = match held { + "in an array" => (ramp, format!("/Properties << /P0 [{function}] >>")), + "in a dictionary" => (ramp, format!("/Properties << /P0 << /K {function} >> >>")), + _ => (function.as_str(), String::new()), + }; + let tint = "<< /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0 1 0] /N 1 >>"; + let resources = format!( + "<< /ColorSpace << /A {space} /CS0 [/Separation /Gold /DeviceCMYK {tint}] >> \ + /Font << /A {font} /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> \ + /Shading << /Sh0 << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function {shaded} >> >> {other} >>" + ); + (resources, refused) +} + +proptest! { + #![proptest_config(config())] + + /// A page that paints with each of [`unreadable_resources`] is imported without a panic. The + /// file opens with its art, or is refused when its calculator function decodes past the cap or + /// has decode parameters the reader can't apply. + #[test] + fn pdf_resources_the_reader_cannot_parse_never_panic( + space in arb_dict_value(), + font in arb_dict_value(), + function in prop::sample::select(vec!["plain", "Columns 0", "a huge Columns", "spaces past the cap"]), + held in prop::sample::select(vec!["as the shading's function", "in an array", "in a dictionary"]), + ) { + let (resources, refused) = unreadable_resources(space, font, function, held); + let pdf = handmade_pdf("/CS0 cs 1 scn 0 0 50 50 re f BT /F1 12 Tf 10 60 Td (Hi) Tj ET /Sh0 sh", &resources, "[0 0 100 100]"); + let what = format!("{space} and {font}, a function ({function}) {held}"); + let mut read = None; + survive(&what, || { + let r = vectorcraft_pdf::import(&pdf); + read = Some(r.as_ref().map(|d| d.layers.iter().any(|l| l.children().is_some_and(|c| !c.is_empty()))).map_err(ToString::to_string)); + r.ok() + })?; + match read.expect("the import ran") { + Ok(art) => prop_assert!(!refused && art, "{what}: opened (its art read: {art})"), + Err(e) => prop_assert!(refused && e.contains("would decode to more than 256 MB"), "{what}: {e}"), + } + } +} + /// A page box with junk in it: inverted, empty, huge, NaN or outside the media box. fn arb_box() -> impl Strategy> { prop_oneof![ @@ -3080,6 +3143,21 @@ fn spaces_past_the_cap() -> Vec { e.finish().unwrap() } +/// [`calculator`] of [`spaces_past_the_cap`], made once. +fn spaces_past_the_cap_once() -> &'static str { + static FUNCTION: std::sync::OnceLock = std::sync::OnceLock::new(); + FUNCTION.get_or_init(|| calculator(&spaces_past_the_cap(), "")) +} + +/// A calculator function with the deflated program `z` and the decode parameters `dp`, to write +/// inside another object. Its data is in hexadecimal digits with spaces between them, so the +/// object around it still reads. +fn calculator(z: &[u8], dp: &str) -> String { + let data: String = z.iter().map(|b| format!("{:X} {:X} ", b >> 4, b & 15)).collect(); + let dict = format!("/FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Filter [/ASCIIHexDecode /FlateDecode] {dp}"); + format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) +} + // Streams the reader would fail on (#1700, #1701): it applies a predictor before checking its // parameters (`Columns 0` divides by zero, a huge `Columns` allocates a row that large), and // inflates without a ceiling. @@ -3094,13 +3172,6 @@ fn pdf_streams_the_reader_would_fail_on_are_refused() { format!("q 10 0 0 10 0 0 cm BI /W 2 /H 2 /CS /G /BPC 8 /F [/AHx /Fl] /DP [null {dp}] ID {data}> EI Q") }; let form = |dict: &str, content: &str| pdf_with("/F Do", "<< /XObject << /F 5 0 R >> >>", &[packed(&format!("{FORM} {dict}"), content)]); - // A calculator function with the deflated program `z`, to write inside another object. Its - // data is in hexadecimal digits with spaces between them, so the object around it still reads. - let calculator = |z: &[u8], dp: &str| { - let data: String = z.iter().map(|b| format!("{:X} {:X} ", b >> 4, b & 15)).collect(); - let dict = format!("/FunctionType 4 /Domain [0 1] /Range [0 1 0 1 0 1] /Filter [/ASCIIHexDecode /FlateDecode] {dp}"); - format!("<< {dict} /Length {} >>\nstream\n{data}\nendstream", data.len()) - }; let held = |object: String| pdf_with("0 0 5 5 re f", "<< >>", &[object.into_bytes()]); let shaded = |function: &str| { let shading = format!("<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 100 0] /Function {function} >>");