mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
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:
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user