From 87df9aab626dc4febfbcc7029d852a5b207b6171 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Tue, 3 Nov 2026 07:40:29 -0800 Subject: [PATCH] craft-doc: recover intact metadata before a damaged gzip tail --- crates/doc/README.md | 4 +++ crates/doc/src/vectorcraft.rs | 54 +++++++++++++++++++++++++++++++---- crates/doc/tests/formats.rs | 48 +++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/crates/doc/README.md b/crates/doc/README.md index 17de4181..af8d1142 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -48,6 +48,10 @@ let outline = craft_doc::outline(&bytes, &limits)?; 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/vectorcraft.rs b/crates/doc/src/vectorcraft.rs index b2fef254..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() { diff --git a/crates/doc/tests/formats.rs b/crates/doc/tests/formats.rs index cbf9263c..82e19c25 100644 --- a/crates/doc/tests/formats.rs +++ b/crates/doc/tests/formats.rs @@ -34,6 +34,54 @@ fn selected_rendition_ignores_other_payloads() { } } +/// 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""#);