From 3cdeca266a9417663eb364e177cfd73b7ee00f57 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 19:07:05 -0800 Subject: [PATCH] craft-doc: bound shared tile conversion and reject trailing frames --- crates/doc/README.md | 5 ++- crates/doc/src/pcraft_pixels.rs | 68 ++++++++++++++++++++++++++++++--- crates/doc/tests/pixels.rs | 2 +- 3 files changed, 67 insertions(+), 8 deletions(-) diff --git a/crates/doc/README.md b/crates/doc/README.md index 3a6e1f3d..1efd0425 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -97,8 +97,9 @@ craft-doc = { git = "https://github.com/storytold/craft-libs", rev = "", allocated. A tile that several references name is read and decoded once per call. A tile is decoded only up to its size, 256 × 256 pixels, and its samples must match the BLAKE3 hash that names them. A tile's zstd frame larger than the tile plus an eighth and 4 KiB, or one that - declares a window larger than 8 MiB, is refused. Every tile reference in the manifest, those of - other layers included, counts against `max_items`. + declares a window larger than 8 MiB, or has bytes after its frame, is refused. Tile references + in parsed surfaces, rendered caches and masks throughout the layer tree count against + `max_items`, including other layers. Video frames and other unrecognized members are skipped. `tests/pixels.rs` builds bundles with tiles compressed by `ruzstd` and runs with `cargo test -p craft-doc --all-features`: 8, 16 and 32-bit tiles, the default pixel, negative diff --git a/crates/doc/src/pcraft_pixels.rs b/crates/doc/src/pcraft_pixels.rs index 2f6debee..85dca4ee 100644 --- a/crates/doc/src/pcraft_pixels.rs +++ b/crates/doc/src/pcraft_pixels.rs @@ -108,8 +108,9 @@ pub struct LayerMask { /// The decoded layer and the decoded mask each count as one entry against /// [`Limits::max_entry`], and together with each distinct tile read and decompressed against /// [`Limits::max_total`]. A tile that several references name is read and decoded once per call. -/// Each is checked before it is allocated. Every tile reference in the manifest, those of other -/// layers included, counts against [`Limits::max_items`]. +/// Each is checked before it is allocated. Tile references in parsed surfaces, rendered caches +/// and masks throughout the layer tree count against [`Limits::max_items`], including other +/// layers. Video frames and other unrecognized members are skipped. pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Result, Error> { match sniff(bytes).ok_or(Error::Unrecognized)? { Kind::PhotoCraft => {} @@ -289,30 +290,49 @@ trait Sample: Copy { const SIZE: usize; const OPAQUE: Self; fn read(bytes: &[u8]) -> Vec; + fn at(bytes: &[u8], index: usize) -> Option; } +#[cfg(test)] +thread_local! { static SAMPLE_BUFFER_BYTES: std::cell::Cell = const { std::cell::Cell::new(0) }; } + impl Sample for u8 { const SIZE: usize = 1; const OPAQUE: u8 = u8::MAX; fn read(bytes: &[u8]) -> Vec { + #[cfg(test)] + SAMPLE_BUFFER_BYTES.with(|n| n.set(n.get() + bytes.len())); bytes.to_vec() } + fn at(bytes: &[u8], index: usize) -> Option { + bytes.get(index).copied() + } } impl Sample for u16 { const SIZE: usize = 2; const OPAQUE: u16 = u16::MAX; fn read(bytes: &[u8]) -> Vec { + #[cfg(test)] + SAMPLE_BUFFER_BYTES.with(|n| n.set(n.get() + bytes.len())); bytes.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes(*b)).collect() } + fn at(bytes: &[u8], index: usize) -> Option { + Some(u16::from_le_bytes(*bytes.get(index.checked_mul(2)?..)?.first_chunk::<2>()?)) + } } impl Sample for f32 { const SIZE: usize = 4; const OPAQUE: f32 = 1.0; fn read(bytes: &[u8]) -> Vec { + #[cfg(test)] + SAMPLE_BUFFER_BYTES.with(|n| n.set(n.get() + bytes.len())); bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() } + fn at(bytes: &[u8], index: usize) -> Option { + Some(f32::from_le_bytes(*bytes.get(index.checked_mul(4)?..)?.first_chunk::<4>()?)) + } } /// `surface` over the canvas, in its own sample type. @@ -394,13 +414,17 @@ fn decode_as(tiles: &mut Tiles, surface: &SurfaceP, canvas: Canvas, o if x0 >= w || y0 >= h { continue; } - let tile = S::read(tiles.get(t, tile_bytes, budget)?); + let tile = tiles.get(t, tile_bytes, budget)?; for y in y0..(y0 + TILE).min(h) { for x in x0..(x0 + TILE).min(w) { let s = ((y - y0) * TILE + (x - x0)) * channels; let d = (y * w + x) * oc; - if let (Some(src), Some(dst)) = (tile.get(s..s + channels), pixels.get_mut(d..d + oc)) { - put(src, gray, format.alpha, out, dst); + let mut src = [S::OPAQUE; 4]; + for (c, sample) in src.iter_mut().enumerate().take(channels) { + *sample = S::at(tile, s + c).ok_or_else(|| malformed("tile pixel is incomplete"))?; + } + if let Some(dst) = pixels.get_mut(d..d + oc) { + put(src.get(..channels).ok_or_else(|| malformed("too many pixel channels"))?, gray, format.alpha, out, dst); } } } @@ -421,6 +445,9 @@ fn unzstd(frame: &[u8], len: usize, what: &str) -> Result, Error> { if out.len() < len { return Err(malformed(format!("{what} decodes to {} bytes, not {len}", out.len()))); } + if !dec.get_ref().is_empty() { + return Err(malformed(format!("{what} has bytes after its zstd frame"))); + } Ok(out) } @@ -461,6 +488,16 @@ mod tests { assert!(matches!(unzstd(&frame, 101, "t"), Err(Error::Malformed(_)))); } + #[test] + fn bytes_after_a_zstd_frame_are_malformed() { + let frame = ruzstd::encoding::compress_to_vec(&[7u8; 100][..], ruzstd::encoding::CompressionLevel::Fastest); + for extra in [&b"junk"[..], frame.as_slice()] { + let mut trailing = frame.clone(); + trailing.extend_from_slice(extra); + assert!(matches!(unzstd(&trailing, 100, "t"), Err(Error::Malformed(_)))); + } + } + /// A ZIP archive of STORE entries. fn stored(entries: &[(String, Vec)]) -> Vec { let (mut out, mut dir) = (vec![], vec![]); @@ -520,6 +557,27 @@ mod tests { assert!(scans <= 4, "{scans} walks of the central directory"); } + #[test] + fn shared_tiles_do_not_allocate_a_converted_tile_for_each_reference() { + let tile = vec![7u8; TILE * TILE]; + let hash = blake3::hash(&tile).to_hex().to_string(); + let refs: Vec = (0..64).map(|i| format!(r#"{{"tx":{i},"ty":0,"hash":"{hash}"}}"#)).collect(); + let manifest = format!( + r#"{{"format_version":1,"generator":"test","document":{{"size":{{"width":{},"height":1}},"layers":[{{"content":{{"kind":"raster","surface":{{"format":{{"mode":"Grayscale","sample":"U8","alpha":false}},"default":"00","tiles":[{}]}}}}}}]}}}}"#, + 64 * TILE, + refs.join(",") + ); + let file = stored(&[ + ("manifest.json".into(), manifest.into_bytes()), + (format!("tiles/{hash}.zst"), ruzstd::encoding::compress_to_vec(&tile[..], ruzstd::encoding::CompressionLevel::Fastest)), + ]); + let before = SAMPLE_BUFFER_BYTES.with(|n| n.get()); + let p = pcraft_layer_rgba(&file, &[0], &Limits::default()).unwrap().unwrap(); + assert!(matches!(p.rgba, Samples::U8(ref rgba) if rgba.len() == 64 * TILE * 4 && rgba[7 * 4] == 7)); + let allocated = SAMPLE_BUFFER_BYTES.with(|n| n.get()) - before; + assert!(allocated <= 64 * TILE + 1, "{allocated} bytes allocated as converted samples"); + } + #[test] fn a_frame_that_declares_a_window_past_the_limit_is_refused() { // Magic, a header without a content size, then a window of 2^(10 + exponent) bytes and diff --git a/crates/doc/tests/pixels.rs b/crates/doc/tests/pixels.rs index 24c053d4..377eb2e9 100644 --- a/crates/doc/tests/pixels.rs +++ b/crates/doc/tests/pixels.rs @@ -215,7 +215,7 @@ fn a_tile_that_several_references_name_is_decoded_once() { let s = surface("Grayscale", "U8", false, &[0], &refs); let file = bundle(64 * TILE as u32, 1, "Grayscale", &[layer(&raster(&s), None)], &[&tile]); let mut limits = Limits::default(); - limits.max_total = 1 << 20; + limits.max_total = 160 << 10; let p = pcraft_layer_rgba(&file, &[0], &limits).unwrap().unwrap(); let px = u8s(&p.rgba); assert_eq!(px.len(), 64 * TILE * 4);