diff --git a/crates/doc/README.md b/crates/doc/README.md index 73393535..76ba7b39 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -35,7 +35,9 @@ let outline = craft_doc::outline(&bytes, &limits)?; - `sniff(head) -> Option` reads the first bytes. The first 64 KiB are enough for every file the apps write. A re-zipped `.pcraft` whose `manifest.json` entry starts after the head is recognized only when `head` holds the whole file. When `head` holds a whole ZIP archive, the - names in its central directory decide. + names in its central directory decide. A VectorCraft file is recognized by its top-level + `format` member, as `craft_vectorcraft_format::sniff` recognizes it, so a file written as a + JSON array, which `File::read` also reads, is not. - `info(bytes, &Limits) -> Result`: the kind, the version the file declares, the writer it names, and `supported`, which is `false` when the version is newer than the crate reads. @@ -74,7 +76,8 @@ A `.pcraft` bundle saved as a folder, ZIP64 archives and encrypted entries are n - `max_entry` (256 MiB): one rendition or one ZIP entry. The size a file states is checked before any of it is copied or inflated. - `max_total` (1 GiB): every byte one call decompresses from gzip, reads out of ZIP entries or - returns as a rendition, together. + returns as a rendition, together. A rendition decompressed from gzip is counted once, and a + second rendition of the same bytes is counted as another copy. - `max_depth` (400, also the largest value it takes, `MAX_DEPTH`): the nesting of JSON arrays and objects, checked before parsing. PhotoCraft nests up to 100 groups of layers, three levels each. `renditions` and `outline` refuse a PhotoCraft manifest nested deeper than 367 levels @@ -108,13 +111,16 @@ limits.max_entry = 32 << 20; - `tests/hostile.rs`: every truncation of each sample, seeded mutations, proptest on arbitrary bytes, and inputs that pass each limit: a gzip bomb, a DEFLATE entry larger than its stated size, members longer than `max_entry`, JSON nested past `max_depth`, ZIP64, encryption, other - compression methods and CRC mismatches. + compression methods and CRC mismatches. An array of a million elements in place of a + `generator` or a preview is skipped. - `tests/envelopes.rs`: files whose envelope is written with the format crates' types read through each call, and envelopes those crates refuse (a manifest without `generator`, a manifest that is a JSON array, a preview that names another entry, a `format` member inside another member) are refused. A preview or PDF member without `mime` gives no rendition, and a VectorCraft `generator`, `preview` or `pdf` reads as `File::read` reads it: as `None` when it - has the wrong shape, and as its last value when it is repeated. + has the wrong shape, and as its last value when it is repeated. A value of the wrong shape is + skipped as it is parsed, and none of it is kept. A repeated `version`, and in `renditions` a + missing or repeated `document`, are refused, as `File::read` refuses them. `.github/workflows/doc.yml` runs fmt, clippy and the tests on Linux and Windows, and a `wasm32-unknown-unknown` check. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index abe72fa2..b36fe821 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -128,7 +128,8 @@ pub struct Limits { /// Most bytes of one rendition or one ZIP entry. Default 256 MiB. pub max_entry: u64, /// Most bytes one call decompresses from gzip, reads out of ZIP entries and returns as - /// renditions, together. Default 1 GiB. + /// renditions, together; a rendition decompressed from gzip is counted once, and a second + /// rendition of the same bytes as another copy. Default 1 GiB. pub max_total: u64, /// Deepest nesting of JSON arrays and objects, at most [`MAX_DEPTH`] (a larger value counts /// as [`MAX_DEPTH`]). Default [`MAX_DEPTH`]. [`renditions`] and [`outline`] also refuse a @@ -330,11 +331,17 @@ impl<'a> Budget<'a> { Ok(()) } - /// Check one rendition or ZIP entry of `n` bytes against `max_entry`, then count it. - pub(crate) fn take_entry(&mut self, n: u64, what: &str) -> Result<(), Error> { + /// Check one rendition or ZIP entry of `n` bytes against `max_entry`. + pub(crate) fn check_entry(&self, n: u64, what: &str) -> Result<(), Error> { if n > self.limits.max_entry { return Err(Error::LimitExceeded(format!("{what} is {n} bytes, more than max_entry ({})", self.limits.max_entry))); } + Ok(()) + } + + /// Check one rendition or ZIP entry of `n` bytes against `max_entry`, then count it. + pub(crate) fn take_entry(&mut self, n: u64, what: &str) -> Result<(), Error> { + self.check_entry(n, what)?; self.take(n, what) } @@ -502,7 +509,7 @@ fn version_member(kind: Kind) -> &'static str { } /// The version number in the member a [`Visitor`] is given, read from the top-level JSON object; -/// the other members are skipped, and a repeated one reads as its last value. +/// the other members are skipped, and a repeated version member is refused. struct VersionIn(&'static str); impl<'de> Visitor<'de> for VersionIn { @@ -516,6 +523,9 @@ impl<'de> Visitor<'de> for VersionIn { let mut version = None; while let Some(key) = map.next_key::()? { if key == self.0 { + if version.is_some() { + return Err(de::Error::duplicate_field(self.0)); + } version = Some(map.next_value()?); } else { map.next_value::()?; @@ -544,29 +554,119 @@ pub(crate) fn parse_versioned(json: &[u8], limits: &Limits, } /// A member read as `None` when its value has the wrong shape, as the format crates read a -/// file's optional members. +/// file's optional members. The value is read as it is parsed: the elements of an array or +/// object of the wrong shape are skipped one at a time with [`de::IgnoredAny`], and none of the +/// value is kept. pub(crate) struct Lenient(pub(crate) Option); -impl<'de, T: Deserialize<'de>> Deserialize<'de> for Lenient { +/// A type [`Lenient`] reads. +pub(crate) trait Shaped: Sized { + /// The value, or `None` when it has the wrong shape. + fn read<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error>; +} + +impl<'de, T: Shaped> Deserialize<'de> for Lenient { fn deserialize>(d: D) -> Result { - #[derive(Deserialize)] - #[serde(untagged)] - enum Shape { - Value(T), - Other(de::IgnoredAny), - } - Ok(Lenient(match Shape::deserialize(d)? { - Shape::Value(v) => Some(v), - Shape::Other(_) => None, - })) + T::read(d).map(Lenient) + } +} + +impl Shaped for String { + fn read<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + Ok(Scalar::deserialize(d)?.string()) } } /// [`Lenient`] as a `deserialize_with` function. -pub(crate) fn lenient<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { +pub(crate) fn lenient<'de, D: Deserializer<'de>, T: Shaped>(d: D) -> Result, D::Error> { Lenient::deserialize(d).map(|l| l.0) } +/// One JSON value: a string, a non-negative integer or a boolean is kept, and an array or object +/// is skipped one element at a time with [`de::IgnoredAny`]. +pub(crate) enum Scalar { + Str(String), + Unsigned(u64), + Bool(bool), + Null, + Other, +} + +impl Scalar { + pub(crate) fn string(self) -> Option { + match self { + Scalar::Str(s) => Some(s), + _ => None, + } + } + + pub(crate) fn unsigned(self) -> Option { + match self { + Scalar::Unsigned(n) => Some(n), + _ => None, + } + } + + pub(crate) fn bool(self) -> Option { + match self { + Scalar::Bool(b) => Some(b), + _ => None, + } + } +} + +impl<'de> Deserialize<'de> for Scalar { + fn deserialize>(d: D) -> Result { + struct ScalarVisitor; + impl<'de> Visitor<'de> for ScalarVisitor { + type Value = Scalar; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a JSON value") + } + + fn visit_bool(self, v: bool) -> Result { + Ok(Scalar::Bool(v)) + } + + fn visit_i64(self, v: i64) -> Result { + Ok(u64::try_from(v).map_or(Scalar::Other, Scalar::Unsigned)) + } + + fn visit_u64(self, v: u64) -> Result { + Ok(Scalar::Unsigned(v)) + } + + fn visit_f64(self, _: f64) -> Result { + Ok(Scalar::Other) + } + + fn visit_str(self, v: &str) -> Result { + Ok(Scalar::Str(v.to_owned())) + } + + fn visit_string(self, v: String) -> Result { + Ok(Scalar::Str(v)) + } + + fn visit_unit(self) -> Result { + Ok(Scalar::Null) + } + + fn visit_seq>(self, mut seq: A) -> Result { + while seq.next_element::()?.is_some() {} + Ok(Scalar::Other) + } + + fn visit_map>(self, mut map: A) -> Result { + while map.next_entry::()?.is_some() {} + Ok(Scalar::Other) + } + } + d.deserialize_any(ScalarVisitor) + } +} + pub(crate) fn too_new(kind: Kind, version: u32) -> Error { Error::TooNew { kind, version, supported: kind.supported_version() } } @@ -592,4 +692,52 @@ mod tests { assert_eq!(b.left(), 3); assert!(b.check_json(11, "x").is_err() && b.check_json(10, "x").is_ok()); } + + /// A value that can be skipped but not read: reading it whole, as a buffer of the value + /// would, fails. + #[derive(Clone)] + pub(crate) struct Unread; + + impl<'de> Deserializer<'de> for Unread { + type Error = de::value::Error; + + fn deserialize_any>(self, _: V) -> Result { + Err(de::Error::custom("the value was read, not skipped")) + } + + fn deserialize_ignored_any>(self, v: V) -> Result { + v.visit_unit() + } + + serde::forward_to_deserialize_any! { + bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string bytes byte_buf option unit + unit_struct newtype_struct seq tuple tuple_struct map struct enum identifier + } + } + + impl<'de> de::IntoDeserializer<'de> for Unread { + type Deserializer = Unread; + + fn into_deserializer(self) -> Unread { + self + } + } + + /// An array of `n` [`Unread`] elements. + pub(crate) fn unread_array(n: usize) -> de::value::SeqDeserializer, de::value::Error> { + de::value::SeqDeserializer::new(std::iter::repeat_n(Unread, n)) + } + + /// An object whose `n` members hold [`Unread`] values. + pub(crate) fn unread_object(n: usize) -> de::value::MapDeserializer<'static, std::vec::IntoIter<(String, Unread)>, de::value::Error> { + de::value::MapDeserializer::new((0..n).map(|i| (i.to_string(), Unread)).collect::>().into_iter()) + } + + #[test] + fn a_lenient_string_skips_an_array_or_object_without_reading_it_whole() { + assert!(Lenient::::deserialize(unread_array(1000)).unwrap().0.is_none()); + assert!(Lenient::::deserialize(unread_object(1000)).unwrap().0.is_none()); + let s: Lenient = Lenient::deserialize(de::value::StrDeserializer::::new("VectorCraft 0.1.0")).unwrap(); + assert_eq!(s.0.as_deref(), Some("VectorCraft 0.1.0")); + } } diff --git a/crates/doc/src/vectorcraft.rs b/crates/doc/src/vectorcraft.rs index 6c0a0627..83b06750 100644 --- a/crates/doc/src/vectorcraft.rs +++ b/crates/doc/src/vectorcraft.rs @@ -18,13 +18,16 @@ use std::borrow::Cow; use std::fmt; use std::io::Read; +use std::marker::PhantomData; use craft_vectorcraft_format::{BLOB_MAGIC, Image, entry, is_compressed, marker_at}; use flate2::read::GzDecoder; use serde::Deserialize; -use serde::de::{self, Deserializer, IgnoredAny, MapAccess, Visitor}; +use serde::de::{self, Deserializer, IgnoredAny, MapAccess, SeqAccess, Visitor}; -use crate::{Budget, Error, Kind, Layer, Lenient, Outline, Page, Rendition, RenditionKind, counted, malformed, parse, parse_versioned, too_new}; +use crate::{ + Budget, Error, Kind, Layer, Lenient, Outline, Page, Rendition, RenditionKind, Scalar, Shaped, counted, malformed, parse, parse_versioned, too_new, +}; /// Bytes decompressed at a time. const CHUNK: u64 = 64 << 10; @@ -88,6 +91,11 @@ impl Tail<'_> { _ => merged.push([start, end]), } } + // Each range is returned as its own copy: bytes that more than one range names are + // decompressed once and counted again here for each further copy. + let stated = ranges.iter().fold(0u64, |n, &[_, len]| n.saturating_add(len)); + let spanned = merged.iter().fold(0u64, |n, &[start, end]| n.saturating_add(end - start)); + budget.take(stated.saturating_sub(spanned), WHAT)?; let mut src = read.as_slice().chain(&mut *dec); let (mut at, mut kept) = (0u64, Vec::<(u64, Vec)>::new()); for [start, end] in merged { @@ -171,11 +179,173 @@ fn unpack<'a>(bytes: &'a [u8], budget: &mut Budget) -> Result, Erro Ok(Unpacked { json: Cow::Owned(buf), tail: Tail::Stream { dec, read } }) } +/// A value of type `Self` that is a JSON array or object, read by [`ContainerVisitor`] as it is +/// parsed; any other value has the wrong shape, apart from `null` where [`Container::null`] takes +/// it. +trait Container: Sized { + fn seq<'de, A: SeqAccess<'de>>(seq: A) -> Result, A::Error>; + fn map<'de, A: MapAccess<'de>>(map: A) -> Result, A::Error>; + fn null() -> Option { + None + } +} + +/// Reads a [`Container`], or `None` when the value has the wrong shape. +struct ContainerVisitor(PhantomData); + +impl<'de, T: Container> Visitor<'de> for ContainerVisitor { + type Value = Option; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a JSON value") + } + + fn visit_bool(self, _: bool) -> Result, E> { + Ok(None) + } + + fn visit_i64(self, _: i64) -> Result, E> { + Ok(None) + } + + fn visit_u64(self, _: u64) -> Result, E> { + Ok(None) + } + + fn visit_f64(self, _: f64) -> Result, E> { + Ok(None) + } + + fn visit_str(self, _: &str) -> Result, E> { + Ok(None) + } + + fn visit_unit(self) -> Result, E> { + Ok(T::null()) + } + + fn visit_seq>(self, seq: A) -> Result, A::Error> { + T::seq(seq) + } + + fn visit_map>(self, map: A) -> Result, A::Error> { + T::map(map) + } +} + +/// Sets `slot` to `value`; `false` when `value` is `None` (the wrong shape) or `slot` is already +/// set (a repeated member). +fn fill(slot: &mut Option, value: Option) -> bool { + match value { + Some(v) if slot.is_none() => { + *slot = Some(v); + true + } + _ => false, + } +} + +/// Skips the rest of `seq`; `true` when nothing was left. +fn ended<'de, A: SeqAccess<'de>>(mut seq: A) -> Result { + let mut ended = true; + while seq.next_element::()?.is_some() { + ended = false; + } + Ok(ended) +} + +/// [`Image::blob`]: `null`, or an array of two non-negative integers. +struct Blob(Option<[u64; 2]>); + +impl Container for Blob { + fn seq<'de, A: SeqAccess<'de>>(mut seq: A) -> Result, A::Error> { + let (mut range, mut n, mut ok) = ([0u64; 2], 0usize, true); + while let Some(v) = seq.next_element::()? { + match (range.get_mut(n), v.unsigned()) { + (Some(slot), Some(v)) => *slot = v, + _ => ok = false, + } + n += 1; + } + Ok((ok && n == 2).then_some(Blob(Some(range)))) + } + + fn map<'de, A: MapAccess<'de>>(mut map: A) -> Result, A::Error> { + while map.next_entry::()?.is_some() {} + Ok(None) + } + + fn null() -> Option { + Some(Blob(None)) + } +} + +/// An [`Image`] read as its derived `Deserialize` reads it, from a JSON object or from an array of +/// its fields in order, without keeping a value of the wrong shape: an unknown member is skipped, +/// and a missing `mime`, a repeated member or a member of the wrong type gives `None`. +impl Container for Image { + fn seq<'de, A: SeqAccess<'de>>(mut seq: A) -> Result, A::Error> { + let mut image = Image::default(); + let Some(mime) = seq.next_element::()? else { return Ok(None) }; + let mut ok = mime.string().map(|m| image.mime = m).is_some(); + let Some(data) = seq.next_element::()? else { return Ok(ok.then_some(image)) }; + ok &= data.string().map(|d| image.data = d).is_some(); + let Some(blob) = seq.next_element_seed(Contained::(PhantomData))? else { return Ok(ok.then_some(image)) }; + ok &= blob.map(|b| image.blob = b.0).is_some(); + let Some(proxy) = seq.next_element::()? else { return Ok(ok.then_some(image)) }; + ok &= proxy.bool().map(|p| image.proxy = p).is_some(); + ok &= ended(seq)?; + Ok(ok.then_some(image)) + } + + fn map<'de, A: MapAccess<'de>>(mut map: A) -> Result, A::Error> { + let (mut mime, mut data, mut blob, mut proxy) = (None, None, None, None); + let mut ok = true; + while let Some(key) = map.next_key::()? { + ok &= match key.as_str() { + "mime" => fill(&mut mime, map.next_value::()?.string()), + "data" => fill(&mut data, map.next_value::()?.string()), + "blob" => fill(&mut blob, map.next_value_seed(Contained::(PhantomData))?), + "proxy" => fill(&mut proxy, map.next_value::()?.bool()), + _ => { + map.next_value::()?; + true + } + }; + } + let (Some(mime), true) = (mime, ok) else { return Ok(None) }; + let mut image = Image::default(); + image.mime = mime; + image.data = data.unwrap_or_default(); + image.blob = blob.and_then(|b| b.0); + image.proxy = proxy.unwrap_or_default(); + Ok(Some(image)) + } +} + +impl Shaped for Image { + fn read<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + d.deserialize_any(ContainerVisitor(PhantomData)) + } +} + +/// Reads a [`Container`] as an element or member value. +struct Contained(PhantomData); + +impl<'de, T: Container> de::DeserializeSeed<'de> for Contained { + type Value = Option; + + fn deserialize>(self, d: D) -> Result, D::Error> { + d.deserialize_any(ContainerVisitor(PhantomData)) + } +} + /// The format name, the version, the writer and, when `IMAGES` is true, the preview and the PDF, /// read without the rest of the file, so that a file of a newer version still reports them. The /// members are read as [`craft_vectorcraft_format::File::read`] reads them: a missing or repeated /// `format` or `version` is refused, and a `generator`, `preview` or `pdf` of the wrong shape -/// reads as `None` and a repeated one as its last value. +/// reads as `None` and a repeated one as its last value. When `IMAGES` is true, a missing or +/// repeated `document` is refused too; its value is skipped. struct Stamp { format: String, version: u32, @@ -205,10 +375,12 @@ impl<'de, const IMAGES: bool> Deserialize<'de> for Stamp { fn visit_map>(self, mut map: A) -> Result, A::Error> { let (mut format, mut version, mut generator, mut preview, mut pdf) = (None, None, None, None, None); + let mut document: Option = None; while let Some(key) = map.next_key::()? { match key.as_str() { "format" => once(&mut map, &mut format, "format")?, "version" => once(&mut map, &mut version, "version")?, + "document" if IMAGES => once(&mut map, &mut document, "document")?, "generator" => generator = map.next_value::>()?.0, "preview" if IMAGES => preview = map.next_value::>()?.0, "pdf" if IMAGES => pdf = map.next_value::>()?.0, @@ -217,6 +389,9 @@ impl<'de, const IMAGES: bool> Deserialize<'de> for Stamp { } } } + if IMAGES && document.is_none() { + return Err(de::Error::missing_field("document")); + } Ok(Stamp { format: format.ok_or_else(|| de::Error::missing_field("format"))?, version: version.ok_or_else(|| de::Error::missing_field("version"))?, @@ -257,9 +432,16 @@ pub(crate) fn renditions(bytes: &[u8], budget: &mut Budget) -> Result = members.iter().filter_map(|(_, m, _)| m.blob).collect(); let mut blobs = if ranges.is_empty() { vec![] } else { tail.ranges(&ranges, budget)? }.into_iter(); @@ -365,3 +547,73 @@ pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result::deserialize(unread_object(1000)).unwrap().0.is_none()); + // A `mime` that is an array, in the array form and in an object, and a `blob` that is an + // object. + let array_form = SeqDeserializer::<_, ValueError>::new([unread_array(1000), unread_array(1000)].into_iter()); + assert!(Lenient::::deserialize(array_form).unwrap().0.is_none()); + let mime_array = MapDeserializer::<_, ValueError>::new([("mime", unread_array(1000))].into_iter()); + assert!(Lenient::::deserialize(mime_array).unwrap().0.is_none()); + let blob_object = MapDeserializer::<_, ValueError>::new([("blob", unread_object(1000))].into_iter()); + assert!(Lenient::::deserialize(blob_object).unwrap().0.is_none()); + let fine: Lenient = + Lenient::deserialize(MapDeserializer::<_, ValueError>::new([("mime", StrDeserializer::::new("image/png"))].into_iter())) + .unwrap(); + assert_eq!(fine.0.map(|i| i.mime).as_deref(), Some("image/png")); + } + + #[test] + fn the_preview_and_pdf_read_as_file_read_reads_them() { + let shapes = [ + r#"{"mime":"image/png","data":"AAAA"}"#, + r#"{"mime":"image/png","data":"AAAA","extra":[1,{"a":2}]}"#, + r#"{"mime":"image/png","blob":[1,2],"proxy":true}"#, + r#"{"mime":"image/png","blob":null}"#, + r#"{"mime":"","data":""}"#, + r#"{"data":"AAAA"}"#, + r#"{"mime":7}"#, + r#"{"mime":"image/png","data":null}"#, + r#"{"mime":"image/png","proxy":1}"#, + r#"{"mime":"image/png","proxy":null}"#, + r#"{"mime":"image/png","blob":[1,2,3]}"#, + r#"{"mime":"image/png","blob":[1]}"#, + r#"{"mime":"image/png","blob":[-1,2]}"#, + r#"{"mime":"image/png","blob":[1.0,2]}"#, + r#"{"mime":"image/png","blob":[1,18446744073709551616]}"#, + r#"{"mime":"image/png","blob":{"0":1}}"#, + r#"{"mime":"image/png","blob":"1,2"}"#, + r#"{"mime":"image/png","mime":"image/jpeg"}"#, + r#"{"mime":"image/png","blob":[1,2],"blob":[1,2]}"#, + r#"["image/png"]"#, + r#"["image/png","AAAA"]"#, + r#"["image/png","",[3,4]]"#, + r#"["image/png","",null,true]"#, + r#"["image/png","",null,true,0]"#, + r#"["image/png",[0,0]]"#, + r#"[7]"#, + r#"[]"#, + r#"[[0,0,0]]"#, + r#""image/png""#, + r#"7"#, + r#"-7"#, + r#"1.5"#, + r#"true"#, + r#"null"#, + ]; + for shape in shapes { + let json = format!(r#"{{"format":"vectorcraft","version":4,"generator":{shape},"preview":{shape},"pdf":{shape},"document":0}}"#); + let file = craft_vectorcraft_format::File::::read(json.as_bytes()).unwrap(); + let stamp: Stamp = crate::parse(json.as_bytes(), &crate::Limits::default()).unwrap(); + assert_eq!((&stamp.generator, &stamp.preview, &stamp.pdf), (&file.generator, &file.preview, &file.pdf), "{shape}"); + } + } +} diff --git a/crates/doc/tests/envelopes.rs b/crates/doc/tests/envelopes.rs index 31f063c9..83ac4f7a 100644 --- a/crates/doc/tests/envelopes.rs +++ b/crates/doc/tests/envelopes.rs @@ -178,6 +178,23 @@ fn a_repeated_vectorcraft_member_reads_as_its_last_value() { let twice = vectorcraft_json(4, r#""version":4"#); assert!(craft_vectorcraft_format::File::::read(twice.as_bytes()).is_err()); assert!(matches!(info(twice.as_bytes(), &limits()), Err(Error::Malformed(_)))); + // A repeated version is malformed, not too new, when the second one is newer. + let newer = vectorcraft_json(4, r#""version":9"#); + assert!(craft_vectorcraft_format::File::::read(newer.as_bytes()).is_err()); + assert!(matches!(renditions(newer.as_bytes(), &limits()), Err(Error::Malformed(_)))); + assert!(matches!(outline(newer.as_bytes(), &limits()), Err(Error::Malformed(_)))); +} + +#[test] +fn a_vectorcraft_file_without_one_document_gives_no_renditions() { + let preview = vectorcraft_json(4, &format!(r#""preview":{}"#, inline("image/png", &png(1, 1, 0)))); + let missing = preview.replacen(r#""document":"#, r#""elsewhere":"#, 1); + let repeated = preview.replacen(r#""images":{}"#, r#""document":{}"#, 1); + for file in [missing, repeated] { + assert!(craft_vectorcraft_format::File::::read(file.as_bytes()).is_err()); + assert!(matches!(renditions(file.as_bytes(), &limits()), Err(Error::Malformed(_))), "{file}"); + } + assert_eq!(renditions(preview.as_bytes(), &limits()).unwrap().len(), 1); } #[test] diff --git a/crates/doc/tests/hostile.rs b/crates/doc/tests/hostile.rs index c4eab52e..524e5076 100644 --- a/crates/doc/tests/hostile.rs +++ b/crates/doc/tests/hostile.rs @@ -244,6 +244,47 @@ fn only_the_json_of_a_compressed_file_is_counted_when_no_member_follows_it() { assert_eq!(renditions(&gz, &limits).unwrap(), vec![]); } +#[test] +fn a_compressed_member_is_counted_against_max_total_once() { + // A 40 KiB PDF after the JSON, with max_total 64 KiB: it reads compressed as it does + // uncompressed. + let pdf: Vec = (0..40u32 << 10).map(|i| (i % 251) as u8).collect(); + let mut file = vectorcraft_json(4, &format!(r#""pdf":{}"#, blob("application/pdf", 0, pdf.len()))).into_bytes(); + file.extend(BLOB_MAGIC); + file.extend(&pdf); + let mut limits = small(); + limits.max_total = 64 << 10; + for bytes in [file.clone(), gzip(&file)] { + assert_eq!(renditions(&bytes, &limits).unwrap()[0].bytes, pdf); + } + // Without room for the JSON and the PDF, the compressed file is refused. + limits.max_total = pdf.len() as u64; + assert!(limit_exceeded(renditions(&gzip(&file), &limits))); + // A preview naming the same bytes is a second copy, counted too. + let mut twice = vectorcraft_json(4, &format!(r#""preview":{0},"pdf":{0}"#, blob("image/png", 0, pdf.len()))).into_bytes(); + twice.extend(BLOB_MAGIC); + twice.extend(&pdf); + for bytes in [twice.clone(), gzip(&twice)] { + limits.max_total = 64 << 10; + assert!(limit_exceeded(renditions(&bytes, &limits))); + limits.max_total = 128 << 10; + assert_eq!(renditions(&bytes, &limits).unwrap().len(), 2); + } +} + +#[test] +fn a_large_array_in_place_of_the_generator_or_a_preview_is_skipped() { + // A million elements, where a buffered read would keep each one. + let zeros = format!("[{}0]", "0,".repeat(1 << 20)); + let members = format!(r#""generator":{zeros},"preview":{{"mime":{zeros}}},"pdf":[{zeros}]"#); + let vc = vectorcraft_json(4, &members).replacen(r#""generator":"VectorCraft 0.1.0","#, "", 1); + assert_eq!(info(vc.as_bytes(), &Limits::default()).unwrap().generator, None); + assert_eq!(renditions(vc.as_bytes(), &Limits::default()).unwrap(), vec![]); + let manifest = pcraft_manifest(1, "[]", "").replacen(r#""generator":"photocraft-format 0.1.0""#, &format!(r#""generator":{zeros}"#), 1); + let pc = zip(&[store("manifest.json", manifest.as_bytes())]); + assert_eq!(info(&pc, &Limits::default()).unwrap().generator, None); +} + #[test] fn a_zip_entry_past_max_entry_is_refused_before_it_is_inflated() { let big = vec![0u8; 1 << 20];