From 3798d807de5413ba79de637750db77b1aa25dc93 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 14:50:56 -0800 Subject: [PATCH 1/3] craft-doc: PhotoCraft layer pixels and masks behind the pcraft-pixels feature pcraft_layer_rgba(bytes, layer, &Limits) decodes one layer of a .pcraft bundle over the canvas: a raster layer's surface, or the rendered copy saved with a text, shape or smart object layer, as RGBA at the layer's 8, 16 or 32-bit sample type, with the layer mask as its own plane. Grayscale repeats the gray value, missing tiles hold the default pixel, and tiles outside the canvas are not read. Other color modes give Unsupported; groups, adjustment and fill layers give None. Tiles are read with craft-pcraft-format's TileRef and tile paths, decoded with ruzstd and checked against their BLAKE3 hash, as PhotoCraft reads them. The decoded layer and mask count against max_entry and max_total before they are allocated; a frame is decoded only up to its tile's size, and a frame larger than the tile plus an eighth and 4 KiB, or one that declares a window over 8 MiB, is refused. The feature adds ruzstd and blake3, both pure Rust. The doc workflow runs clippy and the tests with --all-features. Part of #59. --- .github/workflows/doc.yml | 3 +- Cargo.lock | 21 ++ crates/doc/Cargo.toml | 8 + crates/doc/README.md | 34 +++ crates/doc/src/lib.rs | 7 + crates/doc/src/pcraft.rs | 8 + crates/doc/src/pcraft_pixels.rs | 434 ++++++++++++++++++++++++++++++++ crates/doc/tests/pixels.rs | 311 +++++++++++++++++++++++ 8 files changed, 825 insertions(+), 1 deletion(-) create mode 100644 crates/doc/src/pcraft_pixels.rs create mode 100644 crates/doc/tests/pixels.rs diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 19156ec0..6d797393 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -30,5 +30,6 @@ jobs: targets: wasm32-unknown-unknown - run: cargo fmt --check - run: cargo clippy -p craft-doc --all-targets --locked -- -D warnings - - run: cargo test -p craft-doc --locked -- --nocapture + - run: cargo clippy -p craft-doc --all-targets --all-features --locked -- -D warnings + - run: cargo test -p craft-doc --all-features --locked -- --nocapture - run: cargo check -p craft-doc --all-features --locked --target wasm32-unknown-unknown diff --git a/Cargo.lock b/Cargo.lock index 1af6fd17..02ff750c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,6 +364,19 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -668,6 +681,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core-foundation" version = "0.9.4" @@ -802,11 +821,13 @@ dependencies = [ name = "craft-doc" version = "0.1.0" dependencies = [ + "blake3", "craft-ecproj-format", "craft-pcraft-format", "craft-vectorcraft-format", "flate2", "proptest", + "ruzstd", "serde", "serde_json", "thiserror 2.0.21", diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 4cd92ee1..9e7f511f 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -10,6 +10,11 @@ repository.workspace = true [lints] workspace = true +[features] +default = [] +# `pcraft_layer_rgba`: a PhotoCraft layer's pixels and its layer mask. +pcraft-pixels = ["dep:blake3", "dep:ruzstd"] + [dependencies] craft-ecproj-format = { workspace = true } craft-pcraft-format = { workspace = true } @@ -20,6 +25,9 @@ serde = { workspace = true } # checks the nesting against `Limits::max_depth` before parsing. serde_json = { workspace = true, features = ["unbounded_depth"] } thiserror = { workspace = true } +# `pcraft-pixels`: PhotoCraft's tiles are zstd frames named by the BLAKE3 hash of their samples. +blake3 = { version = "1.8", default-features = false, features = ["pure"], optional = true } +ruzstd = { version = "0.9", default-features = false, features = ["std"], optional = true } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/doc/README.md b/crates/doc/README.md index 20d29ec5..ef4cdec7 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -63,6 +63,40 @@ EffectCraft layers have no `opacity`, because opacity is an animated property th A `.pcraft` bundle saved as a folder, ZIP64 archives and encrypted entries are not read. The `.ecprojx` XML copies are not read. +## PhotoCraft layer pixels + +With the feature `pcraft-pixels`, which adds `ruzstd` and `blake3` (both pure Rust), +`pcraft_layer_rgba(bytes, layer, &Limits)` decodes one layer of a `.pcraft` bundle over the +canvas: + +```toml +craft-doc = { git = "https://github.com/storytold/craft-libs", rev = "", features = ["pcraft-pixels"] } +``` + +- `layer` is a path of indexes through `Outline::layers` and each group's `children`, bottom + first: `&[1, 0]` is the bottom layer inside the second layer from the bottom. +- A raster layer gives its surface; a text, shape or smart object layer gives the rendered copy + PhotoCraft saves with it. A group, an adjustment or fill layer, a layer saved without a rendered + copy, or a path that names no layer gives `Ok(None)`. +- `LayerPixels::rgba` holds `width` × `height` RGBA pixels, row by row, in the layer's sample + type: `Samples::U8`, `U16` or `F32`. Grayscale repeats the gray value in red, green and blue, + and a surface without alpha is opaque. Pixels without a tile hold the surface's default pixel. + Tiles outside the canvas, at negative columns and rows among them, are not read. +- `LayerPixels::mask` holds the layer mask, one sample per pixel, with its `enabled`, `density` + and `feather` as saved. Density and feather are not applied. +- RGB and grayscale documents are read at 8, 16 and 32 bits. Other color modes give + `Error::Unsupported`. +- The decoded layer and the decoded mask each count as one entry against `max_entry`, and with + every tile read and decompressed against `max_total`, checked before they are allocated. 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. + +`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 +tile coordinates, a mask, layers in groups and layers without pixels, a CMYK document, a zstd +bomb, and the limits. + ## Limits `Limits` (fields `max_json`, `max_entry`, `max_total`, `max_depth`) bounds each call: diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 5d27cf83..f33f5171 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -6,6 +6,8 @@ //! - [`info`] reads the format version and the writer the file names. //! - [`renditions`] returns the previews and the PDF a file carries. //! - [`outline`] returns the canvas, the pages (artboards or compositions) and the layers. +//! - `pcraft_layer_rgba`, with the feature `pcraft-pixels`, decodes a PhotoCraft layer's pixels +//! and its layer mask. //! //! Bytes go in and plain data comes out: no file system access, no rendering and no app crate. //! Each format's envelope is read through its craft-libs crate: `craft-pcraft-format`, @@ -26,9 +28,14 @@ mod ecproj; mod pcraft; +#[cfg(feature = "pcraft-pixels")] +mod pcraft_pixels; mod vectorcraft; mod zip; +#[cfg(feature = "pcraft-pixels")] +pub use pcraft_pixels::{LayerMask, LayerPixels, Samples, pcraft_layer_rgba}; + use std::cell::Cell; use std::collections::BTreeMap; use std::fmt; diff --git a/crates/doc/src/pcraft.rs b/crates/doc/src/pcraft.rs index dd079869..039af773 100644 --- a/crates/doc/src/pcraft.rs +++ b/crates/doc/src/pcraft.rs @@ -156,3 +156,11 @@ pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result(bytes: &'a [u8], budget: &mut Budget) -> Result<(Zip<'a>, D), Error> { + let (zip, json) = manifest(bytes, budget)?; + let document = read::(&json, budget)?.document; + Ok((zip, document)) +} diff --git a/crates/doc/src/pcraft_pixels.rs b/crates/doc/src/pcraft_pixels.rs new file mode 100644 index 00000000..287ef705 --- /dev/null +++ b/crates/doc/src/pcraft_pixels.rs @@ -0,0 +1,434 @@ +//! A PhotoCraft layer's pixels (feature `pcraft-pixels`): the tiles of its surface, decoded with +//! `ruzstd` and laid out as RGBA over the canvas, and its layer mask. +//! +//! A surface is a pixel format, a default pixel and a list of 256 × 256 tiles on a grid in +//! document pixels, so tiles may sit at negative columns and rows. Each tile is stored at +//! [`craft_pcraft_format::tile_path`] of its hash as one zstd frame of interleaved little-endian +//! samples, row by row, the color channels first and then the alpha channel when the format has +//! one; the hash is the BLAKE3 hash of those samples. Pixels without a tile hold the default +//! pixel. + +use std::collections::BTreeSet; +use std::io::Read; + +use craft_pcraft_format::TileRef; +use serde::Deserialize; + +use crate::zip::Zip; +use crate::{Budget, Error, Kind, Limits, counted, malformed, pcraft, sniff}; + +/// The side of a tile, in pixels. +const TILE: usize = 256; + +/// The largest zstd window a tile's frame may declare: 8 MiB, eight times the largest tile +/// (256 × 256 pixels of four 32-bit samples). The decoder allocates the window the frame +/// declares, so a larger one is refused before anything is allocated for it. +const MAX_WINDOW: u64 = 8 << 20; + +/// Samples of one type, interleaved. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum Samples { + /// 8-bit samples, 0 to 255. + U8(Vec), + /// 16-bit samples, 0 to 65535. + U16(Vec), + /// 32-bit floating-point samples as stored, nominally 0 to 1. + F32(Vec), +} + +impl Samples { + /// The number of samples. + pub fn len(&self) -> usize { + match self { + Samples::U8(v) => v.len(), + Samples::U16(v) => v.len(), + Samples::F32(v) => v.len(), + } + } + + /// `true` when there are no samples. + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +/// A layer's pixels over the canvas, from [`pcraft_layer_rgba`]. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct LayerPixels { + /// The canvas width in pixels. + pub width: u32, + /// The canvas height in pixels. + pub height: u32, + /// `width` × `height` pixels of red, green, blue and alpha, row by row from the top-left + /// corner of the canvas, in the layer's sample type. Grayscale pixels repeat the gray value in + /// red, green and blue. A surface without alpha is opaque (255, 65535 or 1.0). Alpha is + /// straight, not premultiplied. + pub rgba: Samples, + /// The layer mask, when the layer has one. + pub mask: Option, +} + +/// A layer mask as the manifest stores it. The pixels are the mask's surface; `density` and +/// `feather` are not applied to them. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct LayerMask { + /// `false` when the mask is turned off. + pub enabled: bool, + /// The mask's density, 0 to 1. + pub density: f32, + /// The mask's feather radius in pixels. + pub feather: f32, + /// One sample per canvas pixel, row by row, in the mask's sample type: the minimum hides the + /// layer and the maximum shows it. + pub alpha: Samples, +} + +/// The pixels of the PhotoCraft layer at `layer`, a path of indexes through [`crate::Outline`]'s +/// `layers` (bottom first) and then each group's `children`, over the canvas. +/// +/// A raster layer gives its surface, and a text, shape or smart object layer the rendered copy +/// PhotoCraft saves with it. `Ok(None)` when `layer` names no layer, or a layer without pixels of +/// its own: a group, an adjustment or fill layer, or a text, shape or smart object layer saved +/// without its rendered copy. Tiles outside the canvas are not read. +/// +/// RGB and grayscale documents are read at 8, 16 and 32 bits. Other color modes give +/// [`Error::Unsupported`]; a file other than a `.pcraft` bundle gives [`Error::Unrecognized`] or +/// [`Error::Unsupported`]. +/// +/// The decoded layer and the decoded mask each count as one entry against +/// [`Limits::max_entry`], and together with every tile read and decompressed against +/// [`Limits::max_total`]. Each is checked before it is allocated. +pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Result, Error> { + match sniff(bytes).ok_or(Error::Unrecognized)? { + Kind::PhotoCraft => {} + kind => return Err(Error::Unsupported(format!("layer pixels are read from .pcraft bundles, not .{}", kind.extension()))), + } + let mut budget = Budget::new(limits); + let (zip, doc) = pcraft::document::(bytes, &mut budget)?; + if !matches!(doc.mode.as_str(), "Rgb" | "Grayscale") { + return Err(unsupported_mode(&doc.mode)); + } + let Some(found) = find(&doc.layers, layer) else { return Ok(None) }; + let surface = match found.content.kind.as_str() { + "raster" => found.content.surface.as_ref(), + "text" | "shape" | "smart" => found.content.cache.as_ref(), + _ => None, + }; + let Some(surface) = surface else { return Ok(None) }; + if !matches!(surface.format.mode.as_str(), "Rgb" | "Grayscale") { + return Err(unsupported_mode(&surface.format.mode)); + } + let canvas = Canvas { width: doc.size.width, height: doc.size.height }; + let rgba = decode(&zip, surface, canvas, Out::Rgba, &mut budget)?; + let mask = match &found.mask { + Some(m) => { + if m.surface.format.mode != "Grayscale" { + return Err(malformed(format!("the layer mask is {}, not Grayscale", m.surface.format.mode))); + } + let alpha = decode(&zip, &m.surface, canvas, Out::Gray, &mut budget)?; + Some(LayerMask { enabled: m.enabled, density: m.density, feather: m.feather, alpha }) + } + None => None, + }; + Ok(Some(LayerPixels { width: canvas.width, height: canvas.height, rgba, mask })) +} + +fn unsupported_mode(mode: &str) -> Error { + Error::Unsupported(format!("{mode} pixels are not read; only Rgb and Grayscale")) +} + +/// The layer at `path`. +fn find<'a>(layers: &'a [LayerP], path: &[usize]) -> Option<&'a LayerP> { + let (first, rest) = path.split_first()?; + let layer = layers.get(*first)?; + if rest.is_empty() { Some(layer) } else { find(&layer.content.children, rest) } +} + +#[derive(Deserialize)] +struct DocP { + size: SizeP, + mode: String, + #[serde(deserialize_with = "counted")] + layers: Vec, +} + +#[derive(Deserialize)] +struct SizeP { + width: u32, + height: u32, +} + +#[derive(Deserialize)] +struct LayerP { + #[serde(default)] + mask: Option, + content: ContentP, +} + +#[derive(Deserialize)] +struct MaskP { + surface: SurfaceP, + enabled: bool, + density: f32, + feather: f32, +} + +#[derive(Deserialize)] +struct ContentP { + kind: String, + /// A raster layer's surface. + #[serde(default)] + surface: Option, + /// A text, shape or smart object layer's rendered copy. + #[serde(default)] + cache: Option, + #[serde(default, deserialize_with = "counted")] + children: Vec, +} + +#[derive(Deserialize)] +struct SurfaceP { + format: FormatP, + /// The default pixel, as hex of its little-endian samples. + default: String, + #[serde(deserialize_with = "counted")] + tiles: Vec, +} + +#[derive(Deserialize)] +struct FormatP { + mode: String, + sample: SampleP, + alpha: bool, +} + +#[derive(Deserialize, Clone, Copy)] +enum SampleP { + U8, + U16, + F32, +} + +#[derive(Clone, Copy)] +struct Canvas { + width: u32, + height: u32, +} + +/// What [`decode`] writes per pixel. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Out { + /// Red, green, blue and alpha. + Rgba, + /// The first channel. + Gray, +} + +impl Out { + fn channels(self) -> usize { + match self { + Out::Rgba => 4, + Out::Gray => 1, + } + } +} + +/// One sample type, read from little-endian bytes. +trait Sample: Copy { + const SIZE: usize; + const OPAQUE: Self; + fn read(bytes: &[u8]) -> Vec; +} + +impl Sample for u8 { + const SIZE: usize = 1; + const OPAQUE: u8 = u8::MAX; + fn read(bytes: &[u8]) -> Vec { + bytes.to_vec() + } +} + +impl Sample for u16 { + const SIZE: usize = 2; + const OPAQUE: u16 = u16::MAX; + fn read(bytes: &[u8]) -> Vec { + bytes.as_chunks::<2>().0.iter().map(|b| u16::from_le_bytes(*b)).collect() + } +} + +impl Sample for f32 { + const SIZE: usize = 4; + const OPAQUE: f32 = 1.0; + fn read(bytes: &[u8]) -> Vec { + bytes.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() + } +} + +/// `surface` over the canvas, in its own sample type. +fn decode(zip: &Zip, surface: &SurfaceP, canvas: Canvas, out: Out, budget: &mut Budget) -> Result { + Ok(match surface.format.sample { + SampleP::U8 => Samples::U8(decode_as::(zip, surface, canvas, out, budget)?), + SampleP::U16 => Samples::U16(decode_as::(zip, surface, canvas, out, budget)?), + SampleP::F32 => Samples::F32(decode_as::(zip, surface, canvas, out, budget)?), + }) +} + +/// The channels of one source pixel, written as `out` asks. +fn put(src: &[S], gray: bool, alpha: bool, out: Out, dst: &mut [S]) { + let (Some(&first), Some(&last)) = (src.first(), src.last()) else { return }; + match out { + Out::Gray => { + if let Some(d) = dst.first_mut() { + *d = first; + } + } + Out::Rgba => { + let a = if alpha { last } else { S::OPAQUE }; + let rgb = if gray { [first; 3] } else { [first, src.get(1).copied().unwrap_or(first), src.get(2).copied().unwrap_or(first)] }; + for (d, s) in dst.iter_mut().zip(rgb.into_iter().chain([a])) { + *d = s; + } + } + } +} + +fn decode_as(zip: &Zip, surface: &SurfaceP, canvas: Canvas, out: Out, budget: &mut Budget) -> Result, Error> { + let format = &surface.format; + let gray = format.mode == "Grayscale"; + let channels = if gray { 1 } else { 3 } + usize::from(format.alpha); + let pixel_bytes = channels * S::SIZE; + + if surface.default.len() != pixel_bytes * 2 { + return Err(malformed(format!("the default pixel has {} hex digits, not {}", surface.default.len(), pixel_bytes * 2))); + } + let default = S::read(&unhex(&surface.default)?); + + let (w, h) = (canvas.width as usize, canvas.height as usize); + let oc = out.channels(); + let len = u64::from(canvas.width) + .checked_mul(u64::from(canvas.height)) + .and_then(|n| n.checked_mul((oc * S::SIZE) as u64)) + .ok_or_else(|| Error::LimitExceeded("the canvas is too large".into()))?; + let what = match out { + Out::Rgba => "the layer's pixels", + Out::Gray => "the layer mask", + }; + budget.take_entry(len, what)?; + // The samples, counted in `usize`; on a 32-bit target a canvas within the limits may still + // hold more than `usize` counts. + let too_large = || Error::LimitExceeded(format!("{what} do not fit in memory")); + let count = usize::try_from(len / S::SIZE as u64).map_err(|_| too_large())?; + let mut fill = vec![S::OPAQUE; oc]; + put(&default, gray, format.alpha, out, &mut fill); + let mut pixels = Vec::new(); + pixels.try_reserve_exact(count).map_err(|_| too_large())?; + for _ in 0..count / oc { + pixels.extend_from_slice(&fill); + } + + let tile_bytes = TILE * TILE * pixel_bytes; + let mut seen = BTreeSet::new(); + for t in &surface.tiles { + // Both edges of the tile must fit in i32, as PhotoCraft reads them. + let fits = |i: i32| i.checked_mul(TILE as i32).is_some() && i.checked_add(1).and_then(|e| e.checked_mul(TILE as i32)).is_some(); + if !fits(t.tx) || !fits(t.ty) { + return Err(malformed(format!("tile ({}, {}) is outside the coordinate range", t.tx, t.ty))); + } + if !seen.insert((t.tx, t.ty)) { + return Err(malformed(format!("duplicate tile ({}, {})", t.tx, t.ty))); + } + // Columns and rows of the canvas the tile covers; negative tiles cover none. + let (Ok(tx), Ok(ty)) = (usize::try_from(t.tx), usize::try_from(t.ty)) else { continue }; + let (x0, y0) = (tx.saturating_mul(TILE), ty.saturating_mul(TILE)); + if x0 >= w || y0 >= h { + continue; + } + let path = t.path().ok_or_else(|| malformed(format!("tile ({}, {}) has an invalid hash", t.tx, t.ty)))?; + let entry = zip.find(&path).ok_or_else(|| malformed(format!("the manifest names `{path}`, which the bundle does not have")))?; + // A frame larger than its tile plus an eighth and 4 KiB is refused, as PhotoCraft reads it. + let max_frame = (tile_bytes + tile_bytes / 8 + 4096) as u64; + let frame = zip.read(entry, max_frame.min(budget.limits.max_entry), budget, &path)?; + budget.take_entry(tile_bytes as u64, &path)?; + let samples = unzstd(&frame, tile_bytes, &path)?; + if blake3::hash(&samples).to_hex().as_str() != t.hash { + return Err(malformed(format!("{path}: the samples do not match their hash"))); + } + let tile = S::read(&samples); + 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); + } + } + } + } + Ok(pixels) +} + +/// One zstd frame that decodes to exactly `len` bytes. Decoding stops one byte past `len`, so a +/// frame that expands further is refused without being decoded. +fn unzstd(frame: &[u8], len: usize, what: &str) -> Result, Error> { + let zstd = |e: &dyn std::fmt::Display| malformed(format!("{what}: zstd: {e}")); + let mut dec = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(frame, MAX_WINDOW).map_err(|e| zstd(&e))?; + let mut out = Vec::with_capacity(len); + (&mut dec).take(len as u64 + 1).read_to_end(&mut out).map_err(|e| zstd(&e))?; + if out.len() > len { + return Err(malformed(format!("{what} decodes to more than {len} bytes"))); + } + if out.len() < len { + return Err(malformed(format!("{what} decodes to {} bytes, not {len}", out.len()))); + } + Ok(out) +} + +/// Bytes from hex digits, either case. +fn unhex(s: &str) -> Result, Error> { + let bad = || malformed(format!("bad hex `{s}`")); + if !s.len().is_multiple_of(2) { + return Err(bad()); + } + s.as_bytes() + .as_chunks::<2>() + .0 + .iter() + .map(|[hi, lo]| { + let digit = |c: u8| char::from(c).to_digit(16); + u8::try_from(digit(*hi).ok_or_else(bad)? * 16 + digit(*lo).ok_or_else(bad)?).map_err(|_| bad()) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_reads_both_cases_and_refuses_the_rest() { + assert_eq!(unhex("00ffA0").ok(), Some(vec![0, 255, 0xa0])); + assert!(unhex("0").is_err()); + assert!(unhex("+5").is_err()); + assert!(unhex("zz").is_err()); + } + + #[test] + fn a_frame_that_expands_past_the_tile_is_refused() { + let frame = ruzstd::encoding::compress_to_vec(&[7u8; 100][..], ruzstd::encoding::CompressionLevel::Fastest); + assert_eq!(unzstd(&frame, 100, "t").ok(), Some(vec![7u8; 100])); + assert!(matches!(unzstd(&frame, 99, "t"), Err(Error::Malformed(m)) if m.contains("more than 99 bytes"))); + assert!(matches!(unzstd(&frame, 101, "t"), Err(Error::Malformed(_)))); + } + + #[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 + // one empty last block. + let frame = |exponent: u8| [0x28, 0xb5, 0x2f, 0xfd, 0x00, exponent << 3, 0x01, 0x00, 0x00]; + assert_eq!(unzstd(&frame(13), 0, "t").ok(), Some(vec![])); + assert!(matches!(unzstd(&frame(14), 0, "t"), Err(Error::Malformed(m)) if m.contains("zstd"))); + } +} diff --git a/crates/doc/tests/pixels.rs b/crates/doc/tests/pixels.rs new file mode 100644 index 00000000..cdb36e19 --- /dev/null +++ b/crates/doc/tests/pixels.rs @@ -0,0 +1,311 @@ +//! `pcraft_layer_rgba` on `.pcraft` bundles assembled here: manifests written by hand and tiles +//! compressed with `ruzstd`, each stored under the BLAKE3 hash of its samples. +#![cfg(feature = "pcraft-pixels")] + +mod common; + +use common::{store, zip}; +use craft_doc::{Error, LayerPixels, Limits, Samples, pcraft_layer_rgba}; + +const TILE: usize = 256; + +/// The hash a tile is stored under. +fn hash(tile: &[u8]) -> String { + blake3::hash(tile).to_hex().to_string() +} + +/// A valid hash that names no tile in the bundle. +fn absent(n: u32) -> String { + format!("{n:064x}") +} + +fn zstd(bytes: &[u8]) -> Vec { + ruzstd::encoding::compress_to_vec(bytes, ruzstd::encoding::CompressionLevel::Fastest) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// A surface's JSON: `mode`, `sample` and `alpha`, the default pixel's bytes and `(tx, ty, hash)` +/// tiles. +fn surface(mode: &str, sample: &str, alpha: bool, default: &[u8], tiles: &[(i32, i32, &str)]) -> String { + let tiles: Vec = tiles.iter().map(|(tx, ty, h)| format!(r#"{{"tx":{tx},"ty":{ty},"hash":"{h}"}}"#)).collect(); + format!(r#"{{"format":{{"mode":"{mode}","sample":"{sample}","alpha":{alpha}}},"default":"{}","tiles":[{}]}}"#, hex(default), tiles.join(",")) +} + +/// A layer's JSON with `content` and an optional mask surface. +fn layer(content: &str, mask: Option<&str>) -> String { + let mask = match mask { + Some(s) => format!(r#"{{"surface":{s},"enabled":false,"linked":true,"density":0.5,"feather":2.0}}"#), + None => "null".into(), + }; + format!( + r#"{{"id":1,"name":"L","visible":true,"locks":{{}},"blend":"Normal","opacity":1.0,"fill_opacity":1.0,"clipped":false,"mask":{mask}, + "effects":{{"enabled":true,"items":[],"psd_raw":null}},"label":"None","content":{content},"psd_blocks":[],"psd_id":null,"fill_cache":null}}"# + ) +} + +fn raster(surface: &str) -> String { + format!(r#"{{"kind":"raster","surface":{surface}}}"#) +} + +/// A bundle of a `width` × `height` canvas in `mode` with `layers`, and `(entry hash, uncompressed +/// tile)` entries. +fn bundle_with(width: u32, height: u32, mode: &str, layers: &[String], tiles: &[(String, Vec)]) -> Vec { + let manifest = format!( + r#"{{"format_version":1,"generator":"photocraft-format 0.1.0", + "document":{{"id":1,"name":"Untitled","size":{{"width":{width},"height":{height}}},"resolution_dpi":72.0,"mode":"{mode}","depth":"U8", + "icc_profile":null,"layers":[{}],"channels":[],"guides":{{}},"selection":null,"metadata":{{}},"global_light":{{}}}}, + "thumbnail":null,"composite":null}}"#, + layers.join(",") + ); + let names: Vec = tiles.iter().map(|(h, _)| format!("tiles/{h}.zst")).collect(); + let frames: Vec> = tiles.iter().map(|(_, t)| zstd(t)).collect(); + let mut entries: Vec = names.iter().zip(&frames).map(|(n, f)| store(n, f)).collect(); + entries.push(store("manifest.json", manifest.as_bytes())); + zip(&entries) +} + +/// [`bundle_with`], each tile stored under its own hash. +fn bundle(width: u32, height: u32, mode: &str, layers: &[String], tiles: &[&[u8]]) -> Vec { + let tiles: Vec<(String, Vec)> = tiles.iter().map(|t| (hash(t), t.to_vec())).collect(); + bundle_with(width, height, mode, layers, &tiles) +} + +/// A tile whose every pixel is `pixel`'s bytes. +fn solid(pixel: &[u8]) -> Vec { + pixel.repeat(TILE * TILE) +} + +fn read(bytes: &[u8], path: &[usize]) -> LayerPixels { + pcraft_layer_rgba(bytes, path, &Limits::default()).unwrap().unwrap() +} + +fn u8s(s: &Samples) -> &[u8] { + match s { + Samples::U8(v) => v, + _ => panic!("not 8-bit: {s:?}"), + } +} + +fn rgba_at(p: &LayerPixels, x: usize, y: usize) -> &[u8] { + let i = (y * p.width as usize + x) * 4; + &u8s(&p.rgba)[i..i + 4] +} + +fn malformed(file: &[u8]) -> String { + match pcraft_layer_rgba(file, &[0], &Limits::default()) { + Err(Error::Malformed(m)) => m, + other => panic!("not malformed: {other:?}"), + } +} + +#[test] +fn eight_bit_tiles_land_on_the_canvas_and_missing_tiles_hold_the_default_pixel() { + // Tile (0, 0) is a gradient, tile (1, 1) is solid; (1, 0) and (0, 1) are missing. + let mut gradient = vec![]; + for y in 0..TILE { + for x in 0..TILE { + gradient.extend([x as u8, y as u8, 7, 255]); + } + } + let fill = solid(&[200, 100, 50, 128]); + let s = surface("Rgb", "U8", true, &[10, 20, 30, 40], &[(0, 0, &hash(&gradient)), (1, 1, &hash(&fill))]); + let file = bundle(300, 260, "Rgb", &[layer(&raster(&s), None)], &[&gradient, &fill]); + let p = read(&file, &[0]); + assert_eq!((p.width, p.height, p.rgba.len()), (300, 260, 300 * 260 * 4)); + assert_eq!(rgba_at(&p, 0, 0), [0, 0, 7, 255]); + assert_eq!(rgba_at(&p, 255, 3), [255, 3, 7, 255]); + assert_eq!(rgba_at(&p, 256, 0), [10, 20, 30, 40]); + assert_eq!(rgba_at(&p, 0, 256), [10, 20, 30, 40]); + assert_eq!(rgba_at(&p, 299, 259), [200, 100, 50, 128]); + assert_eq!(rgba_at(&p, 256, 256), [200, 100, 50, 128]); + assert_eq!(p.mask, None); +} + +#[test] +fn sixteen_bit_tiles_without_alpha_are_opaque() { + let pixel: Vec = [1000u16, 30000, 65000].iter().flat_map(|v| v.to_le_bytes()).collect(); + let default: Vec = [1u16, 2, 3].iter().flat_map(|v| v.to_le_bytes()).collect(); + let tile = solid(&pixel); + let s = surface("Rgb", "U16", false, &default, &[(0, 0, &hash(&tile))]); + let file = bundle(257, 2, "Rgb", &[layer(&raster(&s), None)], &[&tile]); + let p = read(&file, &[0]); + let Samples::U16(v) = &p.rgba else { panic!("not 16-bit: {:?}", p.rgba) }; + assert_eq!(v.len(), 257 * 2 * 4); + assert_eq!(&v[..4], [1000, 30000, 65000, 65535]); + assert_eq!(&v[256 * 4..257 * 4], [1, 2, 3, 65535]); +} + +#[test] +fn thirty_two_bit_grayscale_tiles_repeat_the_gray_value() { + let pixel: Vec = [0.25f32, 0.5].iter().flat_map(|v| v.to_le_bytes()).collect(); + let default: Vec = [0.0f32, 0.0].iter().flat_map(|v| v.to_le_bytes()).collect(); + let tile = solid(&pixel); + let s = surface("Grayscale", "F32", true, &default, &[(0, 0, &hash(&tile))]); + let file = bundle(1, 1, "Grayscale", &[layer(&raster(&s), None)], &[&tile]); + assert_eq!(read(&file, &[0]).rgba, Samples::F32(vec![0.25, 0.25, 0.25, 0.5])); +} + +#[test] +fn tiles_at_negative_coordinates_are_outside_the_canvas_and_not_read() { + // The tiles at negative columns and rows are not in the bundle; only (0, 0) is. + let tile = solid(&[9, 8, 7, 6]); + let s = surface("Rgb", "U8", true, &[0; 4], &[(-1, -1, &absent(1)), (-1, 0, &absent(2)), (0, -3, &absent(3)), (0, 0, &hash(&tile))]); + let file = bundle(4, 4, "Rgb", &[layer(&raster(&s), None)], &[&tile]); + assert_eq!(read(&file, &[0]).rgba, Samples::U8([9, 8, 7, 6].repeat(16))); + // A tile right of or below the canvas is not read either. + let s = surface("Rgb", "U8", true, &[1, 2, 3, 4], &[(1, 0, &absent(1)), (0, 1, &absent(2))]); + let file = bundle(4, 4, "Rgb", &[layer(&raster(&s), None)], &[]); + assert_eq!(read(&file, &[0]).rgba, Samples::U8([1, 2, 3, 4].repeat(16))); +} + +#[test] +fn tile_coordinates_past_the_grid_and_repeated_tiles_are_malformed() { + let (a, b) = (absent(1), absent(2)); + for tiles in [&[(i32::MAX, 0, a.as_str())][..], &[(0, i32::MIN, &a)], &[(-1, 2, &a), (-1, 2, &b)]] { + let s = surface("Rgb", "U8", true, &[0; 4], tiles); + malformed(&bundle(4, 4, "Rgb", &[layer(&raster(&s), None)], &[])); + } +} + +#[test] +fn a_layer_mask_comes_back_as_its_own_plane() { + let mut mask_tile = vec![255u8; TILE * TILE]; + mask_tile[1] = 0; + let s = surface("Rgb", "U8", true, &[1, 2, 3, 4], &[]); + let m = surface("Grayscale", "U8", false, &[255], &[(0, 0, &hash(&mask_tile))]); + let file = bundle(3, 1, "Rgb", &[layer(&raster(&s), Some(&m))], &[&mask_tile]); + let mask = read(&file, &[0]).mask.unwrap(); + assert_eq!(mask.alpha, Samples::U8(vec![255, 0, 255])); + assert!(!mask.enabled); + assert_eq!((mask.density, mask.feather), (0.5, 2.0)); + // A mask that is not grayscale is malformed. + let m = surface("Rgb", "U8", false, &[0, 0, 0], &[]); + malformed(&bundle(3, 1, "Rgb", &[layer(&raster(&s), Some(&m))], &[])); +} + +#[test] +fn a_cmyk_document_is_unsupported() { + let s = surface("Cmyk", "U8", true, &[0; 5], &[]); + let file = bundle(4, 4, "Cmyk", &[layer(&raster(&s), None)], &[]); + assert!(matches!(pcraft_layer_rgba(&file, &[0], &Limits::default()), Err(Error::Unsupported(m)) if m.contains("Cmyk"))); + // So is a Lab surface in an RGB document. + let s = surface("Lab", "U8", true, &[0; 4], &[]); + let file = bundle(4, 4, "Rgb", &[layer(&raster(&s), None)], &[]); + assert!(matches!(pcraft_layer_rgba(&file, &[0], &Limits::default()), Err(Error::Unsupported(m)) if m.contains("Lab"))); +} + +#[test] +fn a_zstd_bomb_is_refused() { + // 64 MiB of zeros compresses to a few kilobytes; the tile holds 256 KiB. + let bomb = vec![0; 64 << 20]; + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &hash(&bomb))]); + let file = bundle(4, 4, "Rgb", &[layer(&raster(&s), None)], &[&bomb]); + assert!(file.len() < 1 << 20); + assert!(malformed(&file).contains("more than 262144 bytes")); + // A tile that decodes short is malformed too. + let short = vec![0; 100]; + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &hash(&short))]); + malformed(&bundle(4, 4, "Rgb", &[layer(&raster(&s), None)], &[&short])); +} + +#[test] +fn decoded_tiles_and_the_canvas_count_against_the_limits() { + // Four tiles of 256 KiB each, 1 MiB together, over a 512 × 512 canvas of 1 MiB. + let tile = solid(&[1, 2, 3, 4]); + let h = hash(&tile); + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &h), (1, 0, &h), (0, 1, &h), (1, 1, &h)]); + let file = bundle(512, 512, "Rgb", &[layer(&raster(&s), None)], &[&tile]); + assert!(pcraft_layer_rgba(&file, &[0], &Limits::default()).is_ok()); + // The canvas and the decoded tiles take 2 MiB, and the manifest and the frames read take more. + let mut limits = Limits::default(); + limits.max_total = 2 << 20; + assert!(matches!(pcraft_layer_rgba(&file, &[0], &limits), Err(Error::LimitExceeded(_)))); + let mut limits = Limits::default(); + limits.max_entry = (1 << 20) - 1; + assert!(matches!(pcraft_layer_rgba(&file, &[0], &limits), Err(Error::LimitExceeded(m)) if m.contains("the layer's pixels"))); + // A canvas the size of PhotoCraft's largest is refused before anything is allocated for it. + let file = bundle(300_000, 300_000, "Rgb", &[layer(&raster(&s), None)], &[]); + assert!(matches!(pcraft_layer_rgba(&file, &[0], &Limits::default()), Err(Error::LimitExceeded(_)))); +} + +#[test] +fn layers_are_found_by_path_and_layers_without_pixels_give_none() { + let s = |v: u8| surface("Rgb", "U8", true, &[v, v, v, 255], &[]); + let text = format!( + r#"{{"kind":"text","text":"Hi","font_family":"Sans","size_pt":12.0,"color":{{}},"transform":[1,0,0,1,0,0],"cache":{},"psd_raw":null}}"#, + s(3) + ); + let uncached = r#"{"kind":"shape","fill":null,"cache":null,"psd_raw":null}"#; + let group = format!(r#"{{"kind":"group","expanded":true,"children":[{},{}]}}"#, layer(&raster(&s(1)), None), layer(&text, None)); + let adjustment = r#"{"kind":"adjustment","adjustment":{}}"#; + let file = bundle(1, 1, "Rgb", &[layer(&raster(&s(0)), None), layer(&group, None), layer(uncached, None), layer(adjustment, None)], &[]); + assert_eq!(read(&file, &[0]).rgba, Samples::U8(vec![0, 0, 0, 255])); + assert_eq!(read(&file, &[1, 0]).rgba, Samples::U8(vec![1, 1, 1, 255])); + assert_eq!(read(&file, &[1, 1]).rgba, Samples::U8(vec![3, 3, 3, 255])); + for path in [&[][..], &[1], &[2], &[3], &[4], &[1, 2], &[0, 0]] { + assert_eq!(pcraft_layer_rgba(&file, path, &Limits::default()), Ok(None), "{path:?}"); + } +} + +#[test] +fn damaged_surfaces_are_malformed() { + let tile = solid(&[5, 5, 5, 5]); + let other = solid(&[6, 6, 6, 6]); + let layers = |s: &str| [layer(&raster(s), None)]; + // A tile the bundle does not have. + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &hash(&tile))]); + assert!(malformed(&bundle(2, 2, "Rgb", &layers(&s), &[])).contains("does not have")); + // A tile whose samples are not the ones its hash names. + assert!(malformed(&bundle_with(2, 2, "Rgb", &layers(&s), &[(hash(&tile), other.clone())])).contains("do not match their hash")); + // A tile that is not zstd. + let name = format!("tiles/{}.zst", hash(&tile)); + let manifest = format!( + r#"{{"format_version":1,"generator":"photocraft-format 0.1.0","document":{{"size":{{"width":2,"height":2}},"mode":"Rgb","layers":[{}]}}}}"#, + layer(&raster(&s), None) + ); + assert!(malformed(&zip(&[store(&name, b"not zstd"), store("manifest.json", manifest.as_bytes())])).contains("zstd")); + // A frame larger than any tile's frame is refused before it is read. + let big = vec![1u8; 300 << 10]; + let file = zip(&[store(&name, &big), store("manifest.json", manifest.as_bytes())]); + assert!(matches!(pcraft_layer_rgba(&file, &[0], &Limits::default()), Err(Error::LimitExceeded(_)))); + // A default pixel of the wrong size, or not hex. + assert!(malformed(&bundle(2, 2, "Rgb", &layers(&surface("Rgb", "U8", true, &[0; 3], &[])), &[])).contains("6 hex digits, not 8")); + assert!(malformed(&bundle(2, 2, "Rgb", &layers(&surface("Rgb", "U8", true, &[0; 4096], &[])), &[])).contains("8192 hex digits")); + malformed(&bundle(2, 2, "Rgb", &layers(&surface("Rgb", "U8", true, &[0; 4], &[]).replace("00000000", "0000000g")), &[])); + // A hash that is not 64 hex digits. + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, "../manifest")]); + assert!(malformed(&bundle(2, 2, "Rgb", &layers(&s), &[])).contains("invalid hash")); +} + +#[test] +fn every_truncation_and_flipped_byte_is_an_error_not_a_panic() { + let tile = solid(&[1, 2, 3, 4]); + let mask_tile = vec![9u8; TILE * TILE]; + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &hash(&tile))]); + let m = surface("Grayscale", "U8", false, &[255], &[(0, 0, &hash(&mask_tile))]); + let file = bundle(3, 2, "Rgb", &[layer(&raster(&s), Some(&m))], &[&tile, &mask_tile]); + assert!(read(&file, &[0]).mask.is_some()); + for n in 0..file.len() { + let _ = pcraft_layer_rgba(&file[..n], &[0], &Limits::default()); + let mut flipped = file.clone(); + flipped[n] ^= 0xff; + let _ = pcraft_layer_rgba(&flipped, &[0], &Limits::default()); + } +} + +#[test] +fn other_files_are_refused() { + assert_eq!(pcraft_layer_rgba(b"%PDF-1.7", &[0], &Limits::default()), Err(Error::Unrecognized)); + let vc = common::vectorcraft_json(4, ""); + assert!(matches!(pcraft_layer_rgba(vc.as_bytes(), &[0], &Limits::default()), Err(Error::Unsupported(m)) if m.contains(".vectorcraft"))); +} + +#[test] +fn a_newer_bundle_is_too_new() { + let manifest = r#"{"format_version":2,"generator":"photocraft-format 9.0.0","document":{"layers":"anything"}}"#; + let file = zip(&[store("manifest.json", manifest.as_bytes())]); + assert!(matches!(pcraft_layer_rgba(&file, &[0], &Limits::default()), Err(Error::TooNew { version: 2, .. }))); +} From a5d321af47a675dff3e28c402817076ff071f3fa Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 18:30:41 -0800 Subject: [PATCH 2/3] craft-doc: find tiles by name once per call and decode each hash once - pcraft_layer_rgba builds a name-to-entry map of the bundle once per call instead of walking the central directory for every tile. - A tile that several references name is read, decoded and counted against max_total once per call. A hash referenced at two pixel sizes is malformed. - Surfaces stored as RGB or grayscale are read in documents of every mode, so bitmap, duotone, indexed and multichannel documents read. A CMYK or Lab layer or mask gives Unsupported, and an RGB mask gives its first channel. - A canvas of zero width or height is malformed. - The docs name video layers, the masks of layers without pixels, and tile references counting against max_items. - doc.yml runs the tests without features again, next to --all-features. --- .github/workflows/doc.yml | 1 + crates/doc/README.md | 31 ++++-- crates/doc/src/pcraft_pixels.rs | 177 ++++++++++++++++++++++++-------- crates/doc/src/zip.rs | 29 +++++- crates/doc/tests/pixels.rs | 86 ++++++++++++++-- 5 files changed, 262 insertions(+), 62 deletions(-) diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml index 6d797393..801743f1 100644 --- a/.github/workflows/doc.yml +++ b/.github/workflows/doc.yml @@ -31,5 +31,6 @@ jobs: - run: cargo fmt --check - run: cargo clippy -p craft-doc --all-targets --locked -- -D warnings - run: cargo clippy -p craft-doc --all-targets --all-features --locked -- -D warnings + - run: cargo test -p craft-doc --locked -- --nocapture - run: cargo test -p craft-doc --all-features --locked -- --nocapture - run: cargo check -p craft-doc --all-features --locked --target wasm32-unknown-unknown diff --git a/crates/doc/README.md b/crates/doc/README.md index ef4cdec7..3a6e1f3d 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -76,26 +76,35 @@ craft-doc = { git = "https://github.com/storytold/craft-libs", rev = "", - `layer` is a path of indexes through `Outline::layers` and each group's `children`, bottom first: `&[1, 0]` is the bottom layer inside the second layer from the bottom. - A raster layer gives its surface; a text, shape or smart object layer gives the rendered copy - PhotoCraft saves with it. A group, an adjustment or fill layer, a layer saved without a rendered - copy, or a path that names no layer gives `Ok(None)`. + PhotoCraft saves with it. A video layer is a raster layer whose frames are in its `video` + member; it gives its surface, which holds the frame at the playhead, and the other frames are + not read. A group, an adjustment or fill layer, a layer saved + without a rendered copy, or a path that names no layer gives `Ok(None)`, and the mask of such a + layer is not returned. - `LayerPixels::rgba` holds `width` × `height` RGBA pixels, row by row, in the layer's sample type: `Samples::U8`, `U16` or `F32`. Grayscale repeats the gray value in red, green and blue, and a surface without alpha is opaque. Pixels without a tile hold the surface's default pixel. Tiles outside the canvas, at negative columns and rows among them, are not read. - `LayerPixels::mask` holds the layer mask, one sample per pixel, with its `enabled`, `density` - and `feather` as saved. Density and feather are not applied. -- RGB and grayscale documents are read at 8, 16 and 32 bits. Other color modes give - `Error::Unsupported`. + and `feather` as saved. Density and feather are not applied. A mask stored as RGB gives its + first channel. +- Surfaces stored as RGB or grayscale are read at 8, 16 and 32 bits; PhotoCraft stores the layers + of bitmap, duotone, indexed and multichannel documents that way too. A layer or mask stored in + another color mode, such as CMYK or Lab, gives `Error::Unsupported`. A canvas of zero width or + height gives `Error::Malformed`. - The decoded layer and the decoded mask each count as one entry against `max_entry`, and with - every tile read and decompressed against `max_total`, checked before they are allocated. 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. + each distinct tile read and decompressed against `max_total`, checked before they are + 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`. `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 -tile coordinates, a mask, layers in groups and layers without pixels, a CMYK document, a zstd -bomb, and the limits. +tile coordinates, a mask, layers in groups and layers without pixels, text, shape and smart +object copies, bitmap and indexed documents, a CMYK document, a zstd bomb, a tile shared by many +references, and the limits. ## Limits diff --git a/crates/doc/src/pcraft_pixels.rs b/crates/doc/src/pcraft_pixels.rs index 287ef705..2f6debee 100644 --- a/crates/doc/src/pcraft_pixels.rs +++ b/crates/doc/src/pcraft_pixels.rs @@ -8,21 +8,22 @@ //! one; the hash is the BLAKE3 hash of those samples. Pixels without a tile hold the default //! pixel. -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use std::io::Read; use craft_pcraft_format::TileRef; use serde::Deserialize; -use crate::zip::Zip; +use crate::zip::{Entry, Zip}; use crate::{Budget, Error, Kind, Limits, counted, malformed, pcraft, sniff}; /// The side of a tile, in pixels. const TILE: usize = 256; /// The largest zstd window a tile's frame may declare: 8 MiB, eight times the largest tile -/// (256 × 256 pixels of four 32-bit samples). The decoder allocates the window the frame -/// declares, so a larger one is refused before anything is allocated for it. +/// (256 × 256 pixels of four 32-bit samples). The decoder's buffer grows as it decodes, up to the +/// window the frame declares; a frame that declares a larger window is refused before decoding +/// starts. const MAX_WINDOW: u64 = 8 << 20; /// Samples of one type, interleaved. @@ -90,17 +91,25 @@ pub struct LayerMask { /// `layers` (bottom first) and then each group's `children`, over the canvas. /// /// A raster layer gives its surface, and a text, shape or smart object layer the rendered copy -/// PhotoCraft saves with it. `Ok(None)` when `layer` names no layer, or a layer without pixels of -/// its own: a group, an adjustment or fill layer, or a text, shape or smart object layer saved -/// without its rendered copy. Tiles outside the canvas are not read. +/// PhotoCraft saves with it. A video layer is a raster layer whose frames are in its `video` +/// member; it gives its surface, which holds the frame at the playhead, and the other frames are +/// not read. `Ok(None)` when `layer` names no layer, or a layer +/// without pixels of its own: a group, an adjustment or fill layer, or a text, shape or smart +/// object layer saved without its rendered copy. The mask of such a layer is not returned either. +/// Tiles outside the canvas are not read. /// -/// RGB and grayscale documents are read at 8, 16 and 32 bits. Other color modes give -/// [`Error::Unsupported`]; a file other than a `.pcraft` bundle gives [`Error::Unrecognized`] or -/// [`Error::Unsupported`]. +/// Surfaces stored as RGB or grayscale are read at 8, 16 and 32 bits. PhotoCraft stores the +/// layers of bitmap, duotone, indexed and multichannel documents that way too. A layer or mask +/// stored in another color mode, such as CMYK or Lab, gives [`Error::Unsupported`]. A mask stored +/// as RGB gives its first channel. A file other than a `.pcraft` bundle gives +/// [`Error::Unrecognized`] or [`Error::Unsupported`], and a canvas of zero width or height gives +/// [`Error::Malformed`]. /// /// The decoded layer and the decoded mask each count as one entry against -/// [`Limits::max_entry`], and together with every tile read and decompressed against -/// [`Limits::max_total`]. Each is checked before it is allocated. +/// [`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`]. pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Result, Error> { match sniff(bytes).ok_or(Error::Unrecognized)? { Kind::PhotoCraft => {} @@ -108,8 +117,8 @@ pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Resu } let mut budget = Budget::new(limits); let (zip, doc) = pcraft::document::(bytes, &mut budget)?; - if !matches!(doc.mode.as_str(), "Rgb" | "Grayscale") { - return Err(unsupported_mode(&doc.mode)); + if doc.size.width == 0 || doc.size.height == 0 { + return Err(malformed(format!("canvas size {}x{} is empty", doc.size.width, doc.size.height))); } let Some(found) = find(&doc.layers, layer) else { return Ok(None) }; let surface = match found.content.kind.as_str() { @@ -118,17 +127,16 @@ pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Resu _ => None, }; let Some(surface) = surface else { return Ok(None) }; - if !matches!(surface.format.mode.as_str(), "Rgb" | "Grayscale") { - return Err(unsupported_mode(&surface.format.mode)); + check_mode(surface)?; + if let Some(m) = &found.mask { + check_mode(&m.surface)?; } let canvas = Canvas { width: doc.size.width, height: doc.size.height }; - let rgba = decode(&zip, surface, canvas, Out::Rgba, &mut budget)?; + let mut tiles = Tiles::new(&zip); + let rgba = decode(&mut tiles, surface, canvas, Out::Rgba, &mut budget)?; let mask = match &found.mask { Some(m) => { - if m.surface.format.mode != "Grayscale" { - return Err(malformed(format!("the layer mask is {}, not Grayscale", m.surface.format.mode))); - } - let alpha = decode(&zip, &m.surface, canvas, Out::Gray, &mut budget)?; + let alpha = decode(&mut tiles, &m.surface, canvas, Out::Gray, &mut budget)?; Some(LayerMask { enabled: m.enabled, density: m.density, feather: m.feather, alpha }) } None => None, @@ -136,8 +144,49 @@ pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Resu Ok(Some(LayerPixels { width: canvas.width, height: canvas.height, rgba, mask })) } -fn unsupported_mode(mode: &str) -> Error { - Error::Unsupported(format!("{mode} pixels are not read; only Rgb and Grayscale")) +/// [`Error::Unsupported`] unless `surface` is stored as RGB or grayscale. +fn check_mode(surface: &SurfaceP) -> Result<(), Error> { + let mode = surface.format.mode.as_str(); + if matches!(mode, "Rgb" | "Grayscale") { Ok(()) } else { Err(Error::Unsupported(format!("{mode} pixels are not read; only Rgb and Grayscale"))) } +} + +/// The bundle's tiles for one call: its entries by name, and each tile decoded so far by its +/// hash. +struct Tiles<'z, 'a> { + zip: &'z Zip<'a>, + entries: HashMap<&'a [u8], &'z Entry>, + decoded: HashMap>, +} + +impl<'z, 'a> Tiles<'z, 'a> { + fn new(zip: &'z Zip<'a>) -> Self { + Tiles { zip, entries: zip.index(), decoded: HashMap::new() } + } + + /// The `len` bytes of samples of the tile `t` names, read, decoded and checked against its + /// hash the first time a call asks for that hash. + fn get(&mut self, t: &TileRef, len: usize, budget: &mut Budget) -> Result<&[u8], Error> { + if !self.decoded.contains_key(&t.hash) { + let path = t.path().ok_or_else(|| malformed(format!("tile ({}, {}) has an invalid hash", t.tx, t.ty)))?; + let entry = + self.entries.get(path.as_bytes()).ok_or_else(|| malformed(format!("the manifest names `{path}`, which the bundle does not have")))?; + // A frame larger than its tile plus an eighth and 4 KiB is refused, as PhotoCraft reads it. + let max_frame = (len + len / 8 + 4096) as u64; + let frame = self.zip.read(entry, max_frame.min(budget.limits.max_entry), budget, &path)?; + budget.take_entry(len as u64, &path)?; + let samples = unzstd(&frame, len, &path)?; + if blake3::hash(&samples).to_hex().as_str() != t.hash { + return Err(malformed(format!("{path}: the samples do not match their hash"))); + } + self.decoded.insert(t.hash.clone(), samples); + } + match self.decoded.get(&t.hash) { + // One hash names one string of bytes, so a tile of another length is a reference from + // a format of another pixel size. + Some(samples) if samples.len() == len => Ok(samples), + _ => Err(malformed(format!("tile {} is referenced with different sizes", t.hash))), + } + } } /// The layer at `path`. @@ -150,7 +199,6 @@ fn find<'a>(layers: &'a [LayerP], path: &[usize]) -> Option<&'a LayerP> { #[derive(Deserialize)] struct DocP { size: SizeP, - mode: String, #[serde(deserialize_with = "counted")] layers: Vec, } @@ -268,11 +316,11 @@ impl Sample for f32 { } /// `surface` over the canvas, in its own sample type. -fn decode(zip: &Zip, surface: &SurfaceP, canvas: Canvas, out: Out, budget: &mut Budget) -> Result { +fn decode(tiles: &mut Tiles, surface: &SurfaceP, canvas: Canvas, out: Out, budget: &mut Budget) -> Result { Ok(match surface.format.sample { - SampleP::U8 => Samples::U8(decode_as::(zip, surface, canvas, out, budget)?), - SampleP::U16 => Samples::U16(decode_as::(zip, surface, canvas, out, budget)?), - SampleP::F32 => Samples::F32(decode_as::(zip, surface, canvas, out, budget)?), + SampleP::U8 => Samples::U8(decode_as::(tiles, surface, canvas, out, budget)?), + SampleP::U16 => Samples::U16(decode_as::(tiles, surface, canvas, out, budget)?), + SampleP::F32 => Samples::F32(decode_as::(tiles, surface, canvas, out, budget)?), }) } @@ -295,7 +343,7 @@ fn put(src: &[S], gray: bool, alpha: bool, out: Out, dst: &mut [S]) { } } -fn decode_as(zip: &Zip, surface: &SurfaceP, canvas: Canvas, out: Out, budget: &mut Budget) -> Result, Error> { +fn decode_as(tiles: &mut Tiles, surface: &SurfaceP, canvas: Canvas, out: Out, budget: &mut Budget) -> Result, Error> { let format = &surface.format; let gray = format.mode == "Grayscale"; let channels = if gray { 1 } else { 3 } + usize::from(format.alpha); @@ -346,17 +394,7 @@ fn decode_as(zip: &Zip, surface: &SurfaceP, canvas: Canvas, out: Out, if x0 >= w || y0 >= h { continue; } - let path = t.path().ok_or_else(|| malformed(format!("tile ({}, {}) has an invalid hash", t.tx, t.ty)))?; - let entry = zip.find(&path).ok_or_else(|| malformed(format!("the manifest names `{path}`, which the bundle does not have")))?; - // A frame larger than its tile plus an eighth and 4 KiB is refused, as PhotoCraft reads it. - let max_frame = (tile_bytes + tile_bytes / 8 + 4096) as u64; - let frame = zip.read(entry, max_frame.min(budget.limits.max_entry), budget, &path)?; - budget.take_entry(tile_bytes as u64, &path)?; - let samples = unzstd(&frame, tile_bytes, &path)?; - if blake3::hash(&samples).to_hex().as_str() != t.hash { - return Err(malformed(format!("{path}: the samples do not match their hash"))); - } - let tile = S::read(&samples); + let tile = S::read(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; @@ -423,6 +461,65 @@ mod tests { assert!(matches!(unzstd(&frame, 101, "t"), Err(Error::Malformed(_)))); } + /// A ZIP archive of STORE entries. + fn stored(entries: &[(String, Vec)]) -> Vec { + let (mut out, mut dir) = (vec![], vec![]); + for (name, data) in entries { + let mut crc = flate2::Crc::new(); + crc.update(data); + let (offset, size) = (out.len() as u32, data.len() as u32); + let fields = |sig: u32| [sig.to_le_bytes(), [20, 0, 0, 0]].concat(); + out.extend(fields(0x0403_4b50)); + out.extend([0u8; 6]); + out.extend([crc.sum().to_le_bytes(), size.to_le_bytes(), size.to_le_bytes()].concat()); + out.extend([(name.len() as u16).to_le_bytes(), [0, 0]].concat()); + out.extend(name.as_bytes()); + out.extend(data); + dir.extend(fields(0x0201_4b50)); + dir.extend([0u8; 8]); + dir.extend([crc.sum().to_le_bytes(), size.to_le_bytes(), size.to_le_bytes()].concat()); + dir.extend([(name.len() as u16).to_le_bytes(), [0; 2], [0; 2], [0; 2], [0; 2]].concat()); + dir.extend([0u8; 4]); + dir.extend(offset.to_le_bytes()); + dir.extend(name.as_bytes()); + } + let (start, count) = (out.len() as u32, entries.len() as u16); + out.extend(&dir); + out.extend([0x0605_4b50u32.to_le_bytes(), [0; 4]].concat()); + out.extend([count.to_le_bytes(), count.to_le_bytes()].concat()); + out.extend([(dir.len() as u32).to_le_bytes(), start.to_le_bytes()].concat()); + out.extend([0u8; 2]); + out + } + + #[test] + fn tiles_are_found_with_one_walk_of_the_central_directory() { + // 40 distinct grayscale tiles side by side, behind 1,000 entries that are not tiles. + let n = 40; + let mut entries: Vec<(String, Vec)> = (0..1000).map(|i| (format!("other/{i:060}.bin"), vec![])).collect(); + let mut refs = vec![]; + for i in 0..n { + let tile = vec![i as u8; TILE * TILE]; + let hash = blake3::hash(&tile).to_hex().to_string(); + refs.push(format!(r#"{{"tx":{i},"ty":0,"hash":"{hash}"}}"#)); + entries.push((format!("tiles/{hash}.zst"), ruzstd::encoding::compress_to_vec(&tile[..], ruzstd::encoding::CompressionLevel::Fastest))); + } + let surface = format!(r#"{{"format":{{"mode":"Grayscale","sample":"U8","alpha":false}},"default":"00","tiles":[{}]}}"#, refs.join(",")); + let manifest = format!( + r#"{{"format_version":1,"generator":"photocraft-format 0.1.0","document":{{"size":{{"width":{},"height":1}},"mode":"Grayscale","layers":[{{"mask":null,"content":{{"kind":"raster","surface":{surface}}}}}]}}}}"#, + n * TILE + ); + entries.push(("manifest.json".into(), manifest.into_bytes())); + let file = stored(&entries); + let before = crate::zip::SCANS.with(|s| s.get()); + let pixels = pcraft_layer_rgba(&file, &[0], &Limits::default()); + let scans = crate::zip::SCANS.with(|s| s.get()) - before; + let Ok(Some(pixels)) = pixels else { panic!("not read: {pixels:?}") }; + let Samples::U8(rgba) = &pixels.rgba else { panic!("not 8-bit") }; + assert_eq!(rgba.get((39 * TILE + 5) * 4), Some(&39)); + assert!(scans <= 4, "{scans} walks of the central directory"); + } + #[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/src/zip.rs b/crates/doc/src/zip.rs index ec0a4469..abc3721f 100644 --- a/crates/doc/src/zip.rs +++ b/crates/doc/src/zip.rs @@ -1,11 +1,20 @@ //! A ZIP reader for `.pcraft` bundles: STORE and DEFLATE entries, found through the central //! directory, CRC-checked. No ZIP64, no encryption, no archives split across disks. +#[cfg(feature = "pcraft-pixels")] +use std::collections::HashMap; use std::io::Read; use std::ops::Range; use crate::{Budget, Error, malformed}; +#[cfg(test)] +thread_local! { + /// How many times this thread has walked a central directory with [`Zip::find`] or + /// [`Zip::index`]. + pub(crate) static SCANS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + const LOCAL_SIG: u32 = 0x0403_4b50; const CENTRAL_SIG: u32 = 0x0201_4b50; const EOCD_SIG: u32 = 0x0605_4b50; @@ -112,11 +121,29 @@ impl<'a> Zip<'a> { Ok(Zip { bytes, entries }) } - /// The first entry named `name`. + /// The first entry named `name`. Each call walks the central directory; for many lookups, + /// use [`Zip::index`]. pub(crate) fn find(&self, name: &str) -> Option<&Entry> { + #[cfg(test)] + SCANS.with(|n| n.set(n.get() + 1)); self.entries.iter().find(|e| self.bytes.get(e.name.clone()) == Some(name.as_bytes())) } + /// Every entry by name, the first of each name as [`Zip::find`] gives it, built in one walk of + /// the central directory. + #[cfg(feature = "pcraft-pixels")] + pub(crate) fn index(&self) -> HashMap<&'a [u8], &Entry> { + #[cfg(test)] + SCANS.with(|n| n.set(n.get() + 1)); + let mut index = HashMap::with_capacity(self.entries.len()); + for e in &self.entries { + if let Some(name) = self.bytes.get(e.name.clone()) { + index.entry(name).or_insert(e); + } + } + index + } + /// The entry's bytes, decompressed and CRC-checked; their size is checked against `max` and /// counted against `budget` first. pub(crate) fn read(&self, e: &Entry, max: u64, budget: &mut Budget, what: &str) -> Result, Error> { diff --git a/crates/doc/tests/pixels.rs b/crates/doc/tests/pixels.rs index cdb36e19..24c053d4 100644 --- a/crates/doc/tests/pixels.rs +++ b/crates/doc/tests/pixels.rs @@ -181,9 +181,55 @@ fn a_layer_mask_comes_back_as_its_own_plane() { assert_eq!(mask.alpha, Samples::U8(vec![255, 0, 255])); assert!(!mask.enabled); assert_eq!((mask.density, mask.feather), (0.5, 2.0)); - // A mask that is not grayscale is malformed. - let m = surface("Rgb", "U8", false, &[0, 0, 0], &[]); - malformed(&bundle(3, 1, "Rgb", &[layer(&raster(&s), Some(&m))], &[])); + // A mask stored as RGB gives its first channel. + let m = surface("Rgb", "U8", false, &[7, 8, 9], &[]); + assert_eq!(read(&bundle(3, 1, "Rgb", &[layer(&raster(&s), Some(&m))], &[]), &[0]).mask.unwrap().alpha, Samples::U8(vec![7; 3])); + // A mask stored as CMYK is unsupported. + let m = surface("Cmyk", "U8", false, &[0; 4], &[]); + let file = bundle(3, 1, "Rgb", &[layer(&raster(&s), Some(&m))], &[]); + assert!(matches!(pcraft_layer_rgba(&file, &[0], &Limits::default()), Err(Error::Unsupported(m)) if m.contains("Cmyk"))); +} + +#[test] +fn bitmap_and_indexed_documents_are_read_from_their_expanded_layers() { + let gray = surface("Grayscale", "U8", false, &[9], &[]); + assert_eq!(read(&bundle(1, 1, "Bitmap", &[layer(&raster(&gray), None)], &[]), &[0]).rgba, Samples::U8(vec![9, 9, 9, 255])); + let rgb = surface("Rgb", "U8", false, &[1, 2, 3], &[]); + assert_eq!(read(&bundle(1, 1, "Indexed", &[layer(&raster(&rgb), None)], &[]), &[0]).rgba, Samples::U8(vec![1, 2, 3, 255])); +} + +#[test] +fn an_empty_canvas_is_malformed() { + let s = surface("Rgb", "U8", true, &[0; 4], &[]); + for (w, h) in [(0, 4), (4, 0)] { + assert!(malformed(&bundle(w, h, "Rgb", &[layer(&raster(&s), None)], &[])).contains("empty")); + } +} + +#[test] +fn a_tile_that_several_references_name_is_decoded_once() { + // 64 references to one grayscale tile of 64 KiB: 4 MiB if each were decoded. + let tile: Vec = (0..TILE * TILE).map(|i| i as u8).collect(); + let h = hash(&tile); + let refs: Vec<(i32, i32, &str)> = (0..64).map(|i| (i, 0, h.as_str())).collect(); + 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; + let p = pcraft_layer_rgba(&file, &[0], &limits).unwrap().unwrap(); + let px = u8s(&p.rgba); + assert_eq!(px.len(), 64 * TILE * 4); + assert!((0..64).all(|t| px[(t * TILE + 7) * 4..(t * TILE + 8) * 4] == [7, 7, 7, 255])); +} + +#[test] +fn a_tile_referenced_at_two_pixel_sizes_is_malformed() { + // The layer reads the tile as RGBA (256 KiB), the mask as gray (64 KiB). + let tile = solid(&[1, 2, 3, 4]); + let h = hash(&tile); + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &h)]); + let m = surface("Grayscale", "U8", false, &[255], &[(0, 0, &h)]); + assert!(malformed(&bundle(2, 2, "Rgb", &[layer(&raster(&s), Some(&m))], &[&tile])).contains("different sizes")); } #[test] @@ -213,11 +259,12 @@ fn a_zstd_bomb_is_refused() { #[test] fn decoded_tiles_and_the_canvas_count_against_the_limits() { - // Four tiles of 256 KiB each, 1 MiB together, over a 512 × 512 canvas of 1 MiB. - let tile = solid(&[1, 2, 3, 4]); - let h = hash(&tile); - let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &h), (1, 0, &h), (0, 1, &h), (1, 1, &h)]); - let file = bundle(512, 512, "Rgb", &[layer(&raster(&s), None)], &[&tile]); + // Four distinct tiles of 256 KiB each, 1 MiB together, over a 512 × 512 canvas of 1 MiB. + let tiles: Vec> = (1..=4).map(|v| solid(&[v, 2, 3, 4])).collect(); + let h: Vec = tiles.iter().map(|t| hash(t)).collect(); + let s = surface("Rgb", "U8", true, &[0; 4], &[(0, 0, &h[0]), (1, 0, &h[1]), (0, 1, &h[2]), (1, 1, &h[3])]); + let refs: Vec<&[u8]> = tiles.iter().map(Vec::as_slice).collect(); + let file = bundle(512, 512, "Rgb", &[layer(&raster(&s), None)], &refs); assert!(pcraft_layer_rgba(&file, &[0], &Limits::default()).is_ok()); // The canvas and the decoded tiles take 2 MiB, and the manifest and the frames read take more. let mut limits = Limits::default(); @@ -239,13 +286,32 @@ fn layers_are_found_by_path_and_layers_without_pixels_give_none() { s(3) ); let uncached = r#"{"kind":"shape","fill":null,"cache":null,"psd_raw":null}"#; + let shape = format!(r#"{{"kind":"shape","fill":null,"cache":{},"psd_raw":null}}"#, s(4)); + let smart = format!(r#"{{"kind":"smart","source":{{}},"transform":[1,0,0,1,0,0],"smart_filters":[],"cache":{},"psd_raw":null}}"#, s(5)); let group = format!(r#"{{"kind":"group","expanded":true,"children":[{},{}]}}"#, layer(&raster(&s(1)), None), layer(&text, None)); let adjustment = r#"{"kind":"adjustment","adjustment":{}}"#; - let file = bundle(1, 1, "Rgb", &[layer(&raster(&s(0)), None), layer(&group, None), layer(uncached, None), layer(adjustment, None)], &[]); + // A group with a mask gives no pixels and no mask. + let masked_group = r#"{"kind":"group","expanded":true,"children":[]}"#; + let layers = [ + layer(&raster(&s(0)), None), + layer(&group, None), + layer(uncached, None), + layer(adjustment, None), + layer(&shape, None), + layer(&smart, None), + layer(masked_group, Some(&surface("Grayscale", "U8", false, &[0], &[]))), + // A video layer: a raster layer with its frames in `video`. + layer(&raster(&s(6)), None) + .replace(r#""fill_cache":null"#, &format!(r#""fill_cache":null,"video":{{"frames":[{}],"source":"Blank","fps":30.0}}"#, s(7))), + ]; + let file = bundle(1, 1, "Rgb", &layers, &[]); assert_eq!(read(&file, &[0]).rgba, Samples::U8(vec![0, 0, 0, 255])); assert_eq!(read(&file, &[1, 0]).rgba, Samples::U8(vec![1, 1, 1, 255])); assert_eq!(read(&file, &[1, 1]).rgba, Samples::U8(vec![3, 3, 3, 255])); - for path in [&[][..], &[1], &[2], &[3], &[4], &[1, 2], &[0, 0]] { + assert_eq!(read(&file, &[4]).rgba, Samples::U8(vec![4, 4, 4, 255])); + assert_eq!(read(&file, &[5]).rgba, Samples::U8(vec![5, 5, 5, 255])); + assert_eq!(read(&file, &[7]).rgba, Samples::U8(vec![6, 6, 6, 255])); + for path in [&[][..], &[1], &[2], &[3], &[6], &[8], &[1, 2], &[0, 0]] { assert_eq!(pcraft_layer_rgba(&file, path, &Limits::default()), Ok(None), "{path:?}"); } } From 3cdeca266a9417663eb364e177cfd73b7ee00f57 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 2 Nov 2026 19:07:05 -0800 Subject: [PATCH 3/3] 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);