diff --git a/crates/doc/README.md b/crates/doc/README.md index 03ee07e1..af8d1142 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -43,6 +43,15 @@ let outline = craft_doc::outline(&bytes, &limits)?; reads. - `renditions(bytes, &Limits) -> Result, Error>`: each `Rendition` has a `kind` (`Pdf`, `Thumbnail`, `Composite`), a `mime` and the `bytes`. +- `rendition(bytes, RenditionKind, &Limits) -> Result, Error>`: the requested + kind only, or `None` when absent. Container metadata and JSON limits are still checked. Other + rendition payloads are not decoded or copied: a damaged preview does not prevent reading the + PDF, and a missing thumbnail ZIP entry does not prevent reading the composite. A compressed + member's offset still counts against `max_total`, including bytes traversed to reach it. + Normal gzip reads remain buffered. If read-ahead reaches a damaged appended member, the + metadata read retries with exact compressed input and output through the JSON marker; + already-counted prefix bytes are replayed without retaining or charging another copy. + JSON and selected-payload limits still apply, and requesting the damaged member still fails. - `outline(bytes, &Limits) -> Result`: `canvas`, `pages` and `layers`. - `Error` is `Unrecognized`, `Malformed`, `TooNew`, `Unsupported` or `LimitExceeded`. A file whose version is newer than the crate reads gives `TooNew` from `renditions` and `outline`. A diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ed5daa36..eccc0bd8 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -5,6 +5,7 @@ //! - [`sniff`] tells which app wrote a file, from its first bytes. //! - [`info`] reads the format version and the writer the file names. //! - [`renditions`] returns the previews and the PDF a file carries. +//! - [`rendition`] reads just one kind of preview or PDF. //! - [`outline`] returns the canvas, the pages (artboards or compositions) and the layers. //! - `pcraft_layer_rgba`, with the feature `pcraft-pixels`, decodes a PhotoCraft layer's pixels //! and its layer mask. @@ -291,14 +292,26 @@ pub fn info(bytes: &[u8], limits: &Limits) -> Result { /// Create PDF-Compatible File; a `.pcraft` bundle has the `thumbnail` and `composite` its /// manifest names; an `.ecproj` project has none. pub fn renditions(bytes: &[u8], limits: &Limits) -> Result, Error> { + read_renditions(bytes, None, limits) +} + +fn read_renditions(bytes: &[u8], wanted: Option, limits: &Limits) -> Result, Error> { let mut budget = Budget::new(limits); match sniff(bytes).ok_or(Error::Unrecognized)? { - Kind::PhotoCraft => pcraft::renditions(bytes, &mut budget), - Kind::VectorCraft => vectorcraft::renditions(bytes, &mut budget), + Kind::PhotoCraft => pcraft::renditions(bytes, wanted, &mut budget), + Kind::VectorCraft => vectorcraft::renditions(bytes, wanted, &mut budget), Kind::EffectCraft => ecproj::renditions(bytes, &mut budget), } } +/// Read the requested rendition, or `None` when this format or document carries none of that +/// kind. Container metadata is still validated. Other rendition payloads are not decoded, +/// copied or charged against the entry budget; JSON and bytes traversed to reach the selected +/// compressed member still count against the usual limits. +pub fn rendition(bytes: &[u8], kind: RenditionKind, limits: &Limits) -> Result, Error> { + Ok(read_renditions(bytes, Some(kind), limits)?.pop()) +} + /// The document's canvas, pages and layers (see [`Outline`]). pub fn outline(bytes: &[u8], limits: &Limits) -> Result { let mut budget = Budget::new(limits); diff --git a/crates/doc/src/pcraft.rs b/crates/doc/src/pcraft.rs index bfbb53ff..8ea2a22c 100644 --- a/crates/doc/src/pcraft.rs +++ b/crates/doc/src/pcraft.rs @@ -131,11 +131,14 @@ pub(crate) fn info(bytes: &[u8], budget: &mut Budget) -> Result<(u32, Option Result, Error> { +pub(crate) fn renditions(bytes: &[u8], wanted: Option, budget: &mut Budget) -> Result, Error> { let (zip, json) = manifest(bytes, budget)?; let m: Envelope = read(&json, budget)?; let mut out = vec![]; for (kind, path) in [(RenditionKind::Thumbnail, m.thumbnail), (RenditionKind::Composite, m.composite)] { + if wanted.is_some_and(|wanted| wanted != kind) { + continue; + } let Some(path) = path else { continue }; let entry = zip.find(&path).ok_or_else(|| malformed(format!("the manifest names `{path}`, which the bundle does not have")))?; let max = budget.limits.max_entry; diff --git a/crates/doc/src/vectorcraft.rs b/crates/doc/src/vectorcraft.rs index 83b06750..5b1e6e2f 100644 --- a/crates/doc/src/vectorcraft.rs +++ b/crates/doc/src/vectorcraft.rs @@ -33,7 +33,34 @@ use crate::{ const CHUNK: u64 = 64 << 10; pub(crate) fn sniff(head: &[u8]) -> bool { - craft_vectorcraft_format::sniff(head) + if craft_vectorcraft_format::sniff(head) { + return true; + } + if !is_compressed(head) { + return false; + } + // A decoder can inspect broken compressed data past the requested output. Retry the + // bounded sniff with one compressed byte at a time, retaining any intact prefix. + let mut unpacked = Vec::new(); + let head = head.get(..64 * 1024).unwrap_or(head); + let _ = gzip_decoder(head, 1).take(256).read_to_end(&mut unpacked); + !is_compressed(&unpacked) && craft_vectorcraft_format::sniff(&unpacked) +} + +struct GzipInput<'a> { + bytes: &'a [u8], + step: usize, +} + +impl Read for GzipInput<'_> { + fn read(&mut self, out: &mut [u8]) -> std::io::Result { + let len = out.len().min(self.step); + self.bytes.read(&mut out[..len]) + } +} + +fn gzip_decoder(bytes: &[u8], step: usize) -> GzDecoder> { + GzDecoder::new(GzipInput { bytes, step }) } /// Where the JSON ends and the data after the marker starts, when `text` holds a NUL. JSON holds @@ -60,7 +87,7 @@ enum Tail<'a> { Slice(&'a [u8]), /// Still in the gzip stream: `read` holds what was decompressed past the marker already, not /// yet counted against the budget. - Stream { dec: Box>, read: Vec }, + Stream { dec: Box>>, read: Vec }, } impl Tail<'_> { @@ -137,14 +164,31 @@ fn unpack<'a>(bytes: &'a [u8], budget: &mut Budget) -> Result, Erro budget.check_json(json.len() as u64, JSON)?; return Ok(Unpacked { json: Cow::Borrowed(json), tail: Tail::Slice(tail) }); } - let mut dec = Box::new(GzDecoder::new(bytes)); + let mut dec = Box::new(gzip_decoder(bytes, usize::MAX)); + let mut step = CHUNK; let mut buf = Vec::new(); let mut split: Option<(usize, usize)> = None; let mut nul: Option = None; loop { let start = buf.len(); - let want = CHUNK.min(budget.left().saturating_add(1)); - let n = (&mut *dec).take(want).read_to_end(&mut buf).map_err(damaged)?; + let want = step.min(budget.left().saturating_add(1)); + let n = match (&mut *dec).take(want).read_to_end(&mut buf) { + Ok(n) => n, + Err(_) if step > 1 => { + // Keep the fast path for intact files. On damage, replay the already-counted + // prefix and read only through the marker, with no compressed read-ahead. + let mut retry = Box::new(gzip_decoder(bytes, 1)); + let skipped = std::io::copy(&mut (&mut *retry).take(start as u64), &mut std::io::sink()).map_err(damaged)?; + if skipped != start as u64 { + return Err(malformed("the compressed document ends before its previously read prefix")); + } + dec = retry; + buf.truncate(start); + step = 1; + continue; + } + Err(e) => return Err(damaged(e)), + }; // The JSON ends before the first NUL: until there is one, all of it is JSON. The marker // may end in a later chunk than its first NUL. if nul.is_none() { @@ -345,8 +389,9 @@ impl<'de, T: Container> de::DeserializeSeed<'de> for Contained { /// 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. When `IMAGES` is true, a missing or -/// repeated `document` is refused too; its value is skipped. -struct Stamp { +/// repeated `document` is refused too; its value is skipped. `PREVIEW` and `PDF` choose which +/// image values to read; unrequested values are skipped without copying their strings. +struct Stamp { format: String, version: u32, generator: Option, @@ -363,17 +408,17 @@ fn once<'de, A: MapAccess<'de>, T: Deserialize<'de>>(map: &mut A, slot: &mut Opt Ok(()) } -impl<'de, const IMAGES: bool> Deserialize<'de> for Stamp { +impl<'de, const IMAGES: bool, const PREVIEW: bool, const PDF: bool> Deserialize<'de> for Stamp { fn deserialize>(d: D) -> Result { - struct StampVisitor; - impl<'de, const IMAGES: bool> Visitor<'de> for StampVisitor { - type Value = Stamp; + struct StampVisitor; + impl<'de, const IMAGES: bool, const PREVIEW: bool, const PDF: bool> Visitor<'de> for StampVisitor { + type Value = Stamp; fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str("a VectorCraft file's JSON object") } - fn visit_map>(self, mut map: A) -> Result, A::Error> { + 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::()? { @@ -382,8 +427,8 @@ impl<'de, const IMAGES: bool> Deserialize<'de> for Stamp { "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, + "preview" if IMAGES && PREVIEW => preview = map.next_value::>()?.0, + "pdf" if IMAGES && PDF => pdf = map.next_value::>()?.0, _ => { map.next_value::()?; } @@ -401,14 +446,18 @@ impl<'de, const IMAGES: bool> Deserialize<'de> for Stamp { }) } } - d.deserialize_map(StampVisitor::) + d.deserialize_map(StampVisitor::) } } /// The file's [`Stamp`], refused when the format is not VectorCraft's, and when the version is /// newer than this crate reads unless `newer` is true. -fn stamp(json: &[u8], budget: &Budget, newer: bool) -> Result, Error> { - let s: Stamp = if newer { parse(json, budget.limits)? } else { parse_versioned(json, budget.limits, Kind::VectorCraft)? }; +fn stamp( + json: &[u8], + budget: &Budget, + newer: bool, +) -> Result, Error> { + let s: Stamp = if newer { parse(json, budget.limits)? } else { parse_versioned(json, budget.limits, Kind::VectorCraft)? }; match craft_vectorcraft_format::check(&s.format, s.version) { Ok(()) => Ok(s), Err(craft_vectorcraft_format::Error::Format(format)) => Err(malformed(format!("format is `{format}`"))), @@ -420,16 +469,28 @@ fn stamp(json: &[u8], budget: &Budget, newer: bool) -> Resul pub(crate) fn info(bytes: &[u8], budget: &mut Budget) -> Result<(u32, Option), Error> { let json = unpack(bytes, budget)?.json; - let s = stamp::(&json, budget, true)?; + let s = stamp::(&json, budget, true)?; Ok((s.version, s.generator)) } -pub(crate) fn renditions(bytes: &[u8], budget: &mut Budget) -> Result, Error> { +/// Skip unrequested image fields during deserialization, including their inline base64 strings. +fn images(json: &[u8], budget: &Budget) -> Result<(Option, Option), Error> { + let head = stamp::(json, budget, false)?; + Ok((head.preview, head.pdf)) +} + +pub(crate) fn renditions(bytes: &[u8], wanted: Option, budget: &mut Budget) -> Result, Error> { let Unpacked { json, tail } = unpack(bytes, budget)?; - let head = stamp::(&json, budget, false)?; + let (preview, pdf) = match wanted { + None => images::(&json, budget), + Some(RenditionKind::Thumbnail) => images::(&json, budget), + Some(RenditionKind::Pdf) => images::(&json, budget), + Some(RenditionKind::Composite) => images::(&json, budget), + }?; // A member without a MIME type gives no rendition: a preview may be PNG or JPEG. - let members: Vec<_> = [(RenditionKind::Thumbnail, head.preview, "preview"), (RenditionKind::Pdf, head.pdf, "PDF")] + let members: Vec<_> = [(RenditionKind::Thumbnail, preview, "preview"), (RenditionKind::Pdf, pdf, "PDF")] .into_iter() + .filter(|(kind, _, _)| wanted.is_none_or(|wanted| wanted == *kind)) .filter_map(|(kind, m, what)| Some((kind, m.filter(|m| !m.mime.is_empty())?, what))) .collect(); // Every size is checked before any data after the JSON is decompressed. A member after the @@ -516,7 +577,7 @@ struct KindM { pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result { let json = unpack(bytes, budget)?.json; - stamp::(&json, budget, false)?; + stamp::(&json, budget, false)?; let f: WithDocument = parse(&json, budget.limits)?; let pages = f .document @@ -554,6 +615,21 @@ mod tests { use crate::tests::{unread_array, unread_object}; use serde::de::value::{Error as ValueError, MapDeserializer, SeqDeserializer, StrDeserializer}; + /// An ignored field must use IgnoredAny, which can skip this sentinel without asking it + /// to deserialize an image or materialize any strings. + #[test] + fn unrequested_images_are_skipped_before_their_strings_are_read() { + fn skipped(field: &str) { + let map = MapDeserializer::<_, ValueError>::new([(field, crate::tests::Unread), ("document", crate::tests::Unread)].into_iter()); + let err = Stamp::::deserialize(map).err().unwrap(); + assert!(err.to_string().contains("missing field `format`"), "{err}"); + } + skipped::("preview"); + skipped::("pdf"); + skipped::("preview"); + skipped::("pdf"); + } + #[test] fn an_image_of_the_wrong_shape_is_skipped_without_reading_it_whole() { assert!(Lenient::::deserialize(unread_object(1000)).unwrap().0.is_none()); @@ -612,7 +688,7 @@ mod tests { 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(); + 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/formats.rs b/crates/doc/tests/formats.rs index bc3e6b08..82e19c25 100644 --- a/crates/doc/tests/formats.rs +++ b/crates/doc/tests/formats.rs @@ -4,12 +4,145 @@ mod common; use common::*; -use craft_doc::{Error, Kind, Limits, RenditionKind, info, outline, renditions, sniff}; +use craft_doc::{Error, Kind, Limits, RenditionKind, info, outline, rendition, renditions, sniff}; fn limits() -> Limits { Limits::default() } +/// A PDF-only importer need not decode a broken preview, for inline or blob payloads. +#[test] +fn selected_rendition_ignores_other_payloads() { + let inline = vectorcraft_json( + 3, + &format!(r#""preview":{{"mime":"image/png","data":"!bad!"}}, "pdf":{{"mime":"application/pdf","data":"{}"}}"#, base64(PDF)), + ); + let mut blob = vectorcraft_json( + 4, + &format!(r#""preview":{{"mime":"image/png","blob":[{},100]}}, "pdf":{{"mime":"application/pdf","blob":[0,{}]}}"#, PDF.len() + 100, PDF.len()), + ) + .into_bytes(); + blob.extend(BLOB_MAGIC); + blob.extend(PDF); + for bytes in [inline.into_bytes(), blob] { + for bytes in [bytes.clone(), gzip(&bytes)] { + assert!(renditions(&bytes, &limits()).is_err()); + let selected = rendition(&bytes, RenditionKind::Pdf, &limits()).unwrap().unwrap(); + assert_eq!(selected.bytes, PDF); + assert!(rendition(&bytes, RenditionKind::Thumbnail, &limits()).is_err()); + } + } +} + +/// Stored DEFLATE blocks contain the intact prefix; the next block has reserved BTYPE=3. +fn gzip_with_broken_tail(prefix: &[u8]) -> Vec { + let mut bytes = vec![0x1f, 0x8b, 8, 0, 0, 0, 0, 0, 0, 255]; + for chunk in prefix.chunks(u16::MAX as usize) { + let length = chunk.len() as u16; + bytes.push(0); // Non-final stored block. + bytes.extend(length.to_le_bytes()); + bytes.extend((!length).to_le_bytes()); + bytes.extend(chunk); + } + bytes.push(7); // Final block with an invalid block type. + bytes.extend([0; 8]); + bytes +} + +#[test] +fn selected_inline_pdf_ignores_gzip_damage_after_its_json_marker() { + for padding in [0, 128 * 1024] { + let json = vectorcraft_json( + 4, + &format!(r#""padding":"{}","preview":{},"pdf":{}"#, "x".repeat(padding), blob("image/png", 0, 100), inline("application/pdf", PDF)), + ); + let mut prefix = json.as_bytes().to_vec(); + prefix.extend(BLOB_MAGIC); + let bytes = gzip_with_broken_tail(&prefix); + let mut exact = limits(); + exact.max_json = json.len() as u64; + exact.max_total = (prefix.len() + PDF.len()) as u64; + exact.max_entry = PDF.len() as u64; + assert_eq!(rendition(&bytes, RenditionKind::Pdf, &exact).unwrap().unwrap().bytes, PDF); + assert!(matches!(rendition(&bytes, RenditionKind::Thumbnail, &limits()), Err(Error::Malformed(_)))); + assert!(matches!(renditions(&bytes, &limits()), Err(Error::Malformed(_)))); + exact.max_total -= 1; + assert!(matches!(rendition(&bytes, RenditionKind::Pdf, &exact), Err(Error::LimitExceeded(_)))); + exact.max_total += 1; + exact.max_json -= 1; + assert!(matches!(rendition(&bytes, RenditionKind::Pdf, &exact), Err(Error::LimitExceeded(_)))); + // A broken stream without a complete marker still fails, even after a complete JSON. + assert!(matches!(rendition(&gzip_with_broken_tail(json.as_bytes()), RenditionKind::Pdf, &limits()), Err(Error::Malformed(_)))); + } +} + +#[test] +fn vectorcraft_sniff_does_not_unwrap_nested_gzip() { + let json = vectorcraft_json(4, ""); + assert_eq!(sniff(&gzip(&gzip(json.as_bytes()))), None); +} + +#[test] +fn selected_rendition_skips_missing_zip_payloads_and_enforces_its_own_limits() { + let manifest = pcraft_manifest(1, "[]", r#", "thumbnail":"thumb.png", "composite":"composite/preview.png""#); + let image = png(2, 2, 1); + let bytes = zip(&[store("manifest.json", manifest.as_bytes()), store("composite/preview.png", &image)]); + assert!(renditions(&bytes, &limits()).is_err()); + assert_eq!(rendition(&bytes, RenditionKind::Composite, &limits()).unwrap().unwrap().bytes, image); + assert!(rendition(&bytes, RenditionKind::Thumbnail, &limits()).is_err()); + assert_eq!(rendition(&bytes, RenditionKind::Pdf, &limits()).unwrap(), None); + let mut small = limits(); + small.max_entry = PDF.len() as u64 - 1; + let vc = vectorcraft_json(3, &format!(r#""pdf":{{"mime":"application/pdf","data":"{}"}}"#, base64(PDF))); + assert!(matches!(rendition(vc.as_bytes(), RenditionKind::Pdf, &small), Err(Error::LimitExceeded(_)))); +} + +#[test] +fn selected_rendition_preserves_format_and_version_checks() { + assert_eq!(rendition(b"not a document", RenditionKind::Pdf, &limits()), Err(Error::Unrecognized)); + assert!(matches!(rendition(vectorcraft_json(99, "").as_bytes(), RenditionKind::Pdf, &limits()), Err(Error::TooNew { .. }))); + for bytes in [vectorcraft_json(4, "").into_bytes(), ecproj(2).into_bytes()] { + assert_eq!(rendition(&bytes, RenditionKind::Pdf, &limits()).unwrap(), None); + } +} + +/// Ignored output is not charged, but gzip still has to traverse bytes before the PDF. +#[test] +fn selected_rendition_counts_its_payload_and_the_compressed_prefix() { + let preview = vec![0; 128 * 1024]; + for before in [false, true] { + let (preview_at, pdf_at) = if before { (0, preview.len()) } else { (PDF.len(), 0) }; + let json = vectorcraft_json( + 4, + &format!( + r#""preview":{{"mime":"image/png","blob":[{preview_at},{}]}},"pdf":{{"mime":"application/pdf","blob":[{pdf_at},{}]}}"#, + preview.len(), + PDF.len() + ), + ); + let mut bytes = json.as_bytes().to_vec(); + bytes.extend(BLOB_MAGIC); + if before { + bytes.extend(&preview); + bytes.extend(PDF); + } else { + bytes.extend(PDF); + bytes.extend(&preview); + } + let mut small = limits(); + small.max_entry = PDF.len() as u64; + small.max_total = (json.len() + BLOB_MAGIC.len() + PDF.len()) as u64; + assert!(matches!(renditions(&bytes, &small), Err(Error::LimitExceeded(_)))); + assert_eq!(rendition(&bytes, RenditionKind::Pdf, &small).unwrap().unwrap().bytes, PDF); + let compressed = rendition(&gzip(&bytes), RenditionKind::Pdf, &small); + if before { + assert!(matches!(compressed, Err(Error::LimitExceeded(_)))); + } else { + assert_eq!(compressed.unwrap().unwrap().bytes, PDF); + } + } +} + #[test] fn sniff_tells_the_three_formats() { let vc = vectorcraft_json(4, "");