craft-doc: recover intact metadata before a damaged gzip tail

This commit is contained in:
dotCooCoo committed 2026-11-03 07:40:29 -08:00
1 parent d6dab40b02
commit 87df9aab62
3 files changed
+101 -5

No files matched your search

+4
View File
@@ -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<Outline, Error>`: `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
+49 -5
View File
@@ -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<usize> {
let len = out.len().min(self.step);
self.bytes.read(&mut out[..len])
}
}
fn gzip_decoder(bytes: &[u8], step: usize) -> GzDecoder<GzipInput<'_>> {
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<GzDecoder<&'a [u8]>>, read: Vec<u8> },
Stream { dec: Box<GzDecoder<GzipInput<'a>>>, read: Vec<u8> },
}
impl Tail<'_> {
@@ -137,14 +164,31 @@ fn unpack<'a>(bytes: &'a [u8], budget: &mut Budget) -> Result<Unpacked<'a>, 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<usize> = 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() {
+48
View File
@@ -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<u8> {
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""#);