diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 60f49fb35..c6bf8757e 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -3448,3 +3448,18 @@ fn a_pattern_painting_its_own_tile_opens_without_the_paint() { } }); } + +/// A .ai cut short inside an array or dictionary of its PDF part (found by +/// `ai_mutated_save_never_panics`): a value can't be read whole, and hayro's iterators over the +/// array's items or the dictionary's entries would panic on it. +#[test] +fn ai_cut_inside_an_array_or_dictionary_opens_without_panicking() { + for flip in [None, Some((12262, b'/'))] { + let mut b = saved_ai(); + if let Some((at, c)) = flip { + b[at] = c; + } + b.truncate(44902.min(b.len())); + survive("ai cut inside an array or dictionary", || vectorcraft_engine::cmd::fileio::load("saved.ai", &b).ok().map(|l| l.doc)).unwrap(); + } +} diff --git a/crates/pdf/src/editing.rs b/crates/pdf/src/editing.rs index 6c818198f..c87c57239 100644 --- a/crates/pdf/src/editing.rs +++ b/crates/pdf/src/editing.rs @@ -12,6 +12,7 @@ use krilla::embed::{AssociationKind, EmbeddedFile, MimeType}; use krilla::metadata::DateTime; use crate::PdfError; +use crate::import_color::items; use crate::lab_spot::find; /// The embedded file holding the native document (its name among the PDF's attachments). @@ -110,7 +111,7 @@ fn editing_spec<'a>(node: &Dict<'a>, depth: usize, seen: &mut HashSet>(b"Kids")?; // Only a referenced kid can be shared (a direct kid's `obj_id` is the object holding it). - kids.raw_iter().zip(kids.iter::>()).find_map(|(raw, kid)| { + items(&kids).zip(kids.iter::>()).find_map(|(raw, kid)| { if raw.as_obj_ref().is_some_and(|r| !seen.insert(r.into())) { return None; } diff --git a/crates/pdf/src/import_color.rs b/crates/pdf/src/import_color.rs index 4a698a7b4..8a2c1d28e 100644 --- a/crates/pdf/src/import_color.rs +++ b/crates/pdf/src/import_color.rs @@ -12,7 +12,8 @@ use std::fmt::{self, Debug, Write as _}; use craft_pdf::interpret::Function; use craft_pdf::interpret::color::ColorSpace; use craft_pdf::syntax::Pdf; -use craft_pdf::syntax::object::{Array, MaybeRef, Name, Object, Stream}; +use craft_pdf::syntax::object::{Array, Dict, MaybeRef, Name, Object, Stream}; +use craft_pdf::syntax::reader::{Reader, ReaderExt}; use smallvec::SmallVec; use vectorcraft_color::swatch::REGISTRATION; use vectorcraft_color::{Color, Paint, Swatch}; @@ -304,7 +305,7 @@ impl<'p> Colors<'p> { match o { Object::Array(a) => { self.ink_space(&a); - for item in a.raw_iter().filter_map(direct) { + for item in items(&a).filter_map(direct) { self.visit(item, depth + 1); } } @@ -379,6 +380,28 @@ impl<'p> Colors<'p> { } /// An object written in place (references are visited as objects of their own). +/// Can every item of `a` be read? Not in a damaged file, and hayro's own iterator over the items +/// unwraps each read and panics there. +/// +/// The items are read without the array's context. That reading fails wherever the array's own +/// would (a string it can't decrypt is kept as it is either way; a stream whose `/Length` it can't +/// follow falls back to finding `endstream`, as the array's reading does when its length is wrong). +pub(crate) fn readable(a: &Array<'_>) -> bool { + let mut r = Reader::new(a.data()); + std::iter::from_fn(|| { + r.skip_white_spaces_and_comments(); + (!r.at_end()).then(|| r.read_without_context::>>().is_some()) + }) + .all(|read| read) +} + +/// The items of `a`, or none when one of them can't be read ([`readable`]). A file with such an +/// array is refused before it is imported ([`crate::pages`]), so that none never hides what the +/// interpreter would still read of it (its leading items). +pub(crate) fn items<'a>(a: &Array<'a>) -> impl Iterator>> + use<'a> { + readable(a).then(|| a.raw_iter()).into_iter().flatten() +} + pub(crate) fn direct(m: MaybeRef>) -> Option> { match m { MaybeRef::NotRef(o) => Some(o), @@ -387,7 +410,7 @@ pub(crate) fn direct(m: MaybeRef>) -> Option> { } /// The values of `d` that the reader can parse, sorted by key as `Dict::entries` sorts them. -pub(crate) fn values<'a>(d: &craft_pdf::syntax::object::Dict<'a>) -> impl Iterator>> { +pub(crate) fn values<'a>(d: &Dict<'a>) -> impl Iterator>> { let mut keys: Vec> = d.keys().collect(); keys.sort_by(|a, b| a.as_ref().cmp(b.as_ref())); keys.into_iter().filter_map(|k| d.get_raw::>(k.as_ref())) diff --git a/crates/pdf/src/import_scan.rs b/crates/pdf/src/import_scan.rs index 65124d8e0..80f80851f 100644 --- a/crates/pdf/src/import_scan.rs +++ b/crates/pdf/src/import_scan.rs @@ -14,6 +14,7 @@ use std::collections::{HashMap, HashSet}; +use crate::import_color::items; use craft_pdf::interpret::CacheKey; use craft_pdf::syntax::Pdf; use craft_pdf::syntax::content::TypedIter; @@ -78,7 +79,7 @@ impl Ocgs { let config = props.get::>(b"D").unwrap_or_default(); let refs = |d: &Dict<'_>, key: &[u8]| -> Vec { d.get::>(key) - .map(|a| a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).take(MAX_GROUPS).collect()) + .map(|a| items(&a).filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).take(MAX_GROUPS).collect()) .unwrap_or_default() }; let all = refs(&props, b"OCGs"); @@ -111,7 +112,7 @@ impl Ocgs { /// stays where it is first listed. `seen`: the entries read so far. fn nest(&mut self, order: &Array<'_>, parent: Option, depth: usize, xref: &craft_pdf::syntax::xref::XRef, seen: &mut usize) { let mut prev = None; - for item in order.raw_iter() { + for item in items(order) { *seen += 1; if *seen > 4 * MAX_GROUPS { return; @@ -130,7 +131,7 @@ impl Ocgs { if let Some(sub) = sub && depth < MAX_NESTING { - let label = matches!(sub.raw_iter().next(), Some(MaybeRef::NotRef(Object::String(_)))); + let label = matches!(items(&sub).next(), Some(MaybeRef::NotRef(Object::String(_)))); let under = prev.take().filter(|_| !label).or(parent); self.nest(&sub, under, depth + 1, xref, seen); } @@ -177,7 +178,7 @@ impl Ocgs { /// object) belongs to: a membership dictionary's first group. fn group_of(&mut self, d: &Dict<'_>, id: ObjectIdentifier, xref: &craft_pdf::syntax::xref::XRef) -> Option { if d.get::>(b"Type").is_some_and(|t| t.as_ref() == b"OCMD") { - let first = d.get::>(b"OCGs").and_then(|a| a.raw_iter().find_map(|i| i.as_obj_ref())).or_else(|| d.get_ref(b"OCGs"))?; + let first = d.get::>(b"OCGs").and_then(|a| items(&a).find_map(|i| i.as_obj_ref())).or_else(|| d.get_ref(b"OCGs"))?; let id = ObjectIdentifier::from(first); return self.note(id, &xref.get::>(id)?); } @@ -355,7 +356,7 @@ impl Walker<'_, '_> { Some(id) if !membership => !self.off.contains(&id), _ => { let ids: Vec = match d.get::>(b"OCGs") { - Some(a) => a.raw_iter().filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect(), + Some(a) => items(&a).filter_map(|i| i.as_obj_ref()).map(ObjectIdentifier::from).collect(), None => d.get_ref(b"OCGs").map(ObjectIdentifier::from).into_iter().collect(), }; let on = |i: &ObjectIdentifier| !self.off.contains(i); diff --git a/crates/pdf/src/pages.rs b/crates/pdf/src/pages.rs index aef1331a2..8ee3c536e 100644 --- a/crates/pdf/src/pages.rs +++ b/crates/pdf/src/pages.rs @@ -10,7 +10,7 @@ use craft_pdf::syntax::{DecryptionError, LoadPdfError, Pdf}; use kurbo::{Affine, Rect}; use vectorcraft_doc::setup::MAX_BLEED; -use crate::import_color::{direct, values}; +use crate::import_color::{direct, items, readable, values}; use crate::{Choice, CropTo, ImportOptions, PdfError}; /// What `info` tells about one page. @@ -52,7 +52,7 @@ const MAX_PROCEDURES: usize = 64; fn links<'a>(o: &Object<'a>, xref: &'a XRef) -> Vec>> { const SPACES: [&[u8]; 6] = [b"Indexed", b"I", b"Separation", b"DeviceN", b"Pattern", b"ICCBased"]; if let Object::Array(a) = o { - let mut items = a.raw_iter(); + let mut items = items(a); let kind = match items.next() { Some(MaybeRef::NotRef(Object::Name(n))) => Some(n), Some(MaybeRef::Ref(r)) => xref.get::>(r.into()), @@ -61,7 +61,7 @@ fn links<'a>(o: &Object<'a>, xref: &'a XRef) -> Vec>> { return if kind.is_some_and(|n| SPACES.contains(&n.as_ref())) { items.collect() } else { vec![] }; } let Some((d, _)) = dict_or_stream(o) else { return vec![] }; - let functions = d.get::>(b"Functions").map(|a| a.raw_iter().collect::>()).unwrap_or_default(); + let functions = d.get::>(b"Functions").map(|a| items(&a).collect::>()).unwrap_or_default(); functions.into_iter().chain(d.get_raw::>(b"Alternate")).collect() } @@ -121,6 +121,9 @@ const STREAM_NOTE: &str = "a stream in it would decode to more than 256 MB, or i /// (or on themselves), or a calculator function nested past [`MAX_PROCEDURES`]. const NESTED_NOTE: &str = "its functions or colour spaces are built on one another without end"; +/// 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 /// 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 @@ -140,7 +143,10 @@ fn hostile(pdf: &Pdf) -> Option<&'static str> { return Some(NESTED_NOTE); } match &o { - Object::Array(a) => pending.extend(a.raw_iter().filter_map(direct)), + // 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) => pending.extend(items(a).filter_map(direct)), _ => pending.extend(dict_or_stream(&o).into_iter().flat_map(|(d, _)| values(d)).filter_map(direct)), } } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index c9ea15851..71c88ec79 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -806,6 +806,10 @@ fn functions_and_colour_spaces_built_on_themselves_are_refused() { ("procedures in procedures", &shaded, vec![calculator(&format!("{}{}", "{".repeat(100), "}".repeat(100)))]), ("a colour space on itself", &filled, vec![indexed(&me)]), ("two colour spaces on each other", &filled, vec![indexed(&next), indexed(&me)]), + // Its leading items are read by the interpreter all the same: an item that can't be read + // (a stream of an external file) after them doesn't hide them. + ("a function stitching itself, then an item that can't be read", &shaded, vec![stitch(&format!("{me} << /F (x) >> stream"))]), + ("a colour space on itself, then an item that can't be read", &filled, vec![format!("[/Indexed {me} 1 <000000ffffff> << /F (x) >> stream]")]), ] { let objects: Vec<&str> = objects.iter().map(String::as_str).collect(); let e = import(&pdf_with(std::slice::from_ref(page), &objects, None)).err().map(|e| e.to_string());