mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-11 04:52:13 +00:00
craft-doc: recover intact metadata before a damaged gzip tail
This commit is contained in:
1 parent
d6dab40b02
commit
87df9aab62
3 files changed
+101
-5
No files matched your search
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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""#);
|
||||
|
||||
Reference in new issue
Block a user