From b96d35139c3e060ffe81da108a4382f5b655da14 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Thu, 29 Oct 2026 22:12:16 +0700 Subject: [PATCH] PDF import: a refused file says which stream it trips on (refs #4366) Squashed from #4603 onto main. --- crates/engine/tests/import_fuzz.rs | 105 +++++++++++++++++++++++++---- crates/pdf/src/pages.rs | 86 ++++++++++++++++++++--- crates/pdf/src/streams.rs | 42 ++++++++---- 3 files changed, 199 insertions(+), 34 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 639fe0679..f6bf5e3a9 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -872,9 +872,15 @@ proptest! { 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() })?; + // A function written in the page's resources goes by the page's object (#4366). + let why = if function == "spaces past the cap" { + "a stream in it (object 3) would decode to more than 256 MB" + } else { + "a stream in it (object 3) has decode parameters that can't be applied" + }; 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}"), + Err(e) => prop_assert!(refused && e.contains(why), "{what}: {e}"), } } } @@ -3762,24 +3768,52 @@ fn pdf_streams_the_reader_would_fail_on_are_refused() { }; let spaces = calculator(&spaces_past_the_cap(), ""); let program = deflated(b"{ dup 1 exch sub 0 }"); + // The message names the stream, so a report of a file that can't be shared says which (#4366). + let (parameters, cap) = ("has decode parameters that can't be applied", "would decode to more than 256 MB"); let files = [ - ("a form with Columns 0", form(zero, "0 0 5 5 re f")), - ("a form with a huge Columns", form(huge, "0 0 5 5 re f")), - ("an inline image with Columns 0", deep_pdf(&inline("<< /Predictor 2 /Columns 0 >>"), "<< >>")), - ("an inline image with a huge Columns", deep_pdf(&inline("<< /Predictor 15 /Columns 99999999999 >>"), "<< >>")), - ("an inline image in a compressed form", form("", &inline("<< /Predictor 2 /Columns 0 >>"))), - ("an object stream with Columns 0", pdf_with_object_stream("[0 0 100 100]", zero)), - ("a form of spaces past the cap", pdf_with("/F Do", "<< /XObject << /F 5 0 R >> >>", &[packed_as_is(FORM, &spaces_past_the_cap())])), - ("a function of spaces past the cap in an array", held(format!("[{spaces}]"))), - ("a function of spaces past the cap in a dictionary", held(format!("<< /K {spaces} >>"))), - ("a function of spaces past the cap in a shading", shaded(&spaces)), - ("a function with Columns 0 in an array", held(format!("[{}]", calculator(&program, "/DecodeParms [null << /Predictor 2 /Columns 0 >>]")))), + ("a form with Columns 0", form(zero, "0 0 5 5 re f"), format!("a form in it (object 5) {parameters}")), + ("a form with a huge Columns", form(huge, "0 0 5 5 re f"), format!("a form in it (object 5) {parameters}")), + ( + "an inline image with Columns 0", + deep_pdf(&inline("<< /Predictor 2 /Columns 0 >>"), "<< >>"), + format!("an inline image in the content of object 4 {parameters}"), + ), + ( + "an inline image with a huge Columns", + deep_pdf(&inline("<< /Predictor 15 /Columns 99999999999 >>"), "<< >>"), + format!("an inline image in the content of object 4 {parameters}"), + ), + ( + "an inline image in a compressed form", + form("", &inline("<< /Predictor 2 /Columns 0 >>")), + format!("an inline image in the content of object 5 {parameters}"), + ), + // Read as the file opens, before its objects can be looked at one by one. + ( + "an object stream with Columns 0", + pdf_with_object_stream("[0 0 100 100]", zero), + format!("a stream in it {cap}, or its decode parameters can't be applied"), + ), + ( + "a form of spaces past the cap", + pdf_with("/F Do", "<< /XObject << /F 5 0 R >> >>", &[packed_as_is(FORM, &spaces_past_the_cap())]), + format!("a form in it (object 5) {cap}"), + ), + // A function written inside object 5 goes by it. + ("a function of spaces past the cap in an array", held(format!("[{spaces}]")), format!("a stream in it (object 5) {cap}")), + ("a function of spaces past the cap in a dictionary", held(format!("<< /K {spaces} >>")), format!("a stream in it (object 5) {cap}")), + ("a function of spaces past the cap in a shading", shaded(&spaces), format!("a stream in it (object 5) {cap}")), + ( + "a function with Columns 0 in an array", + held(format!("[{}]", calculator(&program, "/DecodeParms [null << /Predictor 2 /Columns 0 >>]"))), + format!("a stream in it (object 5) {parameters}"), + ), ]; on_a_small_stack(move || { - for (what, bytes) in &files { + for (what, bytes, why) in &files { for name in ["hostile.pdf", "hostile.ai"] { let e = vectorcraft_engine::cmd::fileio::load(name, bytes).err().map(|e| e.to_string()); - assert!(e.as_deref().is_some_and(|e| e.contains("would decode to more than 256 MB")), "{what} as {name}: {e:?}"); + assert!(e.as_deref().is_some_and(|e| e.contains(why.as_str())), "{what} as {name}: {e:?}"); } } }); @@ -3811,6 +3845,49 @@ fn pdf_a_huge_form_drawn_over_and_over_is_refused_in_bounded_time() { assert!(took < std::time::Duration::from_secs(60), "took {took:?}"); } +// A large file the reader would fail on says which stream (#4366): an image with its size in +// pixels, a page's content, or the editing data an `.ai` keeps in its first page's `PieceInfo`. +#[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} >>") + .into_bytes() + }; + let plain = |content: &str| format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len() + 1).into_bytes(); + let pdf = |page: Vec, more: Vec>| { + let head = [b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(), b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(), page]; + pdf_of(&head.into_iter().chain(more).collect::>()) + }; + let spaces = spaces_past_the_cap(); + let editing = "/PieceInfo << /Illustrator << /Private << /AIPrivateData1 6 0 R /NumBlock 1 >> >> >>"; + let files = [ + ( + "an image", + pdf(page(""), 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")]), + "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)]), + "its editing data (object 6) would decode to more than 256 MB", + ), + ]; + on_a_small_stack(move || { + for (what, bytes, why) in &files { + for name in ["large.pdf", "large.ai"] { + let e = vectorcraft_engine::cmd::fileio::load(name, bytes).err().map(|e| e.to_string()); + assert!(e.as_deref().is_some_and(|e| e.contains(why)), "{what} as {name}: {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 650c4a7dd..34ea963ce 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -11,6 +11,7 @@ use kurbo::{Affine, Rect}; use vectorcraft_doc::setup::MAX_BLEED; use crate::import_color::{direct, items, readable, values}; +use crate::streams::{Refusal, Refused, refused}; use crate::{Choice, CropTo, ImportOptions, PdfError}; /// What `info` tells about one page. @@ -115,7 +116,8 @@ fn procedures_too_deep(o: &Object<'_>) -> bool { }) } -/// The error of a file with a stream the reader would fail on ([`crate::streams`]). +/// The error of a file with an object or cross-reference stream the reader would fail on as it +/// opens the file ([`crate::streams::unsafe_file_streams`]); [`stream_note`] names any other. const STREAM_NOTE: &str = "a stream in it would decode to more than 256 MB, or its decode parameters can't be applied"; /// The error of a file with functions or color spaces built on one another past [`MAX_LINKS`] @@ -125,28 +127,30 @@ const NESTED_NOTE: &str = "its functions or colour spaces are built on one anoth /// The error of a file with an array whose items can't all be read (a damaged file). const UNREADABLE_NOTE: &str = "an array in it is damaged and can't be read"; -/// Why `pdf` is refused, if it is: a stream the reader would fail on ([`STREAM_NOTE`]), or +/// Why `pdf` is refused, if it is: a stream the reader would fail on ([`stream_note`]), or /// functions, color spaces or calculator procedures nested too deep ([`NESTED_NOTE`]). Every /// object is looked at, and every array, dictionary and stream written inside one, wherever it /// is used from: the interpreter builds functions and color spaces from resources, shadings, /// images, patterns, masks and graphics states alike. -fn hostile(pdf: &Pdf) -> Option<&'static str> { +fn hostile(pdf: &Pdf) -> Option { let xref = pdf.xref(); let mut known = HashMap::new(); // The objects of the file, then what each holds directly (64 deep at most: the reader's cap). let mut pending: Vec> = pdf.objects().into_iter().collect(); while let Some(o) = pending.pop() { // A stream's own checks come first: a calculator function is decoded below. - if matches!(&o, Object::Stream(s) if crate::streams::unsafe_stream(s)) { - return Some(STREAM_NOTE); + if let Object::Stream(s) = &o + && let Some(r) = refused(s) + { + return Some(stream_note(pdf, s, r)); } if procedures_too_deep(&o) || built_on(MaybeRef::NotRef(o.clone()), xref, &mut known, MAX_LINKS).is_none() { - return Some(NESTED_NOTE); + return Some(NESTED_NOTE.into()); } match &o { // An array with an item that can't be read can't be looked at whole, and the // interpreter would still read its leading items: the file is refused. - Object::Array(a) if !readable(a) => return Some(UNREADABLE_NOTE), + Object::Array(a) if !readable(a) => return Some(UNREADABLE_NOTE.into()), Object::Array(a) => pending.extend(items(a).filter_map(direct)), _ => pending.extend(dict_or_stream(&o).into_iter().flat_map(|(d, _)| values(d)).filter_map(direct)), } @@ -154,6 +158,61 @@ fn hostile(pdf: &Pdf) -> Option<&'static str> { None } +/// The error of a file with the stream `s` the reader would fail on, as [`refused`] says why: +/// which object it is and what it holds, so a report of a file that can't be shared still says +/// which stream it is (#4366). +fn stream_note(pdf: &Pdf, s: &Stream<'_>, r: Refusal) -> String { + let id = s.obj_id(); + // A stream written inside another object goes by that object; 0 is a stream the reader can't + // place. + let object = (id.obj_number > 0).then(|| format!("object {}", id.obj_number)); + let what = match (r.inline_image, object) { + (true, Some(object)) => format!("an inline image in the content of {object}"), + (true, None) => "an inline image in it".to_string(), + (false, Some(object)) => format!("{} ({object})", stream_kind(pdf, id, s.dict())), + (false, None) => stream_kind(pdf, id, s.dict()), + }; + match r.why { + Refused::TooBig(most) => format!("{what} would decode to more than {}", decoded_size(most)), + Refused::Parameters => format!("{what} has decode parameters that can't be applied"), + } +} + +/// What the stream `id` with dictionary `dict` holds, for [`stream_note`]: an image (and its size +/// in pixels), a form, a page's content, the editing data an `.ai` keeps in its first page's +/// `PieceInfo`, or another stream. +fn stream_kind(pdf: &Pdf, id: ObjectIdentifier, dict: &Dict<'_>) -> String { + let subtype = dict.get::>(b"Subtype"); + let subtype_is = |v: &[u8]| subtype.as_ref().is_some_and(|n| n.as_ref() == v); + if subtype_is(b"Image") { + let side = |key: &[u8]| dict.get::(key).unwrap_or(0); + return format!("an image of {} × {} px in it", side(b"Width"), side(b"Height")); + } + if subtype_is(b"Form") { + return "a form in it".to_string(); + } + let is = |m: Option>>| matches!(m, Some(MaybeRef::Ref(r)) if ObjectIdentifier::from(r) == id); + let pages = pdf.pages(); + 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), + }); + if content { + return "a page's content in it".to_string(); + } + let private = pages.first().and_then(|p| p.raw().get::>(b"PieceInfo")?.get::>(PRIVATE_DATA_OWNER)?.get::>(b"Private")); + if private.is_some_and(|d| d.entries().any(|(key, m)| key.as_ref().starts_with(b"AI") && is(Some(m)))) { + return "its editing data".to_string(); + } + "a stream in it".to_string() +} + +/// `bytes` for a message, in whole megabytes, or gigabytes when it is a whole number of them. +fn decoded_size(bytes: usize) -> String { + const GB: usize = 1 << 30; + if bytes >= GB && bytes.is_multiple_of(GB) { format!("{} GB", bytes / GB) } else { format!("{} MB", bytes >> 20) } +} + /// Read a PDF, decrypting it with `password` (none: the empty user password). The permissions /// password opens a file too. /// @@ -202,7 +261,7 @@ pub(crate) fn open_with_options(bytes: &[u8], password: Option<&str>, options: & // Twice: looking at every object makes the reader repair a damaged table of objects (once // only), and the objects are other ones after that. .and_then(|pdf| match (0..2).find_map(|_| hostile(&pdf)) { - Some(why) => Err(PdfError::Parse(why.into())), + Some(why) => Err(PdfError::Parse(why)), None => Ok(pdf), }) } @@ -366,3 +425,14 @@ fn piece_info_has(page: &Page<'_>, prefixes: &[&[u8]]) -> bool { } page.raw().get::>(b"PieceInfo").is_some_and(|d| walk(&d, prefixes, 0)) } + +#[cfg(test)] +mod tests { + #[test] + fn decoded_sizes_read_in_whole_units() { + assert_eq!(super::decoded_size(256 << 20), "256 MB"); + assert_eq!(super::decoded_size(418 << 20), "418 MB"); + assert_eq!(super::decoded_size(1 << 30), "1 GB"); + assert_eq!(super::decoded_size((1 << 30) + (1 << 20)), "1025 MB"); + } +} diff --git a/crates/pdf/src/streams.rs b/crates/pdf/src/streams.rs index 7da9ac94c..b2142ace3 100644 --- a/crates/pdf/src/streams.rs +++ b/crates/pdf/src/streams.rs @@ -234,34 +234,52 @@ fn maybe_content(dict: &Dict<'_>) -> bool { && ![&b"Length1"[..], b"Length2", b"Length3", b"FunctionType", b"N"].iter().any(|k| dict.contains_key(k)) } -/// Would the reader fail on `stream` (or on an inline image in it, when it is content): a -/// predictor it can't apply, or more than its [`budget`] decoded? -pub(crate) fn unsafe_stream(stream: &Stream<'_>) -> bool { - unsafe_data(stream, true) +/// Why the reader would fail on a stream ([`refused`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Refused { + /// A predictor whose decode parameters it can't apply ([`bad_predictor`]). + Parameters, + /// More than this many bytes decoded: the stream's [`budget`]. + TooBig(usize), } -/// [`unsafe_stream`], looking for inline images in it when `inside` (not in an inline image's own +/// A stream the reader would fail on: why, and whether it is an inline image in it that it would +/// fail on. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Refusal { + pub why: Refused, + pub inline_image: bool, +} + +/// Would the reader fail on `stream` (or on an inline image in it, when it is content): a +/// predictor it can't apply, or more than its [`budget`] decoded? Why, if so. +pub(crate) fn refused(stream: &Stream<'_>) -> Option { + refused_data(stream, true) +} + +/// [`refused`], looking for inline images in it when `inside` (not in an inline image's own /// data: it would be read again for each inline image a hostile one nests). -fn unsafe_data(stream: &Stream<'_>, inside: bool) -> bool { +fn refused_data(stream: &Stream<'_>, inside: bool) -> Option { let dict = stream.dict(); + let refusal = |why| Some(Refusal { why, inline_image: !inside }); if params(dict).iter().any(bad_predictor) { - return true; + return refusal(Refused::Parameters); } let content = inside && maybe_content(dict); match decode(&stream.raw_data(), dict, content) { - Err(TooBig) => true, + Err(TooBig) => refusal(Refused::TooBig(budget(dict))), Ok(Some(data)) if content && data.windows(2).any(|w| w == b"ID") => { let mut ops = TypedIter::new(&data); while let Some(op) = ops.next() { if let TypedInstruction::InlineImage(image) = op - && unsafe_data(image.0, false) + && let Some(r) = refused_data(image.0, false) { - return true; + return Some(r); } } - false + None } - Ok(_) => false, + Ok(_) => None, } }