PDF.R1: never panic on a damaged array or dictionary in an imported PDF

hayro-syntax's Dict::entries still unwraps each value it reads, so a
dictionary cut short (or with a stray token) aborts the import. The
craft-libs ArrayIter now stops at the first unreadable item instead of
panicking, which also means a hostile item after a self-referencing
function or colour space is silently dropped from nests_without_end.

- import_color: `readable(array)` checks every item parses; `items()`
  yields nothing for an unreadable array; `entries()` reads a dictionary
  key by key through get_raw, skipping values that don't parse.
- pages/editing/import_scan: use the helpers instead of raw_iter() and
  Dict::entries().
- nests_without_end refuses a file with an unreadable array.
- Tests: two refusal cases in pdf tests and an .ai cut inside an array
  or dictionary in engine/tests/import_fuzz.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcHisW1vt6pkVPYbzL6Cpg
This commit is contained in:
Claude
2026-10-16 02:56:34 +07:00
committed by bflatastic
parent ff7f47119d
commit 95d27fe390
6 changed files with 63 additions and 13 deletions
+15
View File
@@ -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();
}
}
+2 -1
View File
@@ -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<ObjectIden
}
let kids = node.get::<Array<'a>>(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::<Dict<'a>>()).find_map(|(raw, kid)| {
items(&kids).zip(kids.iter::<Dict<'a>>()).find_map(|(raw, kid)| {
if raw.as_obj_ref().is_some_and(|r| !seen.insert(r.into())) {
return None;
}
+26 -3
View File
@@ -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::<MaybeRef<Object<'_>>>().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<Item = MaybeRef<Object<'a>>> + use<'a> {
readable(a).then(|| a.raw_iter()).into_iter().flatten()
}
pub(crate) fn direct(m: MaybeRef<Object<'_>>) -> Option<Object<'_>> {
match m {
MaybeRef::NotRef(o) => Some(o),
@@ -387,7 +410,7 @@ pub(crate) fn direct(m: MaybeRef<Object<'_>>) -> Option<Object<'_>> {
}
/// 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<Item = MaybeRef<Object<'a>>> {
pub(crate) fn values<'a>(d: &Dict<'a>) -> impl Iterator<Item = MaybeRef<Object<'a>>> {
let mut keys: Vec<Name<'a>> = d.keys().collect();
keys.sort_by(|a, b| a.as_ref().cmp(b.as_ref()));
keys.into_iter().filter_map(|k| d.get_raw::<Object<'a>>(k.as_ref()))
+6 -5
View File
@@ -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::<Dict<'_>>(b"D").unwrap_or_default();
let refs = |d: &Dict<'_>, key: &[u8]| -> Vec<ObjectIdentifier> {
d.get::<Array<'_>>(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<usize>, 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<usize> {
if d.get::<Name<'_>>(b"Type").is_some_and(|t| t.as_ref() == b"OCMD") {
let first = d.get::<Array<'_>>(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::<Array<'_>>(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::<Dict<'_>>(id)?);
}
@@ -355,7 +356,7 @@ impl Walker<'_, '_> {
Some(id) if !membership => !self.off.contains(&id),
_ => {
let ids: Vec<ObjectIdentifier> = match d.get::<Array<'_>>(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);
+10 -4
View File
@@ -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<MaybeRef<Object<'a>>> {
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::<Name<'a>>(r.into()),
@@ -61,7 +61,7 @@ fn links<'a>(o: &Object<'a>, xref: &'a XRef) -> Vec<MaybeRef<Object<'a>>> {
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::<Array<'a>>(b"Functions").map(|a| a.raw_iter().collect::<Vec<_>>()).unwrap_or_default();
let functions = d.get::<Array<'a>>(b"Functions").map(|a| items(&a).collect::<Vec<_>>()).unwrap_or_default();
functions.into_iter().chain(d.get_raw::<Object<'a>>(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)),
}
}
+4
View File
@@ -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());