From fa26a2a4c6732ded9f86c411ad7158e159a854fe Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 19:30:55 -0500 Subject: [PATCH] Add craft-pdn: Paint.NET PDN3 reader and a data-only MS-NRBF reader, from PhotoCraft (#968) The format half of PhotoCraft's Paint.NET import (crates/io/src/pdn.rs and pdn/nrbf.rs at abeb16c96): the PDN3 header, the .NET binary serialization graph, the layer list and blend modes, and the chunked gzip pixel blocks. Surfaces, blend-mode mapping and warnings stay in the app. --- Cargo.lock | 9 + Cargo.toml | 1 + README.md | 1 + crates/pdn/Cargo.toml | 18 ++ crates/pdn/README.md | 33 +++ crates/pdn/src/lib.rs | 381 ++++++++++++++++++++++++++++++++ crates/pdn/src/nrbf.rs | 473 ++++++++++++++++++++++++++++++++++++++++ crates/pdn/src/tests.rs | 444 +++++++++++++++++++++++++++++++++++++ 8 files changed, 1360 insertions(+) create mode 100644 crates/pdn/Cargo.toml create mode 100644 crates/pdn/README.md create mode 100644 crates/pdn/src/lib.rs create mode 100644 crates/pdn/src/nrbf.rs create mode 100644 crates/pdn/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 857ed807c..7b227c217 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1409,6 +1409,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "craft-pdn" +version = "0.1.0" +dependencies = [ + "flate2", + "proptest", + "thiserror 2.0.21", +] + [[package]] name = "craft-penpot" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d7a54cda3..6b5a23328 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ craft-aep = { path = "crates/aep" } craft-affinity = { path = "crates/affinity" } craft-ai-core = { path = "crates/ai-core" } craft-pdf = { path = "crates/pdf", version = "=0.1.0" } +craft-pdn = { path = "crates/pdn" } craft-plate-test = { path = "crates/plate-test" } krilla = { version = "=0.8.2", default-features = false, features = ["raster-images"] } hayro = { version = "=0.7.1", default-features = false } diff --git a/README.md b/README.md index 678bf7f1c..33f37171c 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " = std::result::Result; + +fn invalid(message: impl Into) -> Error { + Error::Invalid(message.into()) +} + +fn check(keep_going: &dyn Fn() -> bool) -> Result<()> { + if keep_going() { Ok(()) } else { Err(Error::Cancelled) } +} + +/// The canvas, and all pixel blocks together, may hold at most this many bytes. +pub const MAX_PIXEL_BYTES: usize = 1 << 30; +/// Layers in one document. +pub const MAX_LAYERS: usize = 1024; +/// Longest layer name, in bytes. +pub const MAX_NAME_BYTES: usize = 64 * 1024; +/// Most bytes of layer names in one document. The graph holds a shared string once; every layer +/// that refers to it gets its own copy. +pub const MAX_TOTAL_NAME_BYTES: usize = 4 * 1024 * 1024; + +/// `true` for a PDN3 document (by its signature). +pub fn is_pdn(bytes: &[u8]) -> bool { + bytes.starts_with(b"PDN3") +} + +/// A layer's blend mode: Paint.NET's `LayerBlendMode`, in its numeric order. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum BlendMode { + Normal, + Multiply, + Additive, + /// Paint.NET's own formula, not Photoshop's Color Burn. + ColorBurn, + /// Paint.NET's own formula, not Photoshop's Color Dodge. + ColorDodge, + Reflect, + Glow, + Overlay, + Difference, + Negation, + Lighten, + Darken, + Screen, + Xor, +} + +impl BlendMode { + /// Every mode, indexed by its `LayerBlendMode` value. + pub const ALL: [BlendMode; 14] = [ + BlendMode::Normal, + BlendMode::Multiply, + BlendMode::Additive, + BlendMode::ColorBurn, + BlendMode::ColorDodge, + BlendMode::Reflect, + BlendMode::Glow, + BlendMode::Overlay, + BlendMode::Difference, + BlendMode::Negation, + BlendMode::Lighten, + BlendMode::Darken, + BlendMode::Screen, + BlendMode::Xor, + ]; + + /// The name older files use in `PaintDotNet.UserBlendOps+BlendOp`. + pub fn name(self) -> &'static str { + match self { + BlendMode::Normal => "Normal", + BlendMode::Multiply => "Multiply", + BlendMode::Additive => "Additive", + BlendMode::ColorBurn => "ColorBurn", + BlendMode::ColorDodge => "ColorDodge", + BlendMode::Reflect => "Reflect", + BlendMode::Glow => "Glow", + BlendMode::Overlay => "Overlay", + BlendMode::Difference => "Difference", + BlendMode::Negation => "Negation", + BlendMode::Lighten => "Lighten", + BlendMode::Darken => "Darken", + BlendMode::Screen => "Screen", + BlendMode::Xor => "Xor", + } + } +} + +/// A bitmap layer, bottom first in [`Document::layers`]. Every layer is the size of the canvas. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Layer { + pub name: String, + pub visible: bool, + /// 0 to 255. + pub opacity: u8, + pub blend: BlendMode, + /// Which surface [`Document::read_surfaces`] hands out holds this layer's pixels. Layers may + /// share one. + pub surface: usize, +} + +/// One decoded pixel block: `height` rows of `stride` bytes, each starting with `width` pixels +/// of B, G, R, A bytes (straight alpha). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Surface { + pub width: usize, + pub stride: usize, + pub data: Vec, +} + +impl Surface { + /// The rows, top first, each cut to its `width × 4` pixel bytes (B, G, R, A). + pub fn rows(&self) -> impl Iterator { + self.data.chunks_exact(self.stride.max(1)).filter_map(|row| row.get(..self.width * 4)) + } +} + +struct Block { + length: usize, + stride: usize, +} + +/// The structure of a PDN3 document. Parse first, then read the pixels. +pub struct Document<'a> { + /// Canvas size in pixels. + pub width: usize, + pub height: usize, + /// The layers, bottom first. + pub layers: Vec, + /// The document carries metadata items (`userMetadataItems`); they are not read. + pub has_user_metadata: bool, + bytes: &'a [u8], + /// Where the pixel blocks start. + pixels_at: usize, + blocks: Vec, +} + +fn count(graph: &Graph, object: &Value, field: &str) -> Result { + usize::try_from(graph.integer(graph.field(object, field)?)?).map_err(|_| invalid(format!("invalid {field}"))) +} + +fn blend(graph: &Graph, layer: &Value, props: &Value) -> Result { + if let Some(mode) = graph.optional_field(props, "blendMode")? { + let mode = match graph.resolve(mode)? { + Value::Int(n) => *n, + _ => graph.integer(graph.field(mode, "value__")?)?, + }; + let mode = usize::try_from(mode).map_err(|_| invalid("invalid blend mode"))?; + return BlendMode::ALL.get(mode).copied().ok_or_else(|| invalid(format!("unsupported blend mode {mode}"))); + } + let props = graph.field(layer, "properties")?; + let mode = graph.class_name(graph.field(props, "blendOp")?)?; + let mode = mode + .strip_prefix("PaintDotNet.UserBlendOps+") + .and_then(|s| s.strip_suffix("BlendOp")) + .ok_or_else(|| invalid(format!("unsupported blend operation {mode}")))?; + BlendMode::ALL.iter().copied().find(|m| m.name() == mode).ok_or_else(|| invalid(format!("unsupported blend operation {mode}"))) +} + +impl<'a> Document<'a> { + /// Reads the header and the serialized document. `keep_going` is asked throughout; `false` + /// ends the read with [`Error::Cancelled`]. + pub fn parse(bytes: &'a [u8], keep_going: &dyn Fn() -> bool) -> Result> { + let mut reader = Reader { bytes, pos: 0 }; + if reader.take(4)? != b"PDN3" { + return Err(invalid("unsupported document signature (expected PDN3)")); + } + let header_size = match reader.take(3)? { + [a, b, c] => u32::from_le_bytes([*a, *b, *c, 0]) as usize, + _ => return Err(invalid("invalid header")), + }; + reader.take(header_size)?; + if reader.take(2)? != [0, 1] { + return Err(invalid("unsupported PDN3 serialization version")); + } + let graph = nrbf::parse_from(&mut reader, keep_going)?; + let root = &graph.root; + if graph.class_name(root)? != "PaintDotNet.Document" { + return Err(invalid("root object is not a Paint.NET document")); + } + let width = count(&graph, root, "width")?; + let height = count(&graph, root, "height")?; + let row_bytes = width.checked_mul(4).ok_or_else(|| invalid("canvas size overflow"))?; + let canvas_bytes = row_bytes.checked_mul(height).ok_or_else(|| invalid("canvas size overflow"))?; + if width == 0 || height == 0 || width > i32::MAX as usize || height > i32::MAX as usize || canvas_bytes > MAX_PIXEL_BYTES { + return Err(invalid("canvas is empty or exceeds the 1 GiB pixel limit")); + } + let list = graph.field(root, "layers")?; + let items = graph.array(graph.field(list, "ArrayList+_items")?)?; + let len = count(&graph, list, "ArrayList+_size")?; + if len == 0 || len > MAX_LAYERS { + return Err(invalid("document must have 1..1024 bitmap layers")); + } + let items = items.get(..len).ok_or_else(|| invalid("layer list count exceeds its array"))?; + // Memory block id to the stride of the surfaces that use it. + let mut layouts = HashMap::new(); + let mut layers = Vec::with_capacity(len); + let mut name_bytes = 0usize; + for layer in items { + check(keep_going)?; + if graph.class_name(layer)? != "PaintDotNet.BitmapLayer" { + return Err(invalid("only PDN3 bitmap layers are supported")); + } + if count(&graph, layer, "Layer+width")? != width || count(&graph, layer, "Layer+height")? != height { + return Err(invalid("layer dimensions do not match the canvas")); + } + let surface = graph.field(layer, "surface")?; + if graph.class_name(surface)? != "PaintDotNet.Surface" + || count(&graph, surface, "width")? != width + || count(&graph, surface, "height")? != height + { + return Err(invalid("invalid bitmap surface")); + } + let stride = count(&graph, surface, "stride")?; + if stride < row_bytes { + return Err(invalid("surface stride is too short for BGRA32 pixels")); + } + let Value::Ref(id) = graph.field(surface, "scan0")? else { return Err(invalid("missing pixel memory reference")) }; + if let Some(old) = layouts.insert(*id, stride) + && old != stride + { + return Err(invalid("shared pixel memory has inconsistent strides")); + } + let props = graph.field(layer, "Layer+properties")?; + let opacity = u8::try_from(count(&graph, props, "opacity")?).map_err(|_| invalid("opacity exceeds 255"))?; + let name = graph.string(graph.field(props, "name")?)?; + if name.len() > MAX_NAME_BYTES { + return Err(invalid("layer name is too long")); + } + name_bytes = name_bytes.saturating_add(name.len()); + if name_bytes > MAX_TOTAL_NAME_BYTES { + return Err(invalid("layer names exceed the total metadata budget")); + } + let visible = graph.boolean(graph.field(props, "visible")?)?; + layers.push((*id, Layer { name: name.to_string(), visible, opacity, blend: blend(&graph, layer, props)?, surface: 0 })); + } + // Validate every allocation before any block is decoded. + let mut blocks = Vec::new(); + let mut index_of = HashMap::new(); + let mut total = 0usize; + for (id, block) in graph.objects_of_class("PaintDotNet.MemoryBlock") { + let length = count(&graph, block, "length64")?; + let stride = *layouts.get(&id).ok_or_else(|| invalid("unreferenced pixel memory block"))?; + if length != stride.checked_mul(height).ok_or_else(|| invalid("pixel length overflow"))? { + return Err(invalid("pixel memory length does not match the surface")); + } + if graph.boolean(graph.field(block, "hasParent")?)? || !graph.boolean(graph.field(block, "deferred")?)? { + return Err(invalid("unsupported non-deferred or parented pixel memory")); + } + total = total.checked_add(length).ok_or_else(|| invalid("pixel length overflow"))?; + if total > MAX_PIXEL_BYTES { + return Err(invalid("decoded pixels exceed the 1 GiB limit")); + } + index_of.insert(id, blocks.len()); + blocks.push(Block { length, stride }); + } + let layers = layers + .into_iter() + .map(|(id, layer)| Ok(Layer { surface: *index_of.get(&id).ok_or_else(|| invalid("missing pixel memory block"))?, ..layer })) + .collect::>>()?; + let has_user_metadata = match graph.optional_field(root, "userMetadataItems")? { + Some(meta) if !matches!(graph.resolve(meta)?, Value::Null) => !graph.array(meta)?.is_empty(), + _ => false, + }; + Ok(Document { width, height, layers, has_user_metadata, bytes, pixels_at: reader.pos, blocks }) + } + + /// How many surfaces [`Self::read_surfaces`] hands out. + pub fn surface_count(&self) -> usize { + self.blocks.len() + } + + /// Bytes all surfaces take once decoded: at most [`MAX_PIXEL_BYTES`]. + pub fn decoded_bytes(&self) -> usize { + self.blocks.iter().map(|b| b.length).sum() + } + + /// Decodes the pixel blocks in file order and hands each to `each` with its index (what + /// [`Layer::surface`] names). Only one decoded surface is held at a time unless `each` keeps + /// them. Data after the last block is an error. + pub fn read_surfaces(&self, keep_going: &dyn Fn() -> bool, each: &mut dyn FnMut(usize, Surface)) -> Result<()> { + let mut reader = Reader { bytes: self.bytes, pos: self.pixels_at }; + for (i, block) in self.blocks.iter().enumerate() { + let data = pixels(&mut reader, block.length, keep_going)?; + each(i, Surface { width: self.width, stride: block.stride, data }); + } + if reader.pos != self.bytes.len() { + return Err(invalid("unexpected trailing data")); + } + Ok(()) + } +} + +/// Reads a memory block's numbered chunks in their on-disk order; each chunk has its own gzip +/// stream and checksum. Inflates directly into the bounded destination, checking its exact size. +fn pixels(reader: &mut Reader<'_>, length: usize, keep_going: &dyn Fn() -> bool) -> Result> { + let version = reader.byte()?; + if version > 1 { + return Err(invalid(format!("unsupported pixel compression {version}"))); + } + let chunk_size = reader.be_u32()?; + if chunk_size == 0 { + return Err(invalid("zero pixel chunk size")); + } + let chunks = length.div_ceil(chunk_size); + if chunks > 1_000_000 { + return Err(invalid("too many pixel chunks")); + } + // Uncompressed chunks hold every pixel byte: a file shorter than that is truncated, and is + // rejected before the buffer is allocated. + if version == 1 && reader.bytes.len().saturating_sub(reader.pos) < length { + return Err(invalid("uncompressed pixel data is truncated")); + } + let mut seen = vec![false; chunks]; + let mut data = vec![0; length]; + for _ in 0..chunks { + check(keep_going)?; + let index = reader.be_u32()?; + let found = seen.get_mut(index).ok_or_else(|| invalid("pixel chunk index out of bounds"))?; + if *found { + return Err(invalid("duplicate pixel chunk")); + } + *found = true; + let size = reader.be_u32()?; + let raw = reader.take(size)?; + let start = index.checked_mul(chunk_size).ok_or_else(|| invalid("pixel offset overflow"))?; + let end = start.checked_add(chunk_size.min(length.saturating_sub(start))).ok_or_else(|| invalid("pixel offset overflow"))?; + let dst = data.get_mut(start..end).ok_or_else(|| invalid("invalid pixel chunk range"))?; + if version == 0 { + let mut decoder = flate2::bufread::GzDecoder::new(raw); + for band in dst.chunks_mut(1 << 20) { + check(keep_going)?; + decoder.read_exact(band).map_err(|e| invalid(format!("invalid gzip pixel chunk: {e}")))?; + } + let mut extra = [0]; + if decoder.read(&mut extra).map_err(|e| invalid(format!("invalid gzip pixel checksum: {e}")))? != 0 || !decoder.into_inner().is_empty() { + return Err(invalid("pixel chunk has excess data")); + } + } else { + if raw.len() != dst.len() { + return Err(invalid("incorrect uncompressed pixel chunk size")); + } + dst.copy_from_slice(raw); + } + } + Ok(data) +} diff --git a/crates/pdn/src/nrbf.rs b/crates/pdn/src/nrbf.rs new file mode 100644 index 000000000..ff06b7b6b --- /dev/null +++ b/crates/pdn/src/nrbf.rs @@ -0,0 +1,473 @@ +//! The data-only subset of the .NET Remoting Binary Format (MS-NRBF) that PDN3 uses, from +//! Microsoft's public specification +//! (). The stream becomes +//! a [`Graph`] of [`Value`]s; references stay as ids and no .NET type is instantiated or run. +//! +//! Limits: [`MAX_ITEMS`] records and members, [`MAX_STRINGS`] bytes of strings, [`MAX_DEPTH`] +//! levels of nesting and of reference chains. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::{Error, Result, invalid}; + +/// Most records, array items and class members in one stream. +pub const MAX_ITEMS: usize = 1_000_000; +/// Most bytes of strings in one stream. +pub const MAX_STRINGS: usize = 64 << 20; +/// Deepest nesting of records, and longest chain of references [`Graph::resolve`] follows. +pub const MAX_DEPTH: usize = 64; + +pub(crate) struct Reader<'a> { + pub bytes: &'a [u8], + pub pos: usize, +} + +impl<'a> Reader<'a> { + pub fn take(&mut self, len: usize) -> Result<&'a [u8]> { + let end = self.pos.checked_add(len).ok_or_else(|| invalid("length overflow"))?; + let b = self.bytes.get(self.pos..end).ok_or_else(|| invalid("truncated document"))?; + self.pos = end; + Ok(b) + } + + pub fn byte(&mut self) -> Result { + Ok(self.take(1)?.first().copied().unwrap_or_default()) + } + + pub fn le_i32(&mut self) -> Result { + Ok(i32::from_le_bytes(self.take(4)?.try_into().map_err(|_| invalid("invalid integer"))?)) + } + + pub fn be_u32(&mut self) -> Result { + usize::try_from(u32::from_be_bytes(self.take(4)?.try_into().map_err(|_| invalid("invalid integer"))?)).map_err(|_| invalid("length overflow")) + } + + fn count(&mut self) -> Result { + let n = usize::try_from(self.le_i32()?).map_err(|_| invalid("negative count"))?; + if n > MAX_ITEMS { + return Err(invalid("serialized array or class is too large")); + } + Ok(n) + } +} + +/// A serialized class: its name and its member names, in order. +#[derive(Debug)] +pub struct Class { + name: String, + names: Vec, + primitives: Vec>, +} + +impl Class { + /// The class name as serialized (`PaintDotNet.Document`). + pub fn name(&self) -> &str { + &self.name + } + /// The member names, in the order [`Value::Object`] holds their values. + pub fn member_names(&self) -> &[String] { + &self.names + } +} + +/// A value of the graph. +#[derive(Debug)] +pub enum Value { + /// A null reference, and every primitive this reader skips (doubles, decimals, time spans). + Null, + Bool(bool), + /// Every integer type, widened. An unsigned 64-bit value and a date keep their bit pattern. + Int(i64), + /// A string or a single character. + String(String), + /// A reference to the object with this id; [`Graph::resolve`] follows it. + Ref(i32), + /// An array's items. A byte array's content is not kept: its value stays [`Value::Null`]. + Array(Vec), + Object { + class: Arc, + members: Vec, + }, +} + +/// The objects of a stream, by id. +pub struct Graph { + /// A reference to the root object. + pub root: Value, + objects: HashMap, + order: Vec, +} + +impl Graph { + /// Every object of the class `name` with its id, in stream order. + pub fn objects_of_class<'a>(&'a self, name: &'a str) -> impl Iterator { + self.order + .iter() + .filter_map(|id| self.objects.get(id).map(|v| (*id, v))) + .filter(move |(_, v)| matches!(v, Value::Object { class, .. } if class.name == name)) + } + /// Follows references to the value they name. + pub fn resolve<'a>(&'a self, mut value: &'a Value) -> Result<&'a Value> { + for _ in 0..MAX_DEPTH { + match value { + Value::Ref(id) => value = self.objects.get(id).ok_or_else(|| invalid("missing object reference"))?, + _ => return Ok(value), + } + } + Err(invalid("cyclic or excessively deep object reference")) + } + + /// The class name of an object. + pub fn class_name<'a>(&'a self, value: &'a Value) -> Result<&'a str> { + match self.resolve(value)? { + Value::Object { class, .. } => Ok(&class.name), + _ => Err(invalid("expected a serialized object")), + } + } + + /// A member of an object by name; an error when the class has none of that name. + pub fn field<'a>(&'a self, value: &'a Value, name: &str) -> Result<&'a Value> { + self.optional_field(value, name)?.ok_or_else(|| invalid(format!("missing field {name}"))) + } + + /// A member of an object by name; `None` when the class has none of that name. + pub fn optional_field<'a>(&'a self, value: &'a Value, name: &str) -> Result> { + match self.resolve(value)? { + Value::Object { class, members } => Ok(class.names.iter().position(|n| n == name).and_then(|i| members.get(i))), + _ => Err(invalid("expected a serialized object")), + } + } + + /// An integer value. + pub fn integer(&self, value: &Value) -> Result { + match self.resolve(value)? { + Value::Int(n) => Ok(*n), + _ => Err(invalid("expected an integer")), + } + } + + /// A boolean value. + pub fn boolean(&self, value: &Value) -> Result { + match self.resolve(value)? { + Value::Bool(b) => Ok(*b), + _ => Err(invalid("expected a boolean")), + } + } + + /// A string value. + pub fn string<'a>(&'a self, value: &'a Value) -> Result<&'a str> { + match self.resolve(value)? { + Value::String(s) => Ok(s), + _ => Err(invalid("expected a string")), + } + } + + /// An array's items. + pub fn array<'a>(&'a self, value: &'a Value) -> Result<&'a [Value]> { + match self.resolve(value)? { + Value::Array(a) => Ok(a), + _ => Err(invalid("expected an array")), + } + } +} + +struct Parser<'a, 'b> { + reader: &'b mut Reader<'a>, + keep_going: &'b dyn Fn() -> bool, + graph: Graph, + classes: HashMap>, + items: usize, + strings: usize, +} + +/// Reads a stream from its header record to its end record. Returns the graph and the number of +/// bytes the stream took, so whatever follows it can be read. `keep_going` is asked at every +/// record and array item; `false` ends the read with [`Error::Cancelled`]. +pub fn parse(bytes: &[u8], keep_going: &dyn Fn() -> bool) -> Result<(Graph, usize)> { + let mut reader = Reader { bytes, pos: 0 }; + let graph = parse_from(&mut reader, keep_going)?; + Ok((graph, reader.pos)) +} + +pub(crate) fn parse_from(reader: &mut Reader<'_>, keep_going: &dyn Fn() -> bool) -> Result { + if reader.byte()? != 0 { + return Err(invalid("missing NRBF stream header")); + } + let root = reader.le_i32()?; + let _header = reader.le_i32()?; + if root == 0 || reader.le_i32()? != 1 || reader.le_i32()? != 0 { + return Err(invalid("unsupported NRBF stream version")); + } + let mut parser = Parser { + reader, + keep_going, + graph: Graph { root: Value::Ref(root), objects: HashMap::new(), order: Vec::new() }, + classes: HashMap::new(), + items: 0, + strings: 0, + }; + loop { + let tag = parser.reader.byte()?; + if tag == 11 { + break; + } + parser.record(tag, 0)?; + } + parser.graph.resolve(&parser.graph.root)?; + Ok(parser.graph) +} + +impl Parser<'_, '_> { + fn check(&self) -> Result<()> { + if (self.keep_going)() { Ok(()) } else { Err(Error::Cancelled) } + } + + fn budget(&mut self, n: usize) -> Result<()> { + self.items = self.items.checked_add(n).ok_or_else(|| invalid("object count overflow"))?; + if self.items > MAX_ITEMS { + return Err(invalid("too many serialized objects or members")); + } + Ok(()) + } + + fn string(&mut self) -> Result { + let mut len = 0usize; + for shift in (0..35).step_by(7) { + let b = self.reader.byte()?; + if shift == 28 && b > 7 { + return Err(invalid("invalid string length")); + } + len |= usize::from(b & 127) << shift; + if b & 128 == 0 { + self.strings = self.strings.checked_add(len).ok_or_else(|| invalid("string length overflow"))?; + if self.strings > MAX_STRINGS { + return Err(invalid("serialized strings are too large")); + } + return String::from_utf8(self.reader.take(len)?.to_vec()).map_err(|_| invalid("invalid UTF-8 string")); + } + } + Err(invalid("invalid string length")) + } + + fn primitive(&mut self, tag: u8) -> Result { + macro_rules! number { + ($ty:ty, $variant:ident) => { + Value::$variant( + <$ty>::from_le_bytes(self.reader.take(size_of::<$ty>())?.try_into().map_err(|_| invalid("invalid primitive"))?).into(), + ) + }; + } + Ok(match tag { + 1 => match self.reader.byte()? { + 0 => Value::Bool(false), + 1 => Value::Bool(true), + _ => return Err(invalid("invalid boolean")), + }, + 2 => Value::Int(i64::from(self.reader.byte()?)), + 3 => { + let first = self.reader.byte()?; + let len = match first { + 0..=0x7f => 1, + 0xc2..=0xdf => 2, + 0xe0..=0xef => 3, + _ => return Err(invalid("invalid character")), + }; + let mut b = vec![first]; + b.extend_from_slice(self.reader.take(len - 1)?); + Value::String(String::from_utf8(b).map_err(|_| invalid("invalid character"))?) + } + 5 | 18 => Value::String(self.string()?), + 6 => { + self.reader.take(8)?; + Value::Null + } + 7 => number!(i16, Int), + 8 => number!(i32, Int), + 9 | 12 => number!(i64, Int), + 10 => Value::Int(i64::from(self.reader.byte()? as i8)), + 11 => { + self.reader.take(4)?; + Value::Null + } + 13 | 16 => { + let n = u64::from_le_bytes(self.reader.take(8)?.try_into().map_err(|_| invalid("invalid integer"))?); + // Metadata dates may use the high bits; their bit pattern stays intact. + Value::Int(n as i64) + } + 14 => number!(u16, Int), + 15 => number!(u32, Int), + 17 => Value::Null, + _ => return Err(invalid(format!("unsupported primitive type {tag}"))), + }) + } + + fn type_info(&mut self, tag: u8) -> Result> { + match tag { + 0 => Ok(Some(self.reader.byte()?)), + 1 | 2 | 5 | 6 => Ok(None), + 3 => { + self.string()?; + Ok(None) + } + 4 => { + self.string()?; + self.reader.le_i32()?; + Ok(None) + } + 7 => { + self.reader.byte()?; + Ok(None) + } + _ => Err(invalid(format!("unsupported binary type {tag}"))), + } + } + + fn reserve(&mut self, id: i32) -> Result<()> { + if id == 0 || self.graph.objects.insert(id, Value::Null).is_some() { + return Err(invalid("duplicate or zero object id")); + } + self.graph.order.push(id); + Ok(()) + } + + fn value(&mut self, depth: usize) -> Result { + loop { + let tag = self.reader.byte()?; + if tag == 12 { + self.record(tag, depth)?; + } else { + return self.record(tag, depth); + } + } + } + + fn object(&mut self, id: i32, class: Arc, depth: usize) -> Result { + self.reserve(id)?; + self.budget(class.names.len())?; + let mut members = Vec::with_capacity(class.names.len()); + for primitive in &class.primitives { + members.push(match primitive { + Some(p) => self.primitive(*p)?, + None => self.value(depth + 1)?, + }); + } + self.graph.objects.insert(id, Value::Object { class, members }); + Ok(Value::Ref(id)) + } + + fn array(&mut self, id: i32, len: usize, primitive: Option, depth: usize) -> Result { + self.reserve(id)?; + self.budget(len)?; + if primitive == Some(2) { + self.reader.take(len)?; + return Ok(Value::Ref(id)); + } + let mut values = Vec::with_capacity(len); + while values.len() < len { + self.check()?; + if let Some(p) = primitive { + values.push(self.primitive(p)?); + continue; + } + let tag = self.reader.byte()?; + if matches!(tag, 13 | 14) { + let count = if tag == 13 { usize::from(self.reader.byte()?) } else { self.reader.count()? }; + if count == 0 || count > len - values.len() { + return Err(invalid("invalid array null run")); + } + values.resize_with(values.len() + count, || Value::Null); + } else if tag == 12 { + self.record(tag, depth + 1)?; + } else { + values.push(self.record(tag, depth + 1)?); + } + } + self.graph.objects.insert(id, Value::Array(values)); + Ok(Value::Ref(id)) + } + + fn record(&mut self, tag: u8, depth: usize) -> Result { + self.check()?; + self.budget(1)?; + if depth > MAX_DEPTH { + return Err(invalid("serialized objects nested too deeply")); + } + match tag { + 1 => { + let id = self.reader.le_i32()?; + let metadata = self.reader.le_i32()?; + let class = self.classes.get(&metadata).cloned().ok_or_else(|| invalid("missing class metadata"))?; + self.object(id, class, depth) + } + 2..=5 => { + let id = self.reader.le_i32()?; + let name = self.string()?; + let count = self.reader.count()?; + self.budget(count)?; + let names = (0..count).map(|_| self.string()).collect::>>()?; + let primitives = if tag >= 4 { + let types = self.reader.take(count)?.to_vec(); + types.into_iter().map(|t| self.type_info(t)).collect::>>()? + } else { + vec![None; count] + }; + if matches!(tag, 3 | 5) { + self.reader.le_i32()?; + } + let class = Arc::new(Class { name, names, primitives }); + if self.classes.insert(id, class.clone()).is_some() { + return Err(invalid("duplicate class metadata")); + } + self.object(id, class, depth) + } + 6 => { + let id = self.reader.le_i32()?; + self.reserve(id)?; + let value = Value::String(self.string()?); + self.graph.objects.insert(id, value); + Ok(Value::Ref(id)) + } + 7 => { + let id = self.reader.le_i32()?; + let kind = self.reader.byte()?; + let rank = self.reader.count()?; + if kind > 5 || rank == 0 || rank > 32 { + return Err(invalid("invalid array dimensions")); + } + let mut len = 1usize; + for _ in 0..rank { + len = len.checked_mul(self.reader.count()?).ok_or_else(|| invalid("array size overflow"))?; + } + if kind >= 3 { + for _ in 0..rank { + if self.reader.le_i32()? != 0 { + return Err(invalid("nonzero array lower bounds are unsupported")); + } + } + } + let tag = self.reader.byte()?; + let primitive = self.type_info(tag)?; + self.array(id, len, primitive, depth) + } + 8 => { + let primitive = self.reader.byte()?; + self.primitive(primitive) + } + 9 => Ok(Value::Ref(self.reader.le_i32()?)), + 10 => Ok(Value::Null), + 12 => { + self.reader.le_i32()?; + self.string()?; + Ok(Value::Null) + } + 15..=17 => { + let id = self.reader.le_i32()?; + let len = self.reader.count()?; + let primitive = if tag == 15 { Some(self.reader.byte()?) } else { None }; + self.array(id, len, primitive, depth) + } + _ => Err(invalid(format!("unsupported NRBF record {tag}"))), + } + } +} diff --git a/crates/pdn/src/tests.rs b/crates/pdn/src/tests.rs new file mode 100644 index 000000000..bf6c8586b --- /dev/null +++ b/crates/pdn/src/tests.rs @@ -0,0 +1,444 @@ +use super::*; +use std::io::Write; + +enum Field { + Int(i32), + Long(i64), + Byte(u8), + Bool(bool), + Ref(i32), +} + +struct Writer(Vec); + +impl Writer { + fn int(&mut self, n: i32) { + self.0.extend(n.to_le_bytes()); + } + fn string(&mut self, s: &str) { + let mut len = s.len(); + while len >= 128 { + self.0.push((len as u8 & 127) | 128); + len >>= 7; + } + self.0.push(len as u8); + self.0.extend(s.as_bytes()); + } + fn object(&mut self, id: i32, class: &str, fields: &[(&str, Field)]) { + self.0.push(5); + self.int(id); + self.string(class); + self.int(fields.len() as i32); + for (name, _) in fields { + self.string(name); + } + for (_, f) in fields { + self.0.push(if matches!(f, Field::Ref(_)) { 2 } else { 0 }); + } + for (_, f) in fields { + match f { + Field::Int(_) => self.0.push(8), + Field::Long(_) => self.0.push(9), + Field::Byte(_) => self.0.push(2), + Field::Bool(_) => self.0.push(1), + Field::Ref(_) => {} + } + } + self.int(1); + for (_, f) in fields { + match f { + Field::Int(n) => self.int(*n), + Field::Long(n) => self.0.extend(n.to_le_bytes()), + Field::Byte(n) => self.0.push(*n), + Field::Bool(b) => self.0.push(u8::from(*b)), + Field::Ref(id) => { + self.0.push(9); + self.int(*id); + } + } + } + } +} + +const GO: &dyn Fn() -> bool = &|| true; + +/// Parses and decodes everything, as an application would. +fn read_all(bytes: &[u8]) -> Result<(Vec, Vec)> { + let doc = Document::parse(bytes, GO)?; + let mut surfaces = Vec::new(); + doc.read_surfaces(GO, &mut |i, s| { + assert_eq!(i, surfaces.len()); + surfaces.push(s); + })?; + assert_eq!(surfaces.iter().map(|s| s.data.len()).sum::(), doc.decoded_bytes()); + Ok((doc.layers, surfaces)) +} + +/// Independent synthetic PDN writer: padded rows, forward references, a root cycle, +/// spare ArrayList capacity, and reversed gzip/raw chunks. There is no production PDN writer. +fn fixture(modes: &[i32], legacy: bool, compressed: bool) -> Vec { + use Field::*; + let mut w = Writer(b"PDN3\0\0\0\0\x01".to_vec()); + w.0.push(0); + for n in [1, -1, 1, 0] { + w.int(n); + } + w.0.push(12); + w.int(1); + w.string("PaintDotNet.Data"); + w.object(1, "PaintDotNet.Document", &[("width", Int(2)), ("height", Int(2)), ("layers", Ref(2))]); + w.object(2, "PaintDotNet.LayerList", &[("parent", Ref(1)), ("ArrayList+_size", Int(modes.len() as i32)), ("ArrayList+_items", Ref(3))]); + w.0.push(16); + w.int(3); + w.int(modes.len() as i32 + 2); + for i in 0..modes.len() { + w.0.push(9); + w.int(16 + i as i32 * 10); + } + w.0.extend([13, 2]); + for (i, mode) in modes.iter().enumerate() { + let id = 16 + i as i32 * 10; + w.object( + id, + "PaintDotNet.BitmapLayer", + &[ + ("Layer+width", Int(2)), + ("Layer+height", Int(2)), + ("surface", Ref(id + 1)), + ("Layer+properties", Ref(id + 2)), + ("properties", Ref(id + 3)), + ], + ); + w.object(id + 1, "PaintDotNet.Surface", &[("width", Int(2)), ("height", Int(2)), ("stride", Int(12)), ("scan0", Ref(id + 7))]); + let mut fields = + vec![("name", Ref(id + 4)), ("opacity", Byte(if i == 0 { 255 } else { 128 })), ("visible", Bool(i != 1)), ("isBackground", Bool(i == 0))]; + if !legacy { + fields.push(("blendMode", Ref(id + 5))); + } + w.object(id + 2, "PaintDotNet.Layer+LayerProperties", &fields); + w.object(id + 3, "PaintDotNet.BitmapLayer+BitmapLayerProperties", &[("blendOp", Ref(id + 6))]); + w.0.push(6); + w.int(id + 4); + w.string(&format!("Läyer {i}")); + w.object(id + 5, "PaintDotNet.LayerBlendMode", &[("value__", Int(*mode))]); + w.object(id + 6, &format!("PaintDotNet.UserBlendOps+{}BlendOp", BlendMode::ALL.get(*mode as usize).map_or("Unknown", |m| m.name())), &[]); + w.object(id + 7, "PaintDotNet.MemoryBlock", &[("length64", Long(24)), ("hasParent", Bool(false)), ("deferred", Bool(true))]); + } + w.0.push(11); + for _ in modes { + w.0.push(if compressed { 0 } else { 1 }); + w.0.extend(12u32.to_be_bytes()); + for index in [1u32, 0] { + let raw = if index == 0 { + vec![30, 20, 10, 255, 60, 50, 40, 128, 99, 99, 99, 99] + } else { + vec![90, 80, 70, 64, 120, 110, 100, 0, 99, 99, 99, 99] + }; + let data = if compressed { + let mut enc = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + enc.write_all(&raw).unwrap(); + enc.finish().unwrap() + } else { + raw + }; + w.0.extend(index.to_be_bytes()); + w.0.extend((data.len() as u32).to_be_bytes()); + w.0.extend(data); + } + } + w.0 +} + +#[test] +fn all_blend_modes_and_layer_properties_are_read() { + let modes: Vec<_> = (0..14).collect(); + for legacy in [false, true] { + for compressed in [false, true] { + let bytes = fixture(&modes, legacy, compressed); + assert!(is_pdn(&bytes)); + let doc = Document::parse(&bytes, GO).unwrap(); + assert_eq!((doc.width, doc.height, doc.layers.len(), doc.surface_count(), doc.has_user_metadata), (2, 2, 14, 14, false)); + assert_eq!(doc.decoded_bytes(), 14 * 24); + let (layers, surfaces) = read_all(&bytes).unwrap(); + for (i, layer) in layers.iter().enumerate() { + assert_eq!( + layer, + &Layer { + name: format!("Läyer {i}"), + visible: i != 1, + opacity: if i == 0 { 255 } else { 128 }, + blend: BlendMode::ALL[i], + surface: i + } + ); + // Rows are padded to the stride (12 bytes for 2 pixels); the padding is cut off. + let surface = &surfaces[layer.surface]; + assert_eq!((surface.width, surface.stride, surface.data.len()), (2, 12, 24)); + let rows: Vec<&[u8]> = surface.rows().collect(); + assert_eq!(rows, [&[30, 20, 10, 255, 60, 50, 40, 128][..], &[90, 80, 70, 64, 120, 110, 100, 0]], "B, G, R, A"); + } + } + } +} + +#[test] +fn truncation_unknown_modes_and_corrupt_chunks_fail_without_panicking() { + let raw = fixture(&[0], false, false); + for len in 0..raw.len() { + assert!(read_all(&raw[..len]).is_err(), "length {len}"); + } + assert!(read_all(&raw).is_ok()); + let mut trailing = raw.clone(); + trailing.push(0); + assert_eq!(read_all(&trailing).err(), Some(Error::Invalid("unexpected trailing data".into()))); + for legacy in [false, true] { + assert!(Document::parse(&fixture(&[999], legacy, false), GO).is_err()); + assert!(Document::parse(&fixture(&[14], legacy, false), GO).is_err()); + } + let mut corrupt = raw.clone(); + let block = corrupt.len() - 45; + // Zero chunk size. + corrupt[block + 1..block + 5].fill(0); + assert!(read_all(&corrupt).is_err()); + let mut duplicate = raw.clone(); + // Both row chunks claim index 1. + duplicate[block + 25..block + 29].copy_from_slice(&1u32.to_be_bytes()); + assert!(read_all(&duplicate).is_err()); + let mut version = raw.clone(); + version[block] = 2; + assert!(read_all(&version).is_err()); + let mut zipped = fixture(&[0], false, true); + let last = zipped.len() - 1; + zipped[last] ^= 1; + assert!(Document::parse(&zipped, GO).is_ok() && read_all(&zipped).is_err(), "gzip checksum"); +} + +#[test] +fn reading_can_be_cancelled_during_parsing_and_pixel_decode() { + use std::cell::Cell; + let bytes = fixture(&[0, 1], false, true); + for limit in [0, 10] { + let checks = Cell::new(0); + let keep_going = || { + checks.set(checks.get() + 1); + checks.get() <= limit + }; + assert_eq!(Document::parse(&bytes, &keep_going).err(), Some(Error::Cancelled), "{limit}"); + } + let doc = Document::parse(&bytes, GO).unwrap(); + let seen = Cell::new(0); + let result = doc.read_surfaces(&|| seen.get() == 0, &mut |_, _| seen.set(seen.get() + 1)); + assert_eq!((result, seen.get()), (Err(Error::Cancelled), 1), "cancelled after the first surface"); +} + +proptest::proptest! { + #[test] + fn arbitrary_serialized_data_returns_an_error_without_panicking(data in proptest::collection::vec(proptest::prelude::any::(), 0..4096)) { + let mut bytes = b"PDN3\0\0\0\0\x01".to_vec(); + bytes.extend(data); + let _ = read_all(&bytes); + } + + #[test] + fn mutated_layered_documents_never_panic(edits in proptest::collection::vec((proptest::prelude::any::(), proptest::prelude::any::()), 1..8)) { + let mut bytes = fixture(&[0, 5, 13], false, true); + for (offset, value) in edits { + let index = offset % bytes.len(); + bytes[index] = value; + } + let _ = read_all(&bytes); + } +} + +#[test] +fn malformed_headers_are_errors() { + for bytes in [b"".as_slice(), b"PDN3", b"PDN3\xff\xff\xff", b"PDN4\0\0\0", b"PDN3\0\0\0\0\x02"] { + assert!(matches!(Document::parse(bytes, GO), Err(Error::Invalid(_))), "{bytes:?}"); + } + assert!(!is_pdn(b"PDN4\0\0\0") && !is_pdn(b"PD")); + // The XML header between the signature and the stream is skipped by its 24-bit length. + let plain = fixture(&[0], false, true); + let mut with_header = b"PDN3\x05\0\0".to_vec(); + with_header.extend_from_slice(&plain[7..]); + assert_eq!(read_all(&with_header), read_all(&plain)); +} + +/// A stream of `records` after the header record. +fn stream(records: impl FnOnce(&mut Writer)) -> Vec { + let mut w = Writer(vec![0]); + for n in [1, -1, 1, 0] { + w.int(n); + } + records(&mut w); + w.0 +} + +#[test] +fn serialized_counts_depth_and_references_are_bounded() { + let array = |w: &mut Writer, id: i32, len: i32| { + w.0.push(16); + w.int(id); + w.int(len); + }; + let nested = |depth: i32| { + stream(|w| { + (1..=depth).for_each(|id| array(w, id, 1)); + w.0.extend([10, 11]); + }) + }; + let strings = |total: usize| { + stream(|w| { + for id in 1..=(total >> 20) as i32 { + w.0.push(6); + w.int(id); + w.string(&"s".repeat(1 << 20)); + } + w.0.push(11); + }) + }; + let cases: Vec<(&str, Vec)> = vec![ + ("an array over the item limit", stream(|w| array(w, 1, nrbf::MAX_ITEMS as i32 + 1))), + ("nesting past the depth limit", nested(nrbf::MAX_DEPTH as i32 + 2)), + ("strings past the limit", strings(nrbf::MAX_STRINGS + (1 << 20))), + ( + "a duplicate object id", + stream(|w| { + for _ in 0..2 { + w.0.push(6); + w.int(1); + w.string("duplicate"); + } + w.0.push(11); + }), + ), + ( + "a null run longer than its array", + stream(|w| { + array(w, 1, 1); + w.0.extend([13, 2, 11]); + }), + ), + ( + "an object of an undeclared class", + stream(|w| { + w.0.push(1); + w.int(1); + w.int(999); + }), + ), + ("no root object", stream(|w| w.0.push(11))), + ("an unknown record", stream(|w| w.0.push(99))), + ("a negative count", stream(|w| array(w, 1, -1))), + ]; + for (what, bytes) in cases { + let result = nrbf::parse(&bytes, GO).map(|(g, _)| g.array(&g.root).map(|a| a.len())); + assert!(matches!(result, Err(Error::Invalid(_)) | Ok(Err(Error::Invalid(_)))), "{what}"); + } + // An array holding a reference to itself is a legal graph: resolving stops at the array. + let own = stream(|w| { + array(w, 1, 1); + w.0.push(9); + w.int(1); + w.0.push(11); + }); + let (graph, _) = nrbf::parse(&own, GO).unwrap(); + let items = graph.array(&graph.root).unwrap(); + assert_eq!(graph.array(&items[0]).map(<[Value]>::len), Ok(1)); + assert!(nrbf::parse(&nested(nrbf::MAX_DEPTH as i32), GO).is_ok()); + assert!(nrbf::parse(&strings(nrbf::MAX_STRINGS), GO).is_ok()); +} + +#[test] +fn a_graph_is_read_by_class_field_and_reference() { + use Field::*; + let mut bytes = stream(|w| { + w.object( + 1, + "Root", + &[("count", Int(-7)), ("big", Long(1 << 40)), ("flag", Bool(true)), ("byte", Byte(200)), ("child", Ref(2)), ("text", Ref(3))], + ); + w.object(2, "Child", &[("parent", Ref(1))]); + w.0.push(6); + w.int(3); + w.string("héllo"); + w.0.push(11); + }); + let len = bytes.len(); + bytes.extend_from_slice(b"pixels follow"); + let (graph, used) = nrbf::parse(&bytes, GO).unwrap(); + assert_eq!(used, len, "what follows the stream is left for the caller"); + let root = &graph.root; + assert_eq!(graph.class_name(root).unwrap(), "Root"); + assert_eq!(graph.integer(graph.field(root, "count").unwrap()), Ok(-7)); + assert_eq!(graph.integer(graph.field(root, "big").unwrap()), Ok(1 << 40)); + assert_eq!(graph.integer(graph.field(root, "byte").unwrap()), Ok(200)); + assert_eq!(graph.boolean(graph.field(root, "flag").unwrap()), Ok(true)); + assert_eq!(graph.string(graph.field(root, "text").unwrap()), Ok("héllo")); + let child = graph.field(root, "child").unwrap(); + assert_eq!(graph.class_name(graph.field(child, "parent").unwrap()).unwrap(), "Root", "a cycle through objects is fine"); + assert!(graph.optional_field(root, "missing").unwrap().is_none() && graph.field(root, "missing").is_err()); + assert!(graph.integer(graph.field(root, "flag").unwrap()).is_err() && graph.string(root).is_err() && graph.array(root).is_err()); + assert_eq!(graph.objects_of_class("Child").map(|(id, _)| id).collect::>(), [2]); + match graph.resolve(root).unwrap() { + Value::Object { class, members } => assert_eq!((class.name(), class.member_names().len(), members.len()), ("Root", 6, 6)), + other => panic!("{other:?}"), + } + assert_eq!(nrbf::parse(&bytes, &|| false).err(), Some(Error::Cancelled)); +} + +#[test] +fn truncated_uncompressed_pixels_are_rejected_before_allocating() { + // A raw (version 1) block of 256 MiB chunks that declares 1 GiB of pixels but holds a few bytes. + let mut reader = Reader { bytes: &[1, 0x10, 0, 0, 0, 0, 0, 0, 0], pos: 0 }; + assert_eq!(pixels(&mut reader, 1 << 30, GO).err(), Some(Error::Invalid("uncompressed pixel data is truncated".into()))); +} + +/// The fixture with `edit` applied to the serialized text: same-length replacements only. +fn edited(from: &[u8], to: &[u8]) -> Vec { + assert_eq!(from.len(), to.len()); + let mut bytes = fixture(&[0, 1], false, false); + let at = bytes.windows(from.len()).position(|w| w == from).unwrap_or_else(|| panic!("{from:?} not in the fixture")); + bytes[at..at + from.len()].copy_from_slice(to); + bytes +} + +#[test] +fn documents_this_reader_does_not_cover_are_refused() { + let cases: [(&str, &[u8], &[u8]); 6] = [ + ("root object is not a Paint.NET document", b"PaintDotNet.Document", b"PaintDotNet.Documenx"), + ("only PDN3 bitmap layers are supported", b"PaintDotNet.BitmapLayer\x05", b"PaintDotNet.VectorLayer\x05"), + ("invalid bitmap surface", b"PaintDotNet.Surface", b"PaintDotNet.Surfacf"), + ("unsupported non-deferred or parented pixel memory", b"\x18\0\0\0\0\0\0\0\0\x01", b"\x18\0\0\0\0\0\0\0\x01\x01"), + ("pixel memory length does not match the surface", b"\x18\0\0\0\0\0\0\0\0\x01", b"\x19\0\0\0\0\0\0\0\0\x01"), + ("surface stride is too short for BGRA32 pixels", b"\x02\0\0\0\x02\0\0\0\x0c\0\0\0", b"\x02\0\0\0\x02\0\0\0\x07\0\0\0"), + ]; + for (why, from, to) in cases { + assert_eq!(Document::parse(&edited(from, to), GO).err(), Some(Error::Invalid(why.into()))); + } + // The canvas: 2 × 2 in the fixture's Document object, right after its class header. + let canvas = |w: i32, h: i32| { + let mut bytes = fixture(&[0], false, false); + let at = bytes.windows(8).position(|w| w == [2, 0, 0, 0, 2, 0, 0, 0]).unwrap(); + bytes[at..at + 4].copy_from_slice(&w.to_le_bytes()); + bytes[at + 4..at + 8].copy_from_slice(&h.to_le_bytes()); + Document::parse(&bytes, GO).err() + }; + let limit = Some(Error::Invalid("canvas is empty or exceeds the 1 GiB pixel limit".into())); + assert_eq!(canvas(0, 2), limit); + assert_eq!(canvas(16385, 16384), limit); + assert_eq!(canvas(-1, 2), Some(Error::Invalid("invalid width".into()))); + assert_eq!( + canvas(16384, 16384), + Some(Error::Invalid("layer dimensions do not match the canvas".into())), + "1 GiB exactly passes the canvas check" + ); +} + +#[test] +fn layer_counts_and_names_are_bounded() { + assert!(Document::parse(&fixture(&[], false, false), GO).is_err(), "no layers"); + // 1025 layers: over the limit. The fixture's ids step by ten, so this stays far below i32. + let modes = vec![0; MAX_LAYERS + 1]; + assert_eq!(Document::parse(&fixture(&modes, false, false), GO).err(), Some(Error::Invalid("document must have 1..1024 bitmap layers".into()))); + assert_eq!(Document::parse(&fixture(&modes[..MAX_LAYERS], false, false), GO).map(|d| d.layers.len()), Ok(MAX_LAYERS)); +}