craft-doc: bound shared tile conversion and reject trailing frames

This commit is contained in:
dotCooCoo committed 2026-11-02 19:07:05 -08:00
1 parent 3f6f29693c
commit 3cdeca266a
3 files changed
+67 -8

No files matched your search

+3 -2
View File
@@ -97,8 +97,9 @@ craft-doc = { git = "https://github.com/storytold/craft-libs", rev = "<commit>",
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
+63 -5
View File
@@ -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<Option<LayerPixels>, 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<Self>;
fn at(bytes: &[u8], index: usize) -> Option<Self>;
}
#[cfg(test)]
thread_local! { static SAMPLE_BUFFER_BYTES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) }; }
impl Sample for u8 {
const SIZE: usize = 1;
const OPAQUE: u8 = u8::MAX;
fn read(bytes: &[u8]) -> Vec<u8> {
#[cfg(test)]
SAMPLE_BUFFER_BYTES.with(|n| n.set(n.get() + bytes.len()));
bytes.to_vec()
}
fn at(bytes: &[u8], index: usize) -> Option<u8> {
bytes.get(index).copied()
}
}
impl Sample for u16 {
const SIZE: usize = 2;
const OPAQUE: u16 = u16::MAX;
fn read(bytes: &[u8]) -> Vec<u16> {
#[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<u16> {
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<f32> {
#[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<f32> {
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<S: Sample>(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<Vec<u8>, 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<u8>)]) -> Vec<u8> {
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<String> = (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
+1 -1
View File
@@ -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);