diff --git a/Cargo.lock b/Cargo.lock index 8121690c..e1a79826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1305,6 +1305,16 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-ora" +version = "0.1.0" +dependencies = [ + "craft-xml", + "flate2", + "thiserror 2.0.21", + "zip", +] + [[package]] name = "craft-pcraft-format" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index dbbaa40e..65f50145 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ craft-menu = { path = "crates/menu" } craft-metafile = { path = "crates/metafile" } craft-offcrypto = { path = "crates/offcrypto" } craft-opc = { path = "crates/opc" } +craft-ora = { path = "crates/ora" } craft-penpot = { path = "crates/penpot" } craft-pcraft-format = { path = "crates/pcraft-format" } craft-plotter = { path = "crates/plotter" } diff --git a/README.md b/README.md index 37f11b03..c1ab7967 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = ", `stack.xml` 0.0.x). + +An OpenRaster document is a ZIP whose first, stored entry is `mimetype` = `image/openraster`. It +holds `stack.xml` (the layer tree), one PNG per layer, `mergedimage.png` (the whole image) and +`Thumbnails/thumbnail.png`. + +- `is_ora`, `zip_mimetype`: recognise an archive by its first entry, without unzipping. +- `Archive::open`: checks the mimetype and reads `stack.xml` into an `Image`: `Node::Layer`, + `Node::Stack` and `Node::Other`, topmost first, with the attributes the file has (`name`, + `visibility`, `opacity`, `composite-op`, `edit-locked`, `alpha-preserve`, `x`, `y`, `src`, + `isolation`). `Archive::entry` reads a layer image or any other entry under a size limit. +- `Image::explicit_group_isolation`: whether the file's version (0.0.6 and later) makes + `isolation` explicit, or leaves pass-through to be inferred as older files do. +- `Image::to_xml` and `Writer`: write `stack.xml` and the archive, mimetype first, entries + stored. + +Layer images are PNG; decoding and encoding them is the caller's, as is what a `composite-op` +means. `SVG_COMPOSITE_OPS` lists the names the specification defines. + +## Limits + +| Limit | Value | +| --- | --- | +| Canvas side | 300,000 px (`MAX_SIDE`); layer offsets up to four times that | +| Canvas area | 1 GiB at 8 bytes per pixel (`MAX_PIXEL_BYTES`) | +| Elements under the root stack | 8192 (`MAX_NODES`) | +| `stack.xml` | 16 MiB, 512 levels of nesting, no document type declaration | +| An entry | the caller's limit; `MAX_ENTRY` (512 MiB) is the suggested one for a layer image | + +## ZIP + +The archive is read and written with the `zip` crate directly. `zip_mimetype` (the leading stored +`mimetype` entry), the bounded entry read and the mimetype-first writer are the same in every +mimetype-first ZIP format and belong in a shared ZIP crate once there is one. diff --git a/crates/ora/src/lib.rs b/crates/ora/src/lib.rs new file mode 100644 index 00000000..cd7258a3 --- /dev/null +++ b/crates/ora/src/lib.rs @@ -0,0 +1,425 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! OpenRaster (`.ora`) read and write. Layout from the public OpenRaster specification +//! (, `stack.xml` 0.0.x): a ZIP whose first, stored entry is +//! `mimetype` = `image/openraster`, a `stack.xml` layer tree, one PNG per layer, and a +//! `mergedimage.png` / `Thumbnails/thumbnail.png` rendering. +//! +//! - [`Archive::open`] checks the mimetype and reads `stack.xml` into an [`Image`]: the tree of +//! [`Node`]s with the attributes the file has. [`Archive::entry`] reads a layer's image. +//! - [`Writer`] builds an archive: the mimetype first, then the entries it is given and the +//! `stack.xml` of an [`Image`]. +//! +//! Layer images are PNG files; decoding and encoding them is the caller's. So is what a +//! composite operation means: `composite-op` is kept as written ([`SVG_COMPOSITE_OPS`] lists the +//! specification's names). + +#[cfg(test)] +mod tests; + +use std::io::{Cursor, Read, Write}; + +use craft_xml::El; + +/// Why an OpenRaster document was not read or written. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The file is not an OpenRaster archive, is damaged, or exceeds a limit. + #[error("invalid OpenRaster document: {0}")] + Invalid(String), + /// The archive could not be written. + #[error("OpenRaster archive not written: {0}")] + Write(String), +} + +pub type Result = std::result::Result; + +fn invalid(message: impl Into) -> Error { + Error::Invalid(message.into()) +} + +/// The `mimetype` entry of an OpenRaster archive. +pub const MIMETYPE: &[u8] = b"image/openraster"; +/// The `stack.xml` version [`Image::to_xml`] writes when the image names none. +pub const VERSION: &str = "0.0.6"; +/// Canvas side limit; layer offsets may be up to four times this. +pub const MAX_SIDE: u32 = 300_000; +/// The canvas may hold at most this many bytes at 8 bytes per pixel (16-bit RGBA). +pub const MAX_PIXEL_BYTES: usize = 1 << 30; +/// Elements under the root stack: layers, stacks and unknown ones. +pub const MAX_NODES: usize = 8192; +/// Largest `stack.xml`. +pub const MAX_STACK_XML: usize = 16 << 20; +/// Largest layer image file a reader should ask [`Archive::entry`] for. +pub const MAX_ENTRY: usize = 512 << 20; +/// Deepest element nesting of `stack.xml`. +pub const MAX_XML_DEPTH: usize = 512; + +/// The compositing operations the specification names for `composite-op`. Applications also +/// write `svg:plus`, `svg:exclusion` and their own (`krita:`). +pub const SVG_COMPOSITE_OPS: [&str; 15] = [ + "svg:src-over", + "svg:multiply", + "svg:screen", + "svg:overlay", + "svg:darken", + "svg:lighten", + "svg:color-dodge", + "svg:color-burn", + "svg:hard-light", + "svg:soft-light", + "svg:difference", + "svg:color", + "svg:luminosity", + "svg:hue", + "svg:saturation", +]; + +/// The payload of a ZIP's first entry when it is a stored file named `mimetype` of at most 256 +/// bytes, which is how OpenRaster (and Krita, OpenDocument, EPUB) mark their archives so the +/// type is readable without unzipping. +pub fn zip_mimetype(bytes: &[u8]) -> Option<&[u8]> { + let u16_at = |o: usize| bytes.get(o..o + 2).and_then(|s| <[u8; 2]>::try_from(s).ok()).map(u16::from_le_bytes); + let u32_at = |o: usize| bytes.get(o..o + 4).and_then(|s| <[u8; 4]>::try_from(s).ok()).map(u32::from_le_bytes); + if u32_at(0)? != 0x0403_4b50 || u16_at(8)? != 0 { + return None; + } + let size = usize::try_from(u32_at(18)?).ok()?; + let name_len = usize::from(u16_at(26)?); + let extra_len = usize::from(u16_at(28)?); + if bytes.get(30..30 + name_len)? != b"mimetype" || size > 256 { + return None; + } + let start = 30 + name_len + extra_len; + bytes.get(start..start.checked_add(size)?) +} + +/// `true` for an OpenRaster archive (by its `mimetype` entry). +pub fn is_ora(bytes: &[u8]) -> bool { + zip_mimetype(bytes) == Some(MIMETYPE) +} + +/// The attributes a `` and a `` share. +#[derive(Clone, Debug, PartialEq)] +pub struct Props { + /// `name`, exactly as written (surrounding whitespace kept). + pub name: Option, + /// `visibility`: everything but `hidden` is visible. + pub visible: bool, + /// `opacity`, as stored (not clamped). 1 when missing. + pub opacity: f32, + /// `composite-op`, trimmed. Missing means `svg:src-over`. + pub composite_op: Option, + /// `edit-locked`. + pub edit_locked: bool, + /// `alpha-preserve`. + pub alpha_preserve: bool, +} + +impl Default for Props { + fn default() -> Props { + Props { name: None, visible: true, opacity: 1.0, composite_op: None, edit_locked: false, alpha_preserve: false } + } +} + +/// A ``: one image at a position. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Layer { + pub props: Props, + /// `x`, `y`: where the image's top-left pixel sits (fractions are rounded). + pub x: i32, + pub y: i32, + /// `src`: the archive entry of the layer's image, trimmed. + pub src: Option, +} + +/// A ``: a group. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Stack { + pub props: Props, + /// `isolation`, trimmed: `isolate` or `auto`. See [`Image::explicit_group_isolation`]. + pub isolation: Option, + /// The children, topmost first as the file lists them. + pub children: Vec, +} + +/// An element of a stack. +#[derive(Clone, Debug, PartialEq)] +pub enum Node { + Layer(Layer), + Stack(Stack), + /// An element that is neither (``, ...): its tag and `name` attribute. Not written. + Other { + tag: String, + name: Option, + }, +} + +/// The `` root of `stack.xml`. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + /// `version`, as written. + pub version: Option, + /// Canvas size in pixels (`w`, `h`), 1 to [`MAX_SIDE`]. + pub width: u32, + pub height: u32, + /// `xres`, `yres` in pixels per inch: 72 when `xres` is missing, `yres` defaults to `xres`. + pub xres: f64, + pub yres: f64, + /// The children of the root ``, topmost first. The root stack's own attributes are + /// not read. + pub stack: Vec, +} + +/// A number attribute; a missing one is `default`, an unparsable one an error. +fn num(el: &El, name: &str, default: f64) -> Result { + match el.attr(name) { + None => Ok(default), + Some(v) => v.trim().parse::().ok().filter(|v| v.is_finite()).ok_or_else(|| invalid(format!("invalid {name} attribute `{v}`"))), + } +} + +fn int(el: &El, name: &str) -> Result { + let v = num(el, name, 0.0)?.round(); + if v.abs() > f64::from(MAX_SIDE) * 4.0 { + return Err(invalid(format!("{name} offset {v} is out of range"))); + } + Ok(v as i32) +} + +fn flag(el: &El, name: &str) -> bool { + el.attr(name).is_some_and(|v| matches!(v.trim(), "true" | "1")) +} + +fn props(el: &El) -> Result { + Ok(Props { + name: el.attr("name").map(str::to_string), + visible: el.attr("visibility").is_none_or(|v| v.trim() != "hidden"), + opacity: num(el, "opacity", 1.0)? as f32, + composite_op: el.attr("composite-op").map(|v| v.trim().to_string()), + edit_locked: flag(el, "edit-locked"), + alpha_preserve: flag(el, "alpha-preserve"), + }) +} + +/// The elements of `stack`. Recursion is bounded by [`MAX_XML_DEPTH`]. +fn stack(el: &El, count: &mut usize) -> Result> { + let mut out = Vec::new(); + for child in el.elements() { + *count += 1; + if *count > MAX_NODES { + return Err(invalid(format!("more than {MAX_NODES} layers"))); + } + out.push(match child.name.as_str() { + "layer" => Node::Layer(Layer { + props: props(child)?, + x: int(child, "x")?, + y: int(child, "y")?, + src: child.attr("src").map(|s| s.trim().to_string()), + }), + "stack" => Node::Stack(Stack { + props: props(child)?, + isolation: child.attr("isolation").map(|s| s.trim().to_string()), + children: stack(child, count)?, + }), + other => Node::Other { tag: other.to_string(), name: child.attr("name").map(str::to_string) }, + }); + } + Ok(out) +} + +impl Image { + /// Reads `stack.xml`. + pub fn parse(xml: &[u8]) -> Result { + if std::str::from_utf8(xml).is_err() { + return Err(invalid("stack.xml is not UTF-8")); + } + let options = craft_xml::Options { + doctype: craft_xml::Doctype::Reject, + limits: craft_xml::Limits { max_input: MAX_STACK_XML, max_depth: MAX_XML_DEPTH, ..craft_xml::Limits::DEFAULT }, + ..craft_xml::Options::local().strict() + }; + let image = options.parse(xml).map_err(|e| invalid(format!("stack.xml: {e}")))?; + if image.name != "image" { + return Err(invalid("stack.xml has no root")); + } + let side = |attr: &str| -> Result { + let v = num(&image, attr, 0.0)?; + if !(1.0..=f64::from(MAX_SIDE)).contains(&v) || v.fract() != 0.0 { + return Err(invalid(format!("image {attr} {v} must be a whole number of pixels from 1 to {MAX_SIDE}"))); + } + Ok(v as u32) + }; + let (width, height) = (side("w")?, side("h")?); + if (width as usize).saturating_mul(height as usize).saturating_mul(8) > MAX_PIXEL_BYTES { + return Err(invalid("the canvas exceeds the 1 GiB pixel limit")); + } + let root = image.child("stack").ok_or_else(|| invalid("stack.xml has no root "))?; + let nodes = stack(root, &mut 0)?; + let xres = num(&image, "xres", 72.0)?; + let yres = num(&image, "yres", xres)?; + Ok(Image { version: image.attr("version").map(str::to_string), width, height, xres, yres, stack: nodes }) + } + + /// Does the file's version make group isolation explicit? OpenRaster 0.0.6 defaults to + /// isolated groups and makes `isolation="auto"` pass-through whatever the group's + /// `composite-op`. Older files (and ones with no or an unrecognised version) leave it to the + /// reader, by convention: a group passes through unless it says `isolate` or has a + /// composite operation other than `svg:src-over`. The numeric core is compared, not the + /// version string. + pub fn explicit_group_isolation(&self) -> bool { + let Some(core) = self.version.as_deref().and_then(|v| v.trim().split(['-', '+']).next()) else { return false }; + let mut parts = core.split('.'); + let number = |part: Option<&str>| part.and_then(|s| s.parse::().ok()); + match (number(parts.next()), number(parts.next()), number(parts.next()), parts.next()) { + (Some(major), Some(minor), Some(patch), None) => [major, minor, patch] >= [0, 0, 6], + _ => false, + } + } + + /// `stack.xml` for this image: `version` (or [`VERSION`]), the canvas, the resolution, and + /// the layers and stacks with every attribute of [`Props`]. A stack with no `isolation` + /// is written `isolate`; [`Node::Other`] is left out. + pub fn to_xml(&self) -> String { + let mut xml = format!( + "\n", + craft_xml::esc_attr(self.version.as_deref().unwrap_or(VERSION)), + self.width, + self.height, + self.xres, + self.yres + ); + // Iterative, so a tree built by hand may be as deep as its builder made it. + let mut open: Vec> = vec![self.stack.iter()]; + while let Some(level) = open.last_mut() { + match level.next() { + Some(Node::Layer(l)) => { + xml.push_str("", l.x, l.y)); + } + Some(Node::Stack(s)) => { + xml.push_str("", craft_xml::esc_attr(s.isolation.as_deref().unwrap_or("isolate")))); + open.push(s.children.iter()); + } + Some(Node::Other { .. }) => {} + None => { + open.pop(); + xml.push_str(if open.is_empty() { "\n" } else { "" }); + } + } + } + xml + } +} + +fn push_props(xml: &mut String, p: &Props) { + if let Some(name) = &p.name { + xml.push_str(&format!(" name=\"{}\"", craft_xml::esc_attr(name))); + } + xml.push_str(&format!(" visibility=\"{}\" opacity=\"{}\"", if p.visible { "visible" } else { "hidden" }, p.opacity)); + xml.push_str(&format!(" composite-op=\"{}\"", craft_xml::esc_attr(p.composite_op.as_deref().unwrap_or("svg:src-over")))); + if p.edit_locked { + xml.push_str(" edit-locked=\"true\""); + } + if p.alpha_preserve { + xml.push_str(" alpha-preserve=\"true\""); + } +} + +/// An open OpenRaster archive: the parsed `stack.xml` and access to the archive's entries. +pub struct Archive<'a> { + zip: zip::ZipArchive>, + image: Image, +} + +impl<'a> Archive<'a> { + /// Opens the archive, checks its `mimetype` and reads `stack.xml`. + pub fn open(bytes: &'a [u8]) -> Result> { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| invalid(e.to_string()))?; + let mime = read(&mut zip, "mimetype", 256)?.ok_or_else(|| invalid("the archive has no mimetype"))?; + if mime.trim_ascii() != MIMETYPE { + return Err(invalid("the archive's mimetype is not image/openraster")); + } + let xml = read(&mut zip, "stack.xml", MAX_STACK_XML)?.ok_or_else(|| invalid("the archive has no stack.xml"))?; + Ok(Archive { zip, image: Image::parse(&xml)? }) + } + + /// The document's ``. + pub fn image(&self) -> &Image { + &self.image + } + + /// An entry by name (a layer's `src`, `mergedimage.png`, `Thumbnails/thumbnail.png`), + /// refusing one that inflates past `max` bytes ([`MAX_ENTRY`] for a layer image). `None` + /// when the archive has no such entry. + pub fn entry(&mut self, name: &str, max: usize) -> Result>> { + read(&mut self.zip, name, max) + } +} + +fn read(zip: &mut zip::ZipArchive>, name: &str, max: usize) -> Result>> { + let Some(index) = zip.index_for_name(name) else { return Ok(None) }; + let file = zip.by_index(index).map_err(|e| invalid(format!("{name}: {e}")))?; + let too_large = || invalid(format!("{name}: larger than {max} bytes")); + if file.size() > max as u64 { + return Err(too_large()); + } + // The declared size is the archive's claim; the read is what is bounded. + let mut out = Vec::new(); + file.take(max as u64 + 1).read_to_end(&mut out).map_err(|e| invalid(format!("{name}: {e}")))?; + if out.len() > max { + return Err(too_large()); + } + Ok(Some(out)) +} + +/// Writes an OpenRaster archive. Entries are stored (PNG is already compressed), in the order +/// they are added, after the `mimetype`. +pub struct Writer { + zip: zip::ZipWriter>>, + failed: Option, +} + +impl Default for Writer { + fn default() -> Writer { + Writer::new() + } +} + +impl Writer { + /// An archive holding the `mimetype`: it must be the first entry, stored, so the type is + /// readable at a fixed offset. + pub fn new() -> Writer { + let mut w = Writer { zip: zip::ZipWriter::new(Cursor::new(Vec::new())), failed: None }; + w.failed = w.add("mimetype", MIMETYPE).err(); + w + } + + /// Adds an entry: a layer image under the name its [`Layer::src`] gives, `mergedimage.png` + /// (the whole image, which the specification requires) or `Thumbnails/thumbnail.png` (at + /// most 256 pixels a side, also required). + pub fn add(&mut self, name: &str, data: &[u8]) -> Result<()> { + let stored = + zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(data.len() > u32::MAX as usize); + self.zip.start_file(name, stored).map_err(|e| Error::Write(format!("{name}: {e}")))?; + self.zip.write_all(data).map_err(|e| Error::Write(format!("{name}: {e}"))) + } + + /// Adds `stack.xml` for `image`. + pub fn add_stack(&mut self, image: &Image) -> Result<()> { + self.add("stack.xml", image.to_xml().as_bytes()) + } + + /// The finished archive. + pub fn finish(self) -> Result> { + if let Some(e) = self.failed { + return Err(e); + } + self.zip.finish().map(Cursor::into_inner).map_err(|e| Error::Write(e.to_string())) + } +} diff --git a/crates/ora/src/tests.rs b/crates/ora/src/tests.rs new file mode 100644 index 00000000..49008444 --- /dev/null +++ b/crates/ora/src/tests.rs @@ -0,0 +1,303 @@ +use super::*; + +fn zip_of(files: &[(&str, &[u8])], deflate: bool) -> Vec { + let mut z = zip::ZipWriter::new(Cursor::new(Vec::new())); + let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for (i, (name, data)) in files.iter().enumerate() { + z.start_file(*name, if deflate && i > 0 { deflated } else { stored }).unwrap(); + z.write_all(data).unwrap(); + } + z.finish().unwrap().into_inner() +} + +/// An ORA archive from a hand-written stack.xml and named entries. +fn archive(stack: &str, images: &[(&str, Vec)]) -> Vec { + let mut all: Vec<(&str, &[u8])> = vec![("mimetype", MIMETYPE), ("stack.xml", stack.as_bytes())]; + all.extend(images.iter().map(|(n, d)| (*n, d.as_slice()))); + zip_of(&all, false) +} + +fn image(stack: &str) -> Result { + Archive::open(&archive(stack, &[])).map(|a| a.image().clone()) +} + +fn layer(node: &Node) -> &Layer { + match node { + Node::Layer(l) => l, + other => panic!("not a layer: {other:?}"), + } +} + +fn group(node: &Node) -> &Stack { + match node { + Node::Stack(s) => s, + other => panic!("not a stack: {other:?}"), + } +} + +#[test] +fn reads_other_applications_stacks() { + // The shapes Krita, MyPaint and GIMP write: a root stack with attributes, `auto` + // isolation, svg:plus, krita: ids, unknown elements, fractional positions. + let xml = r#" + + + + + + + + hello + + + + +"#; + let bytes = archive(xml, &[("data/a.png", b"png a".to_vec()), ("data/b.png", b"png b".to_vec())]); + assert!(is_ora(&bytes)); + let mut a = Archive::open(&bytes).unwrap(); + let image = a.image().clone(); + assert_eq!((image.version.as_deref(), image.width, image.height, image.xres, image.yres), (Some("0.0.3"), 8, 6, 150.0, 151.0)); + assert!(!image.explicit_group_isolation()); + assert_eq!(image.stack.len(), 7, "topmost first, unknown elements kept in place"); + let plus = layer(&image.stack[0]); + assert_eq!((plus.props.name.as_deref(), plus.x, plus.y, plus.src.as_deref()), (Some("Plus"), 2, -2, Some("data/a.png"))); + assert_eq!((plus.props.composite_op.as_deref(), plus.props.opacity, plus.props.visible), (Some("svg:plus"), 0.5, true)); + assert_eq!(a.entry("data/a.png", MAX_ENTRY).unwrap().as_deref(), Some(&b"png a"[..])); + let folder = group(&image.stack[1]); + assert_eq!( + (folder.isolation.as_deref(), folder.props.composite_op.as_deref(), folder.props.opacity), + (Some("auto"), Some("krita:darker color"), 1.5) + ); + let inside = layer(&folder.children[0]); + assert!(!inside.props.visible && inside.props.alpha_preserve && !inside.props.edit_locked); + let through = group(&image.stack[2]); + assert_eq!( + (through.props.name.as_deref(), through.isolation.as_deref(), through.props.composite_op.as_deref()), + (Some(" Through "), None, None) + ); + assert_eq!(layer(&through.children[0]).props, Props::default()); + assert_eq!(image.stack[3], Node::Other { tag: "text".into(), name: Some("Caption".into()) }); + assert!(layer(&image.stack[4]).props.edit_locked); + let lost = layer(&image.stack[5]); + assert_eq!(a.entry(lost.src.as_deref().unwrap(), MAX_ENTRY).unwrap(), None, "a layer may name an entry the archive lacks"); + assert_eq!(layer(&image.stack[6]), &Layer::default()); + assert!(a.entry("data/b.png", 4).is_err(), "an entry past the caller's limit is refused"); +} + +#[test] +fn resolution_defaults_to_72_and_yres_to_xres() { + let i = image("").unwrap(); + assert_eq!((i.xres, i.yres, i.version), (72.0, 72.0, None)); + let i = image("").unwrap(); + assert_eq!((i.xres, i.yres), (300.0, 300.0)); +} + +#[test] +fn group_isolation_is_explicit_since_version_0_0_6() { + let explicit = |v: Option<&str>| { + Image { version: v.map(str::to_string), width: 1, height: 1, xres: 72.0, yres: 72.0, stack: vec![] }.explicit_group_isolation() + }; + for v in ["0.0.6", " 0.0.6 ", "0.0.7", "0.1.0", "1.0.0", "0.0.6-beta", "0.0.10+build"] { + assert!(explicit(Some(v)), "{v}"); + } + for v in ["0.0.5", "0.0.3", "0.0", "0.0.6.1", "six", "", "0.0.x"] { + assert!(!explicit(Some(v)), "{v}"); + } + assert!(!explicit(None)); +} + +#[test] +fn a_written_archive_reads_back_the_same() { + let p = |name: &str, op: Option<&str>| Props { name: Some(name.into()), composite_op: op.map(str::to_string), ..Props::default() }; + let image = Image { + version: None, + width: 70, + height: 50, + xres: 300.0, + yres: 300.0, + stack: vec![ + Node::Layer(Layer { + props: Props { opacity: 0.25, visible: false, ..p(" Top\t&\n<\"'> \u{1} ", Some("svg:multiply")) }, + x: -3, + y: 7, + src: Some("data/layer0.png".into()), + }), + Node::Stack(Stack { + props: Props { edit_locked: true, alpha_preserve: true, ..p("Pass <&> \"through\"", Some("krita:linear light")) }, + isolation: Some("auto".into()), + children: vec![ + Node::Layer(Layer { props: p("Inner", None), x: 0, y: 0, src: Some("data/layer1.png".into()) }), + Node::Stack(Stack::default()), + ], + }), + Node::Other { tag: "text".into(), name: None }, + ], + }; + let mut w = Writer::new(); + w.add("data/layer0.png", b"zero").unwrap(); + w.add("data/layer1.png", b"one").unwrap(); + w.add_stack(&image).unwrap(); + w.add("mergedimage.png", b"merged").unwrap(); + w.add("Thumbnails/thumbnail.png", b"thumb").unwrap(); + let bytes = w.finish().unwrap(); + // A stored `mimetype` first, readable at a fixed offset. + assert_eq!(bytes.get(30..38), Some(&b"mimetype"[..])); + assert_eq!(bytes.get(38..54), Some(MIMETYPE)); + assert!(is_ora(&bytes)); + let mut a = Archive::open(&bytes).unwrap(); + for (entry, data) in + [("data/layer0.png", "zero"), ("data/layer1.png", "one"), ("mergedimage.png", "merged"), ("Thumbnails/thumbnail.png", "thumb")] + { + assert_eq!(a.entry(entry, 16).unwrap().as_deref(), Some(data.as_bytes()), "{entry}"); + } + let xml = String::from_utf8(a.entry("stack.xml", 1 << 20).unwrap().unwrap()).unwrap(); + assert!( + xml.starts_with( + "\n"), + "{xml}" + ); + assert!(xml.contains("composite-op=\"krita:linear light\" edit-locked=\"true\" alpha-preserve=\"true\" isolation=\"auto\">"), "{xml}"); + assert!( + xml.ends_with( + "\n" + ), + "{xml}" + ); + // What comes back: the version and the defaults written out, the unknown element gone, and + // the control character XML cannot carry dropped from the name. Tab and newline survive. + let back = a.image(); + assert!(back.explicit_group_isolation()); + assert_eq!((back.version.as_deref(), back.width, back.height, back.xres, back.yres, back.stack.len()), (Some(VERSION), 70, 50, 300.0, 300.0, 2)); + let top = layer(&back.stack[0]); + assert_eq!(top.props.name.as_deref(), Some(" Top\t&\n<\"'> ")); + assert_eq!((top.props.opacity, top.props.visible, top.x, top.y), (0.25, false, -3, 7)); + let pass = group(&back.stack[1]); + assert_eq!((pass.props.name.as_deref(), pass.isolation.as_deref()), (Some("Pass <&> \"through\""), Some("auto"))); + assert_eq!(layer(&pass.children[0]).props.composite_op.as_deref(), Some("svg:src-over")); + assert_eq!(group(&pass.children[1]).isolation.as_deref(), Some("isolate")); + assert_eq!(back.to_xml(), xml, "writing what was read is stable"); +} + +#[test] +fn a_stack_built_ten_thousand_deep_is_written_without_recursion() { + let mut node = Stack::default(); + for _ in 0..10_000 { + node = Stack { children: vec![Node::Stack(node)], ..Stack::default() }; + } + let image = Image { version: None, width: 1, height: 1, xres: 72.0, yres: 72.0, stack: vec![Node::Stack(node)] }; + let xml = std::thread::Builder::new().stack_size(512 * 1024).spawn(move || { + let xml = image.to_xml(); + std::mem::forget(image); // dropping a hand-built tree recurses; that is the builder's concern + xml + }); + assert_eq!(xml.unwrap().join().unwrap().matches("").count(), 10_002); +} + +/// A stack.xml nested 20 000 deep is an error. The small stack makes a regression abort instead +/// of passing. +#[test] +fn deeply_nested_stack_xml_is_an_error_not_a_stack_overflow() { + let stack = format!("{}{}", "".repeat(20_000), "".repeat(20_000)); + let bytes = archive(&stack, &[]); + let e = std::thread::Builder::new().stack_size(512 * 1024).spawn(move || Archive::open(&bytes).err()).unwrap().join().unwrap(); + assert!(matches!(&e, Some(Error::Invalid(m)) if m.contains("deeper")), "{e:?}"); + let nested = |n: usize| format!("{}{}", "".repeat(n), "".repeat(n)); + let bytes = archive(&nested(MAX_XML_DEPTH - 1), &[]); + let depth = std::thread::Builder::new() + .stack_size(512 * 1024) + .spawn(move || { + let a = Archive::open(&bytes).unwrap(); + std::iter::successors(a.image().stack.first(), |n| group(n).children.first()).count() + }) + .unwrap() + .join() + .unwrap(); + assert_eq!(depth, MAX_XML_DEPTH - 2); + assert!(image(&nested(MAX_XML_DEPTH)).is_err()); +} + +#[test] +fn malformed_archives_fail_cleanly() { + let cases: Vec<(&str, Vec)> = vec![ + ("not a zip", b"definitely not a zip archive at all".to_vec()), + ("no stack", zip_of(&[("mimetype", MIMETYPE)], false)), + ("no mimetype", zip_of(&[("stack.xml", b"")], false)), + ("wrong mimetype", zip_of(&[("mimetype", b"image/png"), ("stack.xml", b"")], false)), + ("bad xml", archive("", &[])), + ("doctype", archive("", &[])), + ("wrong root", archive("", &[])), + ("no root stack", archive("", &[])), + ("zero width", archive("", &[])), + ("fractional width", archive("", &[])), + ("too tall", archive("", &[])), + ("huge canvas", archive("", &[])), + ("bad resolution", archive("", &[])), + ("bad opacity", archive("", &[])), + ("nan opacity", archive("", &[])), + ("far offset", archive("", &[])), + ("just too far", archive("", &[])), + ("not utf-8", archive("", &[]).iter().map(|b| if *b == 0 { 0xff } else { *b }).collect()), + ("too many", archive(&format!("{}", "".repeat(MAX_NODES + 1)), &[])), + ]; + for (what, bytes) in cases { + assert!(matches!(Archive::open(&bytes), Err(Error::Invalid(_))), "{what}"); + } + assert!(image(&format!("{}", "".repeat(MAX_NODES))).is_ok()); + assert!(image("").is_ok()); +} + +#[test] +fn deflated_entries_are_read_under_the_limit() { + let big = vec![b' '; MAX_STACK_XML + 1]; + let bytes = zip_of(&[("mimetype", MIMETYPE), ("stack.xml", &big)], true); + assert!(bytes.len() < 1 << 20); + assert_eq!(Archive::open(&bytes).err(), Some(Error::Invalid(format!("stack.xml: larger than {MAX_STACK_XML} bytes")))); + let bytes = zip_of(&[("mimetype", MIMETYPE), ("stack.xml", b""), ("a.png", &[9; 70_000])], true); + let mut a = Archive::open(&bytes).unwrap(); + assert_eq!(a.entry("a.png", 70_000).unwrap(), Some(vec![9; 70_000])); + assert!(a.entry("a.png", 69_999).is_err()); +} + +#[test] +fn truncated_and_corrupted_files_never_panic() { + let xml = ""; + let bytes = zip_of(&[("mimetype", MIMETYPE), ("stack.xml", xml.as_bytes()), ("a.png", &[1; 300]), ("b.png", &[2; 300])], true); + let read_all = |bytes: &[u8]| -> Result { + let mut a = Archive::open(bytes)?; + Ok(a.entry("a.png", MAX_ENTRY)?.map_or(0, |d| d.len()) + a.entry("b.png", MAX_ENTRY)?.map_or(0, |d| d.len())) + }; + assert_eq!(read_all(&bytes), Ok(600)); + for cut in 0..bytes.len() { + let _ = read_all(bytes.get(..cut).unwrap_or_default()); + } + let mut state = 0x9e37_79b9_u32; + for _ in 0..300 { + let mut b = bytes.clone(); + for _ in 0..4 { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + let i = state as usize % b.len(); + b[i] ^= (state >> 24) as u8 | 1; + } + let _ = read_all(&b); + } +} + +#[test] +fn zip_mimetype_reads_only_a_leading_stored_entry() { + assert_eq!(zip_mimetype(&archive("", &[])), Some(MIMETYPE)); + assert_eq!(zip_mimetype(b"PK\x03\x04"), None); + assert_eq!(zip_mimetype(&[]), None); + assert_eq!(zip_mimetype(&zip_of(&[("stack.xml", b"x"), ("mimetype", MIMETYPE)], false)), None); + assert!(!is_ora(&zip_of(&[("mimetype", b"application/x-krita")], false))); + assert_eq!(zip_mimetype(&zip_of(&[("mimetype", &[b'a'; 257])], false)), None); +}