diff --git a/Cargo.lock b/Cargo.lock index 799d1a665..9ca316800 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -958,6 +958,18 @@ dependencies = [ "zip", ] +[[package]] +name = "craft-fig" +version = "0.1.0" +dependencies = [ + "craft-kiwi", + "flate2", + "ruzstd", + "serde_json", + "thiserror 2.0.21", + "zip", +] + [[package]] name = "craft-font-catalog" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 03c47598d..485004f47 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,7 @@ craft-gainmap = { path = "crates/gainmap" } craft-gcode = { path = "crates/gcode" } craft-mesh = { path = "crates/mesh" } craft-heif = { path = "crates/heif" } +craft-fig = { path = "crates/fig" } craft-fonts-build = { path = "crates/fonts-build" } craft-i18n = { path = "crates/i18n" } craft-idml = { path = "crates/idml" } diff --git a/README.md b/README.md index 718c2ff78..024d28593 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` (the hash in hex, no extension). A bare `canvas.fig` is a file too: the + clipboard carries one. +- `canvas.fig` is an 8-byte magic (`fig-kiwi`; `fig-jam.` for a whiteboard), a little-endian + `u32` version, then length-prefixed chunks, each compressed with raw deflate, zlib or zstd. The + first chunk is a kiwi binary schema, the second a message encoded with it. +- The message's `nodeChanges` list the layers (each with its id, its parent's id and its position + among the siblings). Its `blobs` hold vector networks (`vectorData.vectorNetworkBlob`), outline + geometry (`fillGeometry[].commandsBlob`) and, in files written by plugins and other tools, the + bytes of images (`image.dataBlob`). + +## Reading + +```rust +let archive = craft_fig::Archive::open(&bytes)?; // a zip or a bare canvas +let packed = archive.canvas.len(); +let (schema, message) = craft_fig::decode_canvas(&archive.canvas)?; +let root = schema.find("Message").ok_or("no Message definition")?; +let msg = schema.decode_message_within(root, &message, craft_fig::value_budget(packed))?; +let embedded = craft_fig::embedded_copies(&msg, craft_fig::Limits::default().max_image); +let image = archive.image("0a1b…").or_else(|| embedded.get("0a1b…").map(|b| b.to_vec())); +let network = craft_fig::network::decode(blob_bytes); // Option +let outline = craft_fig::path::decode(blob_bytes); // Option> +``` + +## Writing + +```rust +let canvas = craft_fig::encode_canvas(&kiwi::encode::schema(&schema), &kiwi::encode::message(&schema, root, &msg)?)?; +let bytes = craft_fig::write_archive(&craft_fig::Package { + file_name: "Untitled", background: [1.0, 1.0, 1.0, 1.0], canvas: &canvas, images: &images, thumbnail: None, +})?; +``` + +## Limits + +Input is hostile. Nothing panics; every limit has a test. + +| Limit | Value | +|---|---| +| `canvas.fig` read or written (`MAX_CANVAS`) | 256 MiB | +| A decompressed chunk (`MAX_CHUNK`) | 512 MiB | +| The decompressed schema chunk (`MAX_SCHEMA`) | 16 MiB | +| Chunks read (`MAX_CHUNKS`) | 16 | +| `meta.json` | 1 MiB | +| Values decoded from the message (`value_budget`) | 4 per compressed canvas byte, at least 20 million, at most 128 million (32 million on 32-bit targets) | +| An image (`Limits::max_image`) | 256 MiB | +| Image bytes read in all, failed reads included (`Limits::max_image_total`) | 4 GiB (1 GiB on 32-bit targets) | +| An image hash (`MAX_IMAGE_HASH`) | 64 bytes | +| Vertices, segments, regions or loop entries in a vector network (`network::MAX_ITEMS`) | 200 000 | +| Commands in a geometry blob (`path::MAX_COMMANDS`) | 200 000 | +| A written file (canvas and images) | 2 GiB | + +The value budget follows the compressed size, which the file itself bounds, not what it unpacks +to: a small file can't claim a large tree however far its chunks inflate. + +## ZIP + +The archive is read with the `zip` crate directly, with the bounds above applied to each member. +A shared bounded ZIP reader is planned (craft-libs #126); this crate moves to it when it lands. + +## Not here yet + +A typed model of the message (nodes, paints, text, components, variables, prototypes). FigCraft +maps the `Kv` tree straight onto its own document today; a model mirroring the file's schema is +the next step. diff --git a/crates/fig/src/archive.rs b/crates/fig/src/archive.rs new file mode 100644 index 000000000..a62ee6d30 --- /dev/null +++ b/crates/fig/src/archive.rs @@ -0,0 +1,219 @@ +//! The zip around `canvas.fig`. + +use std::cell::Cell; +use std::io::{Cursor, Read, Seek, Write}; + +use zip::write::SimpleFileOptions; + +use crate::canvas::MAX_CANVAS; +use crate::images::MAX_IMAGE_HASH; +use crate::{Error, corrupt}; + +/// Largest `meta.json` read. +const MAX_META: u64 = 1 << 20; +/// Largest file [`write_archive`] writes (the canvas and the images together). +const MAX_FILE: u64 = 2 * 1024 * 1024 * 1024; + +/// What one open may read from `images/`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + /// Largest image member read. + pub max_image: u64, + /// Total decompressed image bytes read, damaged images included. + pub max_image_total: u64, +} + +impl Default for Limits { + /// 256 MiB an image; 4 GiB in all, 1 GiB on 32-bit targets (to leave address space for the + /// canvas, the layers and decoded pixels). + fn default() -> Self { + #[cfg(not(target_pointer_width = "32"))] + let max_image_total = 4 * 1024 * 1024 * 1024; + #[cfg(target_pointer_width = "32")] + let max_image_total = 1024 * 1024 * 1024; + Limits { max_image: 256 * 1024 * 1024, max_image_total } + } +} + +/// Whether `bytes` look like a `.fig` file: a zip holding `canvas.fig`, or a bare canvas. +pub fn is_fig(bytes: &[u8]) -> bool { + bytes.starts_with(b"fig-") + || (bytes.starts_with(b"PK") && zip::ZipArchive::new(Cursor::new(bytes)).is_ok_and(|z| z.index_for_name("canvas.fig").is_some())) +} + +/// The parts of a `.fig` archive (or of a bare canvas, which has no images). +pub struct Archive<'b> { + /// `canvas.fig`, still chunked and compressed ([`crate::decode_canvas`] reads it). Public so + /// that a reader can take it out and drop it once decoded: in a large file every stage takes + /// gigabytes. + pub canvas: Vec, + /// `meta.json`'s `file_name`. + pub file_name: Option, + /// `images/` members (or nothing for a bare canvas). + zip: Option>>, + max_image: u64, + /// Bytes left for image reads. A reader that caches what it resolved, failures included, + /// reads or counts each referenced member only once. + remaining: Cell, + skipped: Cell, +} + +impl<'b> Archive<'b> { + /// Open `bytes` with the default [`Limits`]. + pub fn open(bytes: &'b [u8]) -> Result { + Self::open_with(bytes, Limits::default()) + } + + pub fn open_with(bytes: &'b [u8], limits: Limits) -> Result { + let (max_image, remaining, skipped) = (limits.max_image, Cell::new(limits.max_image_total), Cell::new(0)); + if bytes.starts_with(b"fig-") { + return Ok(Archive { canvas: bytes.to_vec(), file_name: None, zip: None, max_image, remaining, skipped }); + } + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?; + let canvas = read_member(&mut z, "canvas.fig", MAX_CANVAS)?; + let file_name = read_member(&mut z, "meta.json", MAX_META) + .ok() + .and_then(|m| serde_json::from_slice::(&m).ok()) + .and_then(|m| m.get("file_name").and_then(serde_json::Value::as_str).map(str::to_string)); + Ok(Archive { canvas, file_name, zip: Some(z), max_image, remaining, skipped }) + } + + /// Whether the canvas has FigJam's header: the file is a whiteboard (a `.jam`). + pub fn is_board(&self) -> bool { + self.canvas.starts_with(crate::JAM_HEADER) + } + + /// Whether `images/` has a member for image `hash`. + pub fn has_image(&self, hash: &str) -> bool { + self.zip.as_ref().is_some_and(|z| z.index_for_name(&format!("images/{hash}")).is_some()) + } + + /// What is left of the total image budget ([`Limits::max_image_total`]). + pub fn image_bytes_left(&self) -> u64 { + self.remaining.get() + } + + /// Images left out because the total image budget ([`Limits::max_image_total`]) was spent. + pub fn images_skipped(&self) -> usize { + self.skipped.get() + } + + /// Bytes of image `hash` (hex) from `images/`. `None` when there is no such member, it is + /// larger than [`Limits::max_image`], damaged, or past what is left of the total budget (then + /// it counts in [`Self::images_skipped`]). + pub fn image(&self, hash: &str) -> Option> { + if hash.is_empty() || hash.len() > 2 * MAX_IMAGE_HASH || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + // A clone shares the parsed directory and the bytes; the resolver only has shared access. + let mut z = self.zip.clone()?; + let f = z.by_name(&format!("images/{hash}")).ok()?; + let size = f.size(); + if size > self.max_image { + return None; + } + let remaining = self.remaining.get(); + if size > remaining { + self.skipped.set(self.skipped.get() + 1); + return None; + } + let checksum = f.crc32(); + let mut data = Vec::new(); + // Probe past the per-image cap only when the total budget has room. Even a dishonest + // size cannot make the read exceed the remaining total; failed reads keep their charge. + let limit = remaining.min(self.max_image.saturating_add(1)); + let read = f.take(limit).read_to_end(&mut data); + let used = data.len() as u64; + self.remaining.set(remaining - used); + if used > size && used == remaining { + self.skipped.set(self.skipped.get() + 1); + } + if read.is_err() || used != size { + return None; + } + // `take` can stop exactly at the budget without asking ZIP for EOF (where it checks CRC). + // Check it here too, without spending an extra byte on an overflow probe. + if used == limit { + let mut crc = flate2::Crc::new(); + crc.update(&data); + if crc.sum() != checksum { + return None; + } + } + Some(data) + } +} + +/// Member `name` of `z`, refused when it is (or unpacks to) more than `limit` bytes. +fn read_member(z: &mut zip::ZipArchive, name: &str, limit: u64) -> Result, Error> { + let f = z.by_name(name).map_err(|e| corrupt(format!("{name}: {e}")))?; + let too_large = || corrupt(format!("{name} is too large")); + if f.size() > limit { + return Err(too_large()); + } + let mut data = Vec::new(); + f.take(limit.saturating_add(1)).read_to_end(&mut data).map_err(|e| corrupt(format!("{name}: {e}")))?; + if data.len() as u64 > limit { + return Err(too_large()); + } + Ok(data) +} + +/// What [`write_archive`] puts in a `.fig` file. +#[derive(Clone, Copy, Debug)] +pub struct Package<'a> { + /// `meta.json`'s `file_name`. + pub file_name: &'a str, + /// `meta.json`'s `client_meta.background_color`: red, green, blue, alpha, each 0–1. + pub background: [f64; 4], + /// `canvas.fig` ([`crate::encode_canvas`]). + pub canvas: &'a [u8], + /// `images/` members: the name (the image's hash in hex) and the bytes. + pub images: &'a [(&'a str, &'a [u8])], + /// `thumbnail.png`; a PNG's size goes into `client_meta.thumbnail_size`. + pub thumbnail: Option<&'a [u8]>, +} + +/// The `.fig` zip of `package`: `canvas.fig`, `thumbnail.png`, `meta.json`, then the images +/// (stored, as they are compressed already; `meta.json` deflated). +pub fn write_archive(package: &Package) -> Result, Error> { + let io = |e: &dyn std::fmt::Display| Error::Io(e.to_string()); + let mut buf = Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + let deflated = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated).compression_level(Some(3)); + let mut put = |name: &str, options: SimpleFileOptions, bytes: &[u8]| -> Result<(), Error> { + z.start_file(name, options).map_err(|e| io(&e))?; + z.write_all(bytes).map_err(|e| io(&e)) + }; + put("canvas.fig", stored, package.canvas)?; + if let Some(t) = package.thumbnail { + put("thumbnail.png", stored, t)?; + } + let [r, g, b, a] = package.background; + let mut client = serde_json::json!({"background_color": {"r": r, "g": g, "b": b, "a": a}}); + // A PNG says its size right after its signature and header chunk name. + let be = |at: usize| package.thumbnail.and_then(|t| t.get(at..at + 4)).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); + if let (Some(w), Some(h)) = (be(16), be(20)) + && let Some(client) = client.as_object_mut() + { + client.insert("thumbnail_size".into(), serde_json::json!({"width": w, "height": h})); + } + let meta = serde_json::to_vec(&serde_json::json!({"client_meta": client, "file_name": package.file_name})).map_err(|e| io(&e))?; + put("meta.json", deflated, &meta)?; + let mut total = package.canvas.len() as u64; + for (name, bytes) in package.images { + total = total.saturating_add(bytes.len() as u64); + if total > MAX_FILE { + return Err(Error::TooLarge); + } + if name.is_empty() || !name.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(Error::Io("an image is named by its hash in hex".into())); + } + put(&format!("images/{name}"), stored, bytes)?; + } + z.finish().map_err(|e| io(&e))?; + } + Ok(buf.into_inner()) +} diff --git a/crates/fig/src/canvas.rs b/crates/fig/src/canvas.rs new file mode 100644 index 000000000..7302f1c89 --- /dev/null +++ b/crates/fig/src/canvas.rs @@ -0,0 +1,110 @@ +//! `canvas.fig`: the header, the chunks and their compression. + +use std::io::{Read, Write}; + +use craft_kiwi::Schema; + +use crate::{Error, corrupt}; + +/// The canvas header of a design file. +pub const DESIGN_HEADER: &[u8] = b"fig-kiwi"; +/// The canvas header of a FigJam whiteboard (a `.jam`); a `.fig` has [`DESIGN_HEADER`]. +pub const JAM_HEADER: &[u8] = b"fig-jam."; +/// The version [`encode_canvas`] writes after the header. +pub const VERSION: u32 = 101; +/// Largest `canvas.fig` read from an archive or written. +pub const MAX_CANVAS: u64 = 256 * 1024 * 1024; +/// Largest decompressed chunk. +pub const MAX_CHUNK: u64 = 512 * 1024 * 1024; +/// Largest decompressed schema chunk: real schemas unpack to well under 1 MB, so a schema that +/// inflates past this is refused before its definitions are decoded. +pub const MAX_SCHEMA: u64 = 16 * 1024 * 1024; +/// Most chunks read from `canvas.fig`. +pub const MAX_CHUNKS: usize = 16; + +/// Value budget per compressed canvas byte. Real files hold up to 0.75 values per compressed byte +/// without their single scalar fields; counting those too adds 15–43% on generated files and up +/// to 3.2 times on a rectangle carrying only geometry and paint, so a dense file may reach about +/// 2.4 per byte. Four leaves two-thirds more room than that. The trade-off: from 32 MiB of canvas +/// (8 MiB on 32-bit targets) a file gets all of the ceiling, and above the floor it may decode +/// four values for every byte it carries. +const VALUES_PER_PACKED_BYTE: usize = 4; +/// 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is lower: past it +/// the file is refused cleanly instead of running out of memory. +#[cfg(not(target_pointer_width = "32"))] +const MAX_VALUES: usize = 128 << 20; +#[cfg(target_pointer_width = "32")] +const MAX_VALUES: usize = 32 << 20; + +/// Most values to decode ([`Schema::decode_message_within`]) from a file whose compressed canvas +/// is `packed` bytes: at least [`craft_kiwi::DEFAULT_MAX_VALUES`], four per byte, and at most 128 +/// million (32 million on 32-bit targets). A large real file decodes while a small one can't +/// expand into a huge tree, however far its chunks inflate. +pub fn value_budget(packed: usize) -> usize { + packed.saturating_mul(VALUES_PER_PACKED_BYTE).clamp(craft_kiwi::DEFAULT_MAX_VALUES, MAX_VALUES) +} + +/// The schema and the raw (decompressed) message of a `canvas.fig`. +pub fn decode_canvas(canvas: &[u8]) -> Result<(Schema, Vec), Error> { + let chunks = chunks(canvas)?; + let mut it = chunks.into_iter(); + let (Some(schema), Some(message)) = (it.next(), it.next()) else { + return Err(corrupt("canvas.fig needs a schema and a message chunk")); + }; + let schema = Schema::decode(&inflate(schema, MAX_SCHEMA)?).map_err(|e| corrupt(e.to_string()))?; + Ok((schema, inflate(message, MAX_CHUNK)?)) +} + +/// The length-prefixed chunks after the 12-byte header (at most [`MAX_CHUNKS`]), still compressed. +pub fn chunks(canvas: &[u8]) -> Result, Error> { + if canvas.len() < 12 || !canvas.starts_with(b"fig-") { + return Err(corrupt("canvas.fig has no header")); + } + let mut out = Vec::new(); + let mut pos = 12usize; + while pos < canvas.len() && out.len() < MAX_CHUNKS { + let start = pos.checked_add(4).ok_or_else(|| corrupt("truncated chunk"))?; + let len = canvas.get(pos..start).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes).ok_or_else(|| corrupt("truncated chunk"))?; + let end = start.checked_add(len as usize).filter(|e| *e <= canvas.len()).ok_or_else(|| corrupt("truncated chunk"))?; + out.push(canvas.get(start..end).unwrap_or_default()); + pos = end; + } + Ok(out) +} + +/// Decompress a chunk: zstd (by its magic number), else raw deflate, else zlib. A chunk that +/// unpacks to more than `max` bytes is refused. +pub fn inflate(data: &[u8], max: u64) -> Result, Error> { + let capped = |r: &mut dyn Read| -> Option> { + let mut out = Vec::new(); + r.take(max.saturating_add(1)).read_to_end(&mut out).ok()?; + (out.len() as u64 <= max).then_some(out) + }; + if data.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) { + let mut src = data; + let mut dec = ruzstd::decoding::StreamingDecoder::new(&mut src).map_err(|e| corrupt(format!("zstd: {e}")))?; + return capped(&mut dec).ok_or_else(|| corrupt("damaged or oversized zstd chunk")); + } + capped(&mut flate2::read::DeflateDecoder::new(data)) + .or_else(|| capped(&mut flate2::read::ZlibDecoder::new(data))) + .ok_or_else(|| corrupt("damaged or oversized deflate chunk")) +} + +/// `canvas.fig` of a design file: [`DESIGN_HEADER`], [`VERSION`] and the two chunks (the encoded +/// schema and message), each raw-deflated after its length. +pub fn encode_canvas(schema: &[u8], message: &[u8]) -> Result, Error> { + let mut out = DESIGN_HEADER.to_vec(); + out.extend_from_slice(&VERSION.to_le_bytes()); + for chunk in [schema, message] { + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(chunk).map_err(|e| Error::Io(e.to_string()))?; + let c = z.finish().map_err(|e| Error::Io(e.to_string()))?; + let len = u32::try_from(c.len()).map_err(|_| Error::TooLarge)?; + out.extend_from_slice(&len.to_le_bytes()); + out.extend_from_slice(&c); + } + if out.len() as u64 > MAX_CANVAS { + return Err(Error::TooLarge); + } + Ok(out) +} diff --git a/crates/fig/src/images.rs b/crates/fig/src/images.rs new file mode 100644 index 000000000..352eaf214 --- /dev/null +++ b/crates/fig/src/images.rs @@ -0,0 +1,85 @@ +//! Images that travel inside the message instead of in `images/`. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use craft_kiwi::Kv; + +/// Longest image hash (bytes) an image is found by: `images/` names and embedded images both +/// stop at 64 bytes (128 hex digits). +pub const MAX_IMAGE_HASH: usize = 64; + +/// Image bytes that travel inside the message, by image hash (lowercase hex): a paint's +/// `image.dataBlob` indexes the message's `blobs`. Copies written by plugins and other tools embed +/// their images this way, and a bare canvas (what the clipboard carries) has no `images/`. The +/// walk is iterative (no recursion on nested data) and skips blobs larger than `max_image` bytes. +/// Returns the index of each hash's blob. +pub fn embedded_images(msg: &Kv, max_image: u64) -> BTreeMap { + let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default(); + let mut found = BTreeMap::new(); + let mut pending = vec![msg]; + while let Some(value) = pending.pop() { + match value { + Kv::Object(fields) => { + if let Some((hash, index)) = value.get("image").and_then(|image| embedded_image(image, blobs, max_image)) { + found.entry(hash).or_insert(index); + } + pending.extend(fields.iter().filter(|(name, _)| *name != "blobs").map(|(_, field)| field)); + } + Kv::Array(items) => pending.extend(items), + _ => {} + } + } + found +} + +/// The images that travel inside the message ([`embedded_images`]), copied out of it once per +/// blob: every hash naming the same blob shares its copy. A file can point any number of hashes +/// at one large blob, and a copy for each would multiply it. +pub fn embedded_copies(msg: &Kv, max_image: u64) -> BTreeMap> { + let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default(); + let mut copies: BTreeMap> = BTreeMap::new(); + let mut out = BTreeMap::new(); + for (hash, index) in embedded_images(msg, max_image) { + let Some(bytes) = blob(blobs, index) else { continue }; + out.insert(hash, Arc::clone(copies.entry(index).or_insert_with(|| Arc::from(bytes)))); + } + out +} + +/// Embedded images ([`embedded_copies`]) whose blob an earlier hash already names, each with that +/// hash. Hashes that also have an `images/` member (`in_zip`) keep their own name: the member is +/// read first. Pointing every alias at one name lets a reader copy and hash each blob once, +/// however many hashes a file points at it. +pub fn embedded_aliases(embedded: &BTreeMap>, in_zip: impl Fn(&str) -> bool) -> BTreeMap { + let mut first: BTreeMap = BTreeMap::new(); + let mut out = BTreeMap::new(); + for (hash, bytes) in embedded { + if in_zip(hash) { + continue; + } + // Hashes naming one blob share its copy, so the copy's address tells the blob. + let canonical = *first.entry(bytes.as_ptr() as usize).or_insert(hash); + if canonical != hash { + out.insert(hash.clone(), canonical.clone()); + } + } + out +} + +/// The hash and blob index of an `Image` value whose `dataBlob` points at a usable blob. +fn embedded_image(image: &Kv, blobs: &[Kv], max_image: u64) -> Option<(String, usize)> { + let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= MAX_IMAGE_HASH)?; + let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None }; + let index = usize::try_from(*index).ok()?; + let bytes = blob(blobs, index)?; + if bytes.is_empty() || bytes.len() as u64 > max_image { + return None; + } + Some((hash.iter().map(|b| format!("{b:02x}")).collect(), index)) +} + +/// The bytes of blob `index`. +fn blob<'k>(blobs: &'k [Kv], index: usize) -> Option<&'k [u8]> { + blobs.get(index)?.get("bytes").and_then(Kv::bytes) +} diff --git a/crates/fig/src/lib.rs b/crates/fig/src/lib.rs new file mode 100644 index 000000000..b35ea327a --- /dev/null +++ b/crates/fig/src/lib.rs @@ -0,0 +1,55 @@ +//! The `.fig` container (Figma Design and FigJam local copies), read and written from public +//! format knowledge only. +//! +//! A `.fig` file is a zip archive holding `canvas.fig` (the document), `meta.json`, a thumbnail +//! and `images/` files. `canvas.fig` (also accepted on its own: the clipboard carries a bare +//! canvas) is an 8-byte `fig-…` magic, a little-endian `u32` version and length-prefixed chunks, +//! each compressed with deflate or zstd: a binary schema in the kiwi format ([`kiwi`]), then a +//! message encoded with it. Every file carries its own schema, so the message is decoded +//! generically and read by field name: a list of node changes (each with its id, parent id and +//! position among its siblings) that make up the layer tree, plus blobs: vector outlines +//! ([`path`]), editable vector networks ([`network`]) and the bytes of images that travel inside +//! the message ([`embedded_images`]). +//! +//! This crate is the wire layer: the archive ([`Archive`], [`write_archive`]), the canvas +//! ([`decode_canvas`], [`encode_canvas`]) and the blobs. What the node changes mean is read from +//! the decoded [`kiwi::Kv`] tree by the caller. +//! +//! Everything here reads untrusted bytes: sizes, counts and totals are bounded, and malformed +//! input is an error, never a panic. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod archive; +mod canvas; +mod images; +pub mod network; +pub mod path; +#[cfg(test)] +mod tests; + +pub use craft_kiwi as kiwi; + +pub use archive::{Archive, Limits, Package, is_fig, write_archive}; +pub use canvas::{ + DESIGN_HEADER, JAM_HEADER, MAX_CANVAS, MAX_CHUNK, MAX_CHUNKS, MAX_SCHEMA, VERSION, chunks, decode_canvas, encode_canvas, inflate, value_budget, +}; +pub use images::{MAX_IMAGE_HASH, embedded_aliases, embedded_copies, embedded_images}; + +/// Why a `.fig` file could not be read or written. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// The bytes are not a readable `.fig` file. + #[error(".fig file: {0}")] + Corrupt(String), + /// What was handed to a writer does not fit the format's limits. + #[error("the document is too large for a .fig file")] + TooLarge, + /// Writing failed. + #[error("{0}")] + Io(String), +} + +pub(crate) fn corrupt(msg: impl Into) -> Error { + Error::Corrupt(msg.into()) +} diff --git a/crates/fig/src/network.rs b/crates/fig/src/network.rs new file mode 100644 index 000000000..fcfde1817 --- /dev/null +++ b/crates/fig/src/network.rs @@ -0,0 +1,139 @@ +//! Vector network blobs: the editable form of a vector layer (`vectorData.vectorNetworkBlob`). +//! +//! Little-endian throughout: `u32` counts of vertices, segments and regions; each vertex a style +//! id (`u32`) and its position (`f32` x, y); each segment a style id, its start vertex (`u32`) and +//! tangent (`f32` x, y), then its end vertex and tangent; each region a `u32` holding its style id +//! shifted left once with the winding rule in the low bit (set: non-zero, clear: even-odd), a loop +//! count, and per loop a segment count and the segment indices. Positions are in the space of +//! `vectorData.normalizedSize`. Style ids point into `vectorData.styleOverrideTable` (0: none). + +/// Most vertices, segments, regions or loop entries read from one blob. +pub const MAX_ITEMS: usize = 200_000; + +/// A point of the network. +#[derive(Clone, Debug, PartialEq)] +pub struct Vertex { + pub style: u32, + pub x: f64, + pub y: f64, +} + +/// A curve between vertices `a` and `b`, with the tangent at each end (relative to its vertex). +#[derive(Clone, Debug, PartialEq)] +pub struct Segment { + pub style: u32, + pub a: usize, + pub ta: (f64, f64), + pub b: usize, + pub tb: (f64, f64), +} + +/// A filled region: loops of segment indices. +#[derive(Clone, Debug, PartialEq)] +pub struct Region { + pub style: u32, + pub non_zero: bool, + pub loops: Vec>, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Network { + pub vertices: Vec, + pub segments: Vec, + pub regions: Vec, +} + +struct Reader<'b> { + b: &'b [u8], + pos: usize, +} + +impl Reader<'_> { + fn u32(&mut self) -> Option { + let v = u32::from_le_bytes(self.b.get(self.pos..self.pos.checked_add(4)?)?.try_into().ok()?); + self.pos += 4; + Some(v) + } + + fn f32(&mut self) -> Option { + Some(f64::from(f32::from_bits(self.u32()?))).filter(|x| x.is_finite()) + } + + /// An index below `len`. + fn index(&mut self, len: usize) -> Option { + Some(self.u32()? as usize).filter(|i| *i < len) + } + + /// A count of items of at least `size` bytes each that the rest of the blob can hold. + fn count(&mut self, size: usize) -> Option { + let n = self.u32()? as usize; + (n <= MAX_ITEMS && n * size <= self.b.len().saturating_sub(self.pos)).then_some(n) + } +} + +/// The network in blob `b`; `None` when it's truncated, out of range or not finite. +pub fn decode(b: &[u8]) -> Option { + let mut r = Reader { b, pos: 0 }; + let (nv, ns, nr) = (r.u32()? as usize, r.u32()? as usize, r.u32()? as usize); + if nv > MAX_ITEMS || ns > MAX_ITEMS || nr > MAX_ITEMS || nv * 12 + ns * 28 + nr * 8 > b.len().saturating_sub(12) { + return None; + } + let mut net = Network::default(); + for _ in 0..nv { + net.vertices.push(Vertex { style: r.u32()?, x: r.f32()?, y: r.f32()? }); + } + for _ in 0..ns { + let style = r.u32()?; + net.segments.push(Segment { style, a: r.index(nv)?, ta: (r.f32()?, r.f32()?), b: r.index(nv)?, tb: (r.f32()?, r.f32()?) }); + } + let mut entries = 0usize; + for _ in 0..nr { + let sw = r.u32()?; + let mut loops = Vec::new(); + for _ in 0..r.count(4)? { + let n = r.count(4)?; + entries += n; + if entries > MAX_ITEMS { + return None; + } + loops.push((0..n).map(|_| r.index(ns)).collect::>>()?); + } + net.regions.push(Region { style: sw >> 1, non_zero: sw & 1 == 1, loops }); + } + Some(net) +} + +/// The blob of `net`. Callers pass finite values and indices that fit `u32`. +pub fn encode(net: &Network) -> Vec { + let mut out = Vec::new(); + let u = |v: u32, out: &mut Vec| out.extend_from_slice(&v.to_le_bytes()); + let fl = |v: f64, out: &mut Vec| out.extend_from_slice(&(v as f32).to_le_bytes()); + for n in [net.vertices.len(), net.segments.len(), net.regions.len()] { + u(n as u32, &mut out); + } + for v in &net.vertices { + u(v.style, &mut out); + fl(v.x, &mut out); + fl(v.y, &mut out); + } + for s in &net.segments { + u(s.style, &mut out); + u(s.a as u32, &mut out); + fl(s.ta.0, &mut out); + fl(s.ta.1, &mut out); + u(s.b as u32, &mut out); + fl(s.tb.0, &mut out); + fl(s.tb.1, &mut out); + } + for r in &net.regions { + u((r.style << 1) | u32::from(r.non_zero), &mut out); + u(r.loops.len() as u32, &mut out); + for l in &r.loops { + u(l.len() as u32, &mut out); + for i in l { + u(*i as u32, &mut out); + } + } + } + out +} diff --git a/crates/fig/src/path.rs b/crates/fig/src/path.rs new file mode 100644 index 000000000..352cd918e --- /dev/null +++ b/crates/fig/src/path.rs @@ -0,0 +1,76 @@ +//! Geometry blobs: the outlines a layer draws (`fillGeometry` / `strokeGeometry`, each entry's +//! `commandsBlob`). A blob is a run of commands, each one byte (`0` close, `1` move, `2` line, +//! `3` quadratic, `4` cubic) followed by its points as little-endian `f32` pairs. + +/// Most commands read from one blob. +pub const MAX_COMMANDS: usize = 200_000; + +/// A point: x, y. +pub type Point = [f32; 2]; + +/// One path command, in the order the blob holds them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Command { + Close, + Move(Point), + Line(Point), + /// Control point, end point. + Quad(Point, Point), + /// Two control points, end point. + Cubic(Point, Point, Point), +} + +/// The commands of blob `b`; `None` when it's truncated, has an unknown command, a value that +/// isn't finite, or more than [`MAX_COMMANDS`] commands. +pub fn decode(b: &[u8]) -> Option> { + let mut out = Vec::new(); + let mut i = 0usize; + let point = |i: &mut usize| -> Option { + let mut p = [0f32; 2]; + for v in &mut p { + *v = f32::from_le_bytes(b.get(*i..i.checked_add(4)?)?.try_into().ok()?); + if !v.is_finite() { + return None; + } + *i += 4; + } + Some(p) + }; + while let Some(cmd) = b.get(i) { + if out.len() >= MAX_COMMANDS { + return None; + } + i += 1; + out.push(match cmd { + 0 => Command::Close, + 1 => Command::Move(point(&mut i)?), + 2 => Command::Line(point(&mut i)?), + 3 => Command::Quad(point(&mut i)?, point(&mut i)?), + 4 => Command::Cubic(point(&mut i)?, point(&mut i)?, point(&mut i)?), + _ => return None, + }); + } + Some(out) +} + +/// The blob of `commands`. Callers pass finite values. +pub fn encode(commands: &[Command]) -> Vec { + let mut out = Vec::new(); + let mut put = |cmd: u8, points: &[Point]| { + out.push(cmd); + for [x, y] in points { + out.extend_from_slice(&x.to_le_bytes()); + out.extend_from_slice(&y.to_le_bytes()); + } + }; + for c in commands { + match *c { + Command::Close => put(0, &[]), + Command::Move(a) => put(1, &[a]), + Command::Line(a) => put(2, &[a]), + Command::Quad(a, b) => put(3, &[a, b]), + Command::Cubic(a, b, c) => put(4, &[a, b, c]), + } + } + out +} diff --git a/crates/fig/src/tests.rs b/crates/fig/src/tests.rs new file mode 100644 index 000000000..ae31ac6ab --- /dev/null +++ b/crates/fig/src/tests.rs @@ -0,0 +1,525 @@ +//! Tests on files built in code: a small schema in the compact text form, a message encoded with +//! craft-kiwi, compressed (deflate, zlib and zstd), framed and zipped. +#![allow(clippy::unwrap_used, clippy::panic)] + +use std::io::Write; +use std::sync::Arc; + +use craft_kiwi::{Kv, Schema, encode}; + +use super::*; + +const SCHEMA: &str = " +struct GUID uint sessionID, uint localID +message Image byte[] hash, string name, uint dataBlob +message Paint Image image +message NodeChange GUID guid, string name, Paint[] fillPaints +message Blob byte[] bytes +message Message NodeChange[] nodeChanges, Blob[] blobs +"; + +fn schema() -> Schema { + Schema::parse(SCHEMA).unwrap() +} + +fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { + Kv::Object(fields) +} + +fn blob(bytes: Vec) -> Kv<'static> { + o(vec![("bytes", Kv::Bytes(bytes))]) +} + +/// A node filled with image `hash` (20 bytes of it), its bytes in blob `data_blob` when given. +fn image_node(hash: u8, data_blob: Option) -> Kv<'static> { + let mut image = vec![("hash", Kv::Bytes(vec![hash; 20]))]; + if let Some(i) = data_blob { + image.push(("dataBlob", Kv::Uint(i))); + } + o(vec![("name", Kv::Str("n".into())), ("fillPaints", Kv::Array(vec![o(vec![("image", o(image))])]))]) +} + +fn message() -> Kv<'static> { + o(vec![("nodeChanges", Kv::Array(vec![image_node(1, None), image_node(2, None)])), ("blobs", Kv::Array(vec![blob(vec![1, 2, 3])]))]) +} + +/// `canvas.fig`: magic, version, deflated schema, zstd message. +fn canvas(schema: &Schema, msg: &Kv) -> Vec { + let root = schema.find("Message").unwrap(); + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(&encode::schema(schema)).unwrap(); + let schema_chunk = z.finish().unwrap(); + let msg_chunk = ruzstd::encoding::compress_to_vec(&encode::message(schema, root, msg).unwrap()[..], ruzstd::encoding::CompressionLevel::Fastest); + let mut out = b"fig-kiwi".to_vec(); + out.extend_from_slice(&101u32.to_le_bytes()); + for c in [schema_chunk, msg_chunk] { + out.extend_from_slice(&(c.len() as u32).to_le_bytes()); + out.extend_from_slice(&c); + } + out +} + +fn name(hash: u8) -> String { + format!("{hash:02x}").repeat(20) +} + +/// Bytes that don't compress to nothing, different for each `hash`. +fn image(hash: u8) -> Vec { + let mut r = Lcg(u64::from(hash)); + (0..70).map(|_| r.next() as u8).collect() +} + +/// A `.fig` zip: the canvas, `meta.json` and deflated `images/` members. +fn file(members: &[(u8, Vec)]) -> Vec { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + z.start_file("canvas.fig", deflated).unwrap(); + z.write_all(&canvas(&schema(), &message())).unwrap(); + z.start_file("meta.json", deflated).unwrap(); + z.write_all(br#"{"file_name":"Fixture"}"#).unwrap(); + for (hash, bytes) in members { + z.start_file(format!("images/{}", name(*hash)), deflated).unwrap(); + z.write_all(bytes).unwrap(); + } + z.finish().unwrap().into_inner() +} + +fn open(zip: &[u8], max_image_total: u64) -> Archive<'_> { + Archive::open_with(zip, Limits { max_image_total, ..Limits::default() }).unwrap() +} + +/// Overwrite a 32-bit field of `hash`'s central directory record (16: CRC, 20: compressed size, +/// 24: size). +fn change_member_field(zip: &mut [u8], hash: u8, offset: usize, value: u32) { + let start = { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&*zip)).unwrap(); + z.by_name(&format!("images/{}", name(hash))).unwrap().central_header_start() as usize + }; + zip[start + offset..start + offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +struct Lcg(u64); +impl Lcg { + fn next(&mut self) -> u64 { + self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + self.0 >> 33 + } +} + +#[test] +fn an_archive_opens_and_its_canvas_decodes() { + let members = [(1, image(1)), (2, image(2))]; + let zip = file(&members); + assert!(is_fig(&zip)); + let archive = Archive::open(&zip).unwrap(); + assert_eq!(archive.file_name.as_deref(), Some("Fixture")); + assert!(!archive.is_board()); + let (sc, msg) = decode_canvas(&archive.canvas).unwrap(); + let root = sc.find("Message").unwrap(); + assert_eq!(sc.decode_message_within(root, &msg, value_budget(archive.canvas.len())).unwrap(), message()); + assert!(archive.has_image(&name(1)) && !archive.has_image(&name(9))); + assert_eq!(archive.image(&name(1)), Some(members[0].1.clone())); + assert_eq!(archive.image(&name(2)), Some(members[1].1.clone())); + // Names that aren't a hash never reach the archive. + for bad in ["", "../canvas.fig", "zz", &"0".repeat(2 * MAX_IMAGE_HASH + 1)] { + assert_eq!(archive.image(bad), None, "{bad}"); + } + assert_eq!(archive.images_skipped(), 0); +} + +#[test] +fn bare_canvas_and_zlib_chunks_open() { + let sc = schema(); + let c = canvas(&sc, &message()); + assert!(is_fig(&c)); + let archive = Archive::open(&c).unwrap(); + assert_eq!((archive.file_name.clone(), archive.has_image(&name(1)), archive.image(&name(1))), (None, false, None)); + assert!(decode_canvas(&archive.canvas).is_ok()); + // A zlib-wrapped schema chunk reads too. + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(&encode::schema(&sc)).unwrap(); + let zl = z.finish().unwrap(); + let parts = chunks(&c).unwrap(); + let mut out = c[..12].to_vec(); + for ch in [zl.as_slice(), parts[1]] { + out.extend_from_slice(&(ch.len() as u32).to_le_bytes()); + out.extend_from_slice(ch); + } + assert!(decode_canvas(&out).is_ok()); + // A whiteboard's header. + let mut jam = c.clone(); + jam[..8].copy_from_slice(JAM_HEADER); + assert!(Archive::open(&jam).unwrap().is_board()); + assert!(decode_canvas(&jam).is_ok()); +} + +#[test] +fn a_written_file_reads_back() { + let sc = schema(); + let root = sc.find("Message").unwrap(); + let c = encode_canvas(&encode::schema(&sc), &encode::message(&sc, root, &message()).unwrap()).unwrap(); + assert!(c.starts_with(DESIGN_HEADER)); + assert_eq!(c[8..12], VERSION.to_le_bytes()); + // A PNG's signature, IHDR length and name, then width 7 and height 5. + let mut png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec(); + png.extend_from_slice(&[0, 0, 0, 7, 0, 0, 0, 5]); + let (a, b) = (name(1), name(2)); + let images = [(a.as_str(), &[1u8, 2, 3][..]), (b.as_str(), &[4u8][..])]; + let package = Package { file_name: "Written", background: [1.0, 0.5, 0.0, 1.0], canvas: &c, images: &images, thumbnail: Some(&png) }; + let zip = write_archive(&package).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&zip[..])).unwrap(); + let names: Vec = (0..z.len()).map(|i| z.by_index(i).unwrap().name().unwrap().to_string()).collect(); + assert_eq!(names, ["canvas.fig".to_string(), "thumbnail.png".into(), "meta.json".into(), format!("images/{a}"), format!("images/{b}")]); + let mut meta = String::new(); + std::io::Read::read_to_string(&mut z.by_name("meta.json").unwrap(), &mut meta).unwrap(); + let meta: serde_json::Value = serde_json::from_str(&meta).unwrap(); + assert_eq!( + meta, + serde_json::json!({"client_meta": {"background_color": {"r": 1.0, "g": 0.5, "b": 0.0, "a": 1.0}, "thumbnail_size": {"width": 7, "height": 5}}, "file_name": "Written"}) + ); + let archive = Archive::open(&zip).unwrap(); + assert_eq!(archive.file_name.as_deref(), Some("Written")); + assert_eq!(archive.image(&a), Some(vec![1, 2, 3])); + let (sc2, msg) = decode_canvas(&archive.canvas).unwrap(); + assert_eq!(sc2.decode_message(sc2.find("Message").unwrap(), &msg).unwrap(), message()); + // No thumbnail, or one that isn't a PNG header: no size. An image not named by a hash is refused. + for thumbnail in [None, Some(&b"short"[..])] { + let zip = write_archive(&Package { thumbnail, images: &[], ..package }).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&zip[..])).unwrap(); + let meta: serde_json::Value = serde_json::from_reader(z.by_name("meta.json").unwrap()).unwrap(); + assert!(meta["client_meta"]["thumbnail_size"].is_null()); + } + assert!(write_archive(&Package { images: &[("../x", &[1u8][..])], ..package }).is_err()); +} + +#[test] +fn other_files_are_not_fig() { + assert!(!is_fig(b"{\"document\": {}}")); + assert!(!is_fig(b"")); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + z.start_file("document.json", zip::write::SimpleFileOptions::default()).unwrap(); + z.write_all(b"{}").unwrap(); + let other = z.finish().unwrap().into_inner(); + assert!(!is_fig(&other)); + assert!(Archive::open(&other).is_err()); +} + +#[test] +fn the_canvas_limits_hold() { + let c = canvas(&schema(), &message()); + // No header, a cut length, a length past the end. + assert!(chunks(b"fig-kiwi").is_err()); + assert!(chunks(b"PK\x03\x04aaaaaaaaaaaa").is_err()); + assert!(chunks(&c[..14]).is_err()); + assert!(chunks(&c[..c.len() - 1]).is_err()); + // One chunk is not a canvas. + let first = chunks(&c).unwrap()[0].len(); + assert!(decode_canvas(&c[..12 + 4 + first]).is_err()); + // Chunks past the sixteenth are not read. + let mut many = c[..12].to_vec(); + for _ in 0..MAX_CHUNKS + 4 { + many.extend_from_slice(&0u32.to_le_bytes()); + } + assert_eq!(chunks(&many).unwrap().len(), MAX_CHUNKS); + // A chunk that unpacks past its cap is refused, deflate and zstd alike; at the cap it reads. + let zeros = vec![0u8; 100_000]; + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(&zeros).unwrap(); + let deflated = z.finish().unwrap(); + let zstd = ruzstd::encoding::compress_to_vec(&zeros[..], ruzstd::encoding::CompressionLevel::Fastest); + for packed in [&deflated, &zstd] { + assert_eq!(inflate(packed, 100_000).unwrap().len(), 100_000); + assert!(inflate(packed, 99_999).is_err()); + } + assert!(inflate(&[0x28, 0xB5, 0x2F, 0xFD, 1, 2, 3], 1 << 20).is_err()); + assert!(inflate(b"\xff\xff\xff\xff", 1 << 20).is_err()); + // A schema chunk past MAX_SCHEMA is refused before it is decoded. + let big = vec![0u8; MAX_SCHEMA as usize + 1]; + let mut z = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast()); + z.write_all(&big).unwrap(); + let packed = z.finish().unwrap(); + let mut bomb = c[..12].to_vec(); + for ch in [packed.as_slice(), chunks(&c).unwrap()[1]] { + bomb.extend_from_slice(&(ch.len() as u32).to_le_bytes()); + bomb.extend_from_slice(ch); + } + assert_eq!(decode_canvas(&bomb).unwrap_err(), Error::Corrupt("damaged or oversized deflate chunk".into())); +} + +#[test] +fn large_files_get_a_larger_value_budget() { + // Small files keep the fixed floor: a few bytes can't expand into a huge tree. + assert_eq!(value_budget(0), 20_000_000); + assert_eq!(value_budget(1 << 20), 20_000_000); + // A real 400 000-layer file: an 83.5 MB canvas whose message holds 30.5 million values. + assert!(value_budget(83_491_232) > 30_514_899); + // A real 194 MB design-system file: a 39.5 MB canvas holding 29.7 million values. + assert!(value_budget(39_528_420) > 29_703_730); + // Still bounded by the largest canvas read. + assert!(value_budget(MAX_CANVAS as usize) <= 128 << 20); + // A dense file may reach about 2.4 values per compressed byte: below the ceiling the budget + // covers that, the floor still holds up to 5 MB of canvas, and 32 MiB is enough for the ceiling. + assert!(value_budget(8 << 20) >= (8 << 20) * 12 / 5); + #[cfg(not(target_pointer_width = "32"))] + assert!(value_budget(30_000_000) >= 30_000_000 * 12 / 5); + assert_eq!(value_budget(5_000_000), 20_000_000); + assert_eq!(value_budget(32 << 20), value_budget(MAX_CANVAS as usize)); +} + +#[test] +fn mangled_files_never_panic() { + let zip = file(&[(1, image(1))]); + let c = canvas(&schema(), &message()); + let read = |bytes: &[u8]| { + let _ = is_fig(bytes); + if let Ok(archive) = Archive::open(bytes) { + let _ = archive.image(&name(1)); + if let Ok((sc, msg)) = decode_canvas(&archive.canvas) + && let Some(root) = sc.find("Message") + && let Ok(kv) = sc.decode_message(root, &msg) + { + let _ = embedded_copies(&kv, 1 << 20); + } + } + }; + let mut r = Lcg(7); + for src in [&zip, &c] { + for cut in (0..src.len()).step_by(7) { + read(&src[..cut]); + } + for _ in 0..300 { + let mut m = src.clone(); + for _ in 0..1 + r.next() % 8 { + let i = (r.next() as usize) % m.len(); + m[i] ^= 1 << (r.next() % 8); + } + read(&m); + } + } + for _ in 0..500 { + let junk: Vec = (0..(r.next() % 200)).map(|_| r.next() as u8).collect(); + let mut framed = b"fig-kiwi\0\0\0\0".to_vec(); + framed.extend_from_slice(&junk); + read(&framed); + let _ = network::decode(&junk); + let _ = path::decode(&junk); + } +} + +#[test] +fn images_carried_in_the_blobs_are_found() { + let msg = o(vec![ + ("nodeChanges", Kv::Array(vec![image_node(1, Some(1)), image_node(2, Some(7)), image_node(3, None), image_node(4, Some(2))])), + ("blobs", Kv::Array(vec![blob(vec![9]), blob(vec![5, 6, 7]), blob(vec![0; 40]), blob(vec![])])), + ]); + // A blob index past the blobs, no index, and an empty or oversized blob name no image. + assert_eq!(embedded_images(&msg, 39), [(name(1), 1)].into()); + assert_eq!(embedded_images(&msg, 40), [(name(1), 1), (name(4), 2)].into()); + assert_eq!(*embedded_copies(&msg, 39)[&name(1)], [5, 6, 7]); + // A hash longer than 64 bytes is not a name. + let long = o(vec![("image", o(vec![("hash", Kv::Bytes(vec![1; MAX_IMAGE_HASH + 1])), ("dataBlob", Kv::Uint(0))]))]); + let msg = o(vec![("nodeChanges", Kv::Array(vec![long])), ("blobs", Kv::Array(vec![blob(vec![9])]))]); + assert!(embedded_images(&msg, 100).is_empty()); +} + +/// A file can point any number of image hashes at one large blob; each shares one copy of it. +#[test] +fn hashes_naming_one_blob_share_one_copy_of_it() { + let msg = o(vec![ + ("blobs", Kv::Array(vec![blob(vec![3; 64]), blob(vec![7; 16])])), + ("nodeChanges", Kv::Array((1..=50).map(|h| image_node(h, Some(0))).chain([image_node(99, Some(1)), image_node(98, Some(2))]).collect())), + ]); + let copies = embedded_copies(&msg, 1 << 20); + assert_eq!(copies.len(), 51, "the hash naming no blob is left out"); + let first = &copies[&name(1)]; + assert_eq!(**first, [3; 64]); + assert_eq!(copies.values().filter(|c| Arc::ptr_eq(c, first)).count(), 50); + assert_eq!(*copies[&name(99)], [7; 16]); + // Every alias maps to the first hash; a hash `images/` also holds keeps its own name. + let aliases = embedded_aliases(&copies, |_| false); + assert_eq!(aliases.len(), 49); + assert!(aliases.values().all(|c| *c == name(1))); + assert!(!embedded_aliases(&copies, |h| h == name(5)).contains_key(&name(5))); +} + +mod image_budget { + use super::*; + + #[test] + fn exact_total_limit_keeps_all_images_and_each_open_gets_its_own_budget() { + let members = [(1, image(1)), (2, image(2))]; + let zip = file(&members); + for _ in 0..2 { + let archive = open(&zip, 140); + assert_eq!(archive.image(&name(1)), Some(members[0].1.clone())); + assert_eq!(archive.image(&name(2)), Some(members[1].1.clone())); + assert_eq!((archive.image_bytes_left(), archive.images_skipped()), (0, 0)); + } + } + + #[test] + fn individually_acceptable_images_overflow_the_total() { + let members = [(1, image(1)), (2, image(2))]; + let zip = file(&members); + let archive = open(&zip, 139); + assert!(archive.image(&name(1)).is_some()); + assert_eq!(archive.image(&name(2)), None); + assert_eq!(archive.images_skipped(), 1); + } + + #[test] + fn rejecting_a_member_before_reading_leaves_room_for_a_smaller_one() { + let members = [(1, vec![0; 71]), (2, image(2))]; + let zip = file(&members); + let archive = open(&zip, 70); + assert_eq!(archive.image(&name(1)), None); + assert_eq!(archive.image(&name(2)), Some(members[1].1.clone())); + assert_eq!(archive.images_skipped(), 1); + } + + #[test] + fn failed_crc_reads_still_consume_the_bytes_read() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&members); + change_member_field(&mut zip, 1, 16, 0); + let archive = open(&zip, 71); + assert_eq!(archive.image(&name(1)), None); + assert_eq!(archive.image_bytes_left(), 1); + assert_eq!(archive.image(&name(2)), None); + assert_eq!(archive.images_skipped(), 1); + } + + #[test] + fn a_truncated_deflate_read_keeps_its_partial_charge() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&members); + // Shorten the first member's compressed size, cutting its deflate stream mid-image. + change_member_field(&mut zip, 1, 20, 15); + let archive = open(&zip, 70); + assert!(archive.image(&name(1)).is_none()); + let remaining = archive.image_bytes_left(); + assert!(remaining < 70, "a partial read left {remaining} of 70 bytes"); + assert!(archive.image(&name(2)).is_none()); + assert_eq!(archive.image_bytes_left(), remaining); + } + + #[test] + fn the_individual_image_cap_is_kept_without_reading_a_large_member() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&members); + change_member_field(&mut zip, 1, 24, (Limits::default().max_image + 1) as u32); + let archive = open(&zip, 70); + assert_eq!(archive.image(&name(1)), None); + assert_eq!(archive.image(&name(2)), Some(members[1].1.clone())); + assert_eq!(archive.images_skipped(), 0); + // A cap of the caller's own. + let zip = file(&members); + let small = Archive::open_with(&zip, Limits { max_image: 69, ..Limits::default() }).unwrap(); + assert_eq!(small.image(&name(1)), None); + assert_eq!(small.image_bytes_left(), Limits::default().max_image_total); + } + + #[test] + fn crc_is_checked_even_when_the_read_ends_exactly_at_the_budget() { + let members = [(1, image(1))]; + let mut zip = file(&members); + change_member_field(&mut zip, 1, 16, 0); + let archive = open(&zip, 70); + assert_eq!(archive.image(&name(1)), None); + assert_eq!(archive.images_skipped(), 0); + } + + #[test] + fn an_understated_size_cannot_read_past_the_remaining_budget() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&members); + // The directory says one byte; the stream holds 70. Whether the ZIP reader refuses the + // mismatch or reads on, the member is not returned and the budget is never overdrawn. + change_member_field(&mut zip, 1, 24, 1); + let archive = open(&zip, 69); + assert!(archive.image(&name(1)).is_none()); + assert!(archive.image_bytes_left() <= 69); + assert!(archive.image(&name(2)).is_none()); + assert!(archive.images_skipped() >= 1); + } + + #[test] + fn missing_members_do_not_consume_the_budget() { + let members = [(1, image(1)), (2, vec![0; 128])]; + let zip = file(&members); + let archive = open(&zip, 70); + assert_eq!(archive.image(&name(9)), None); + assert_eq!(archive.image(&name(1)), Some(members[0].1.clone())); + assert_eq!(archive.images_skipped(), 0); + } +} + +#[test] +fn a_vector_network_round_trips_and_its_limits_hold() { + use network::{Network, Region, Segment, Vertex, decode, encode}; + let net = Network { + vertices: vec![Vertex { style: 0, x: 0.0, y: 0.0 }, Vertex { style: 2, x: 10.0, y: 0.0 }, Vertex { style: 0, x: 5.0, y: 8.5 }], + segments: vec![ + Segment { style: 0, a: 0, ta: (0.0, 0.0), b: 1, tb: (0.0, 0.0) }, + Segment { style: 1, a: 1, ta: (1.0, 2.0), b: 2, tb: (-1.0, 0.5) }, + Segment { style: 0, a: 2, ta: (0.0, 0.0), b: 0, tb: (0.0, 0.0) }, + ], + regions: vec![Region { style: 3, non_zero: true, loops: vec![vec![0, 1, 2]] }, Region { style: 0, non_zero: false, loops: vec![] }], + }; + let blob = encode(&net); + assert_eq!(blob.len(), 12 + 3 * 12 + 3 * 28 + (8 + 4 + 12) + 8); + // The first region's word: style 3 shifted once, the low bit set for non-zero. + assert_eq!(blob[12 + 36 + 84..][..4], 7u32.to_le_bytes()); + assert_eq!(decode(&blob), Some(net)); + for cut in 0..blob.len() { + assert_eq!(decode(&blob[..cut]), None, "cut at {cut}"); + } + let with = |at: usize, v: u32| { + let mut b = blob.clone(); + b[at..at + 4].copy_from_slice(&v.to_le_bytes()); + b + }; + // A count the blob can't hold or past the cap, a vertex or segment index out of range, NaN. + assert_eq!(decode(&with(0, 4)), None); + assert_eq!(decode(&with(0, network::MAX_ITEMS as u32 + 1)), None); + assert_eq!(decode(&with(12 + 36 + 4, 3)), None); + assert_eq!(decode(&with(blob.len() - 8 - 4, 3)), None); + assert_eq!(decode(&with(12 + 4, f32::NAN.to_bits())), None); + // Loop entries are capped in total, however many loops share them. + let mut many = Vec::new(); + for v in [0u32, 0, 1, 0, 2] { + many.extend_from_slice(&v.to_le_bytes()); + } + for _ in 0..2 { + many.extend_from_slice(&(network::MAX_ITEMS as u32 / 2 + 1).to_le_bytes()); + many.resize(many.len() + 4 * (network::MAX_ITEMS / 2 + 1), 0); + } + assert_eq!(decode(&many), None); +} + +#[test] +fn a_geometry_blob_round_trips_and_its_limits_hold() { + use path::{Command, decode, encode}; + let commands = vec![ + Command::Move([0.0, 0.0]), + Command::Line([10.0, 0.5]), + Command::Quad([1.0, 2.0], [3.0, 4.0]), + Command::Cubic([5.0, 6.0], [7.0, 8.0], [9.0, -1.0]), + Command::Close, + ]; + let blob = encode(&commands); + assert_eq!(blob.len(), 9 + 9 + 17 + 25 + 1); + assert_eq!(blob[..9], [1, 0, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(decode(&blob), Some(commands)); + assert_eq!(decode(&[]), Some(vec![])); + // Cut inside a point, an unknown command, a value that isn't finite. + assert_eq!(decode(&blob[..blob.len() - 2]), None); + assert_eq!(decode(&[5]), None); + let mut nan = vec![1]; + nan.extend_from_slice(&f32::INFINITY.to_le_bytes()); + nan.extend_from_slice(&0f32.to_le_bytes()); + assert_eq!(decode(&nan), None); + // The command cap. + assert_eq!(decode(&vec![0; path::MAX_COMMANDS]).map(|c| c.len()), Some(path::MAX_COMMANDS)); + assert_eq!(decode(&vec![0; path::MAX_COMMANDS + 1]), None); +}