Merge pull request #4603 from VoiceLessQ/fix/4366-name-the-refused-stream

PDF import: a refused file says which stream it trips on (refs #4366)
This commit is contained in:
BFlat
2026-10-29 22:37:56 +07:00
committed by GitHub
3 changed files with 199 additions and 34 deletions
+91 -14
View File
@@ -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<u8>, more: Vec<Vec<u8>>| {
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::<Vec<_>>())
};
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 >> >> >> >>";
+78 -8
View File
@@ -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<String> {
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<Object<'_>> = 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::<Name<'_>>(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::<u32>(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<MaybeRef<Object<'_>>>| 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::<Object<'_>>(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::<Dict<'_>>(b"PieceInfo")?.get::<Dict<'_>>(PRIVATE_DATA_OWNER)?.get::<Dict<'_>>(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::<Dict<'_>>(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");
}
}
+30 -12
View File
@@ -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<Refusal> {
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<Refusal> {
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,
}
}