diff --git a/Cargo.lock b/Cargo.lock index 307b428c7..073e2aa33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1344,6 +1344,16 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "craft-sketch" +version = "0.1.0" +dependencies = [ + "flate2", + "serde_json", + "thiserror 2.0.21", + "zip", +] + [[package]] name = "craft-svg" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c449f4f05..371698976 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ craft-psd = { path = "crates/psd" } craft-quit = { path = "crates/quit" } craft-rtf = { path = "crates/rtf" } craft-raw = { path = "crates/raw" } +craft-sketch = { path = "crates/sketch" } craft-settings = { path = "crates/settings" } craft-silhouette-studio = { path = "crates/silhouette-studio" } craft-svg = { path = "crates/svg" } diff --git a/README.md b/README.md index c004b1e46..1a3986f86 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,7 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "`, shared styles, swatches, library symbols copied into the document), + `meta.json` (`appVersion`, the format `version`), `user.json`, one `pages/.json` per page + (the layer tree), `images/` and, in newer files, `fonts/`. +- Before version 43 a Sketch file was a SQLite database; until 47 text was a binary property + list inside the JSON. Both are refused as too old (`Error::TooOld`, with a message saying to + save the file again in a newer Sketch). +- `meta.json` versions above 139 (Sketch 83, the newest checked against real files) open with a + warning. + +## Reading + +```rust +if craft_sketch::is_sketch(&bytes) { + let archive = craft_sketch::Archive::open(&bytes)?; + for (id, page) in &archive.pages { // in the document's order + let layers = page.get("layers"); + } + let styles = archive.document.get("layerStyles"); + let image = archive.image("images/0a1b….png"); // Option> + let fonts = craft_sketch::embedded_font_families(&archive.document); + for warning in &archive.warnings { /* pages skipped, a newer format than tested */ } +} +``` + +A `document.json` that can't be read (damaged, nested too deep) loses the shared styles, not the +pages: they are then taken in archive order, with a warning. A page that can't be read is +skipped with a warning; the others open. + +## Limits + +Input is hostile. Nothing panics; every limit has a test. + +| Limit | Value | +|---|---| +| `meta.json`, `document.json` (`MAX_JSON_MEMBER`) | 512 MiB | +| A page (`Limits::max_page`) | 512 MiB | +| JSON read in all (`MAX_JSON_TOTAL`) | 2 GiB (512 MiB on 32-bit targets) | +| Unpacked size per compressed byte (`MAX_RATIO`), past a 16 MiB floor (`MIN_ALLOWANCE`) | 100 | +| Pages (`MAX_PAGES`) | 1000 | +| An image (`Limits::max_image`) | 256 MiB | +| Image bytes in all (`Limits::max_image_total`) | 1 GiB (256 MiB on 32-bit targets) | +| A page id or image name | 128 characters of letters, digits, `-` and `_` (an image may have one extension) | +| JSON nesting | serde_json's 128 levels | + +A member is refused before unpacking when its declared size is past its allowance, and again if +it unpacks past it. A read that fails is charged in full, and a page listed twice is read once, +so a page list can't repeat an unpacking within the budget. + +## 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 document and the layers (`_class` objects: pages, artboards, groups, + shape paths, text with attributed strings, symbols and overrides, styles). FigCraft maps the + JSON straight onto its own import shape today. +- Writing. diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs new file mode 100644 index 000000000..2ef5a9a76 --- /dev/null +++ b/crates/sketch/src/lib.rs @@ -0,0 +1,254 @@ +//! The `.sketch` container, for files saved by Sketch 48 or newer, read from the public +//! description of the format only. +//! +//! A `.sketch` file is a zip archive holding `document.json` (the document: its pages in order, +//! shared styles, swatches and library symbols copied into it), `meta.json` (the app version and +//! format version that saved it), one `pages/.json` per page (the layer tree), `images/` and, +//! in newer files, `fonts/`. Every object carries its kind in `_class`. Files older than Sketch 48 +//! (a SQLite database before 43, text as binary property lists until 47) are refused +//! ([`Error::TooOld`]). +//! +//! This crate is the container layer: [`Archive`] opens the zip, checks the version, and hands +//! out the document and the pages as the JSON the file holds ([`serde_json::Value`]) and the +//! images as bytes. What the layers mean is read from that JSON by the caller. +//! +//! Everything here reads untrusted bytes: member sizes, compression ratios, totals and the page +//! count are bounded; malformed input is an error or a warning, never a panic. + +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +#[cfg(test)] +mod tests; + +use std::cell::Cell; +use std::collections::{BTreeSet, HashSet}; +use std::io::{Cursor, Read}; + +use serde_json::{Value, json}; + +/// The oldest Sketch whose files open: from 48 on, text is plain JSON too. +pub const MIN_SKETCH: u32 = 48; +/// The newest `meta.json` format version checked against real files (Sketch 83); newer files +/// open with a warning. +pub const NEWEST_TESTED_VERSION: u64 = 139; +/// What [`Error::TooOld`] says. +pub const TOO_OLD: &str = "saved by a Sketch older than 48; open it in a newer Sketch and save it again"; +/// Largest `meta.json` or `document.json` read. +pub const MAX_JSON_MEMBER: u64 = 512 * 1024 * 1024; +/// Most bytes a member may unpack to, per compressed byte (real JSON packs 5–20 times and images +/// barely; a zero-filled member a thousand times), with a floor ([`MIN_ALLOWANCE`]) for small +/// members. +pub const MAX_RATIO: u64 = 100; +pub const MIN_ALLOWANCE: u64 = 16 * 1024 * 1024; +/// Most JSON read from one file in all. +#[cfg(not(target_pointer_width = "32"))] +pub const MAX_JSON_TOTAL: u64 = 2 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +pub const MAX_JSON_TOTAL: u64 = 512 * 1024 * 1024; +/// Most pages read from one file. +pub const MAX_PAGES: usize = 1000; +/// The SQLite header: what Sketch wrote before version 43. +const SQLITE: &[u8] = b"SQLite format 3\0"; + +/// Why a `.sketch` file could not be read. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + /// Saved by a Sketch older than [`MIN_SKETCH`]. + #[error("{TOO_OLD}")] + TooOld, + /// The bytes are not a readable `.sketch` file. + #[error("Sketch file: {0}")] + Corrupt(String), +} + +fn corrupt(msg: impl Into) -> Error { + Error::Corrupt(msg.into()) +} + +/// What one open may read beyond the fixed caps. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Limits { + /// Largest page JSON read (a real page of a few thousand layers takes 33 MB); a larger page + /// is skipped with a warning. + pub max_page: u64, + /// Largest image member read. + pub max_image: u64, + /// Most image bytes read from one file in all. + pub max_image_total: u64, +} + +impl Default for Limits { + /// 512 MiB a page, 256 MiB an image, 1 GiB of images in all (256 MiB on 32-bit targets). + fn default() -> Self { + #[cfg(not(target_pointer_width = "32"))] + let max_image_total = 1024 * 1024 * 1024; + #[cfg(target_pointer_width = "32")] + let max_image_total = 256 * 1024 * 1024; + Limits { max_page: MAX_JSON_MEMBER, max_image: 256 * 1024 * 1024, max_image_total } + } +} + +/// Whether `bytes` look like a `.sketch` file: a zip holding `document.json` and `meta.json`, or +/// the SQLite database Sketch wrote before version 43 (so that it gets the "too old" message). +pub fn is_sketch(bytes: &[u8]) -> bool { + bytes.starts_with(SQLITE) + || (bytes.starts_with(b"PK") + && zip::ZipArchive::new(Cursor::new(bytes)) + .is_ok_and(|z| z.index_for_name("document.json").is_some() && z.index_for_name("meta.json").is_some())) +} + +/// The parts of a `.sketch` archive: the document, the pages in document order and the images. +pub struct Archive<'b> { + /// `document.json`. When it can't be read (nested too deep, damaged) this is `{"pages": […]}` + /// listing the archive's pages in archive order, and a warning says so. + pub document: Value, + /// `meta.json`. + pub meta: Value, + /// `meta.json`'s format version (0 when missing). + pub version: u64, + /// `(id, page JSON)` in the document's order. + pub pages: Vec<(String, Value)>, + /// Problems met while reading (pages skipped, a newer format than tested). + pub warnings: Vec, + zip: zip::ZipArchive>, + limits: Limits, + /// Image bytes read so far. + image_total: 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 { + if bytes.starts_with(SQLITE) { + return Err(Error::TooOld); + } + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?; + let mut total = 0u64; + let meta = json_member(&mut z, "meta.json", MAX_JSON_MEMBER, &mut total)?; + if !meta.is_object() { + return Err(corrupt("meta.json is not an object")); + } + if app_major(&meta).is_some_and(|v| v < MIN_SKETCH) { + return Err(Error::TooOld); + } + let mut warnings = Vec::new(); + if meta.get("version").and_then(Value::as_u64).is_some_and(|v| v > NEWEST_TESTED_VERSION) { + warnings.push("the file was saved by a newer Sketch than the import was checked with; some layers may differ".to_string()); + } + // A document.json that can't be read (nested too deep, damaged) loses its shared styles and + // library copies, not the pages: they are then taken in archive order. + let document = match json_member(&mut z, "document.json", MAX_JSON_MEMBER, &mut total) { + Ok(d) if d.is_object() => d, + other => { + let why = other.err().map_or_else(|| "not an object".to_string(), |e| e.to_string()); + warnings.push(format!("the document's shared styles and library symbols were skipped: {why}")); + let pages: Vec = z + .file_names() + .filter_map(|n| { + let n = n.ok()?; + let id = n.strip_prefix("pages/")?.strip_suffix(".json")?; + Some(json!({"_ref": format!("pages/{id}")})) + }) + .collect(); + json!({"pages": pages}) + } + }; + let refs = document.get("pages").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default(); + let mut pages = Vec::new(); + let mut seen = HashSet::new(); + for r in refs.iter().take(MAX_PAGES) { + let Some(id) = r.get("_ref").and_then(Value::as_str).and_then(|s| s.strip_prefix("pages/")).filter(|id| valid_id(id)) else { + warnings.push("pages the document lists without a valid reference were skipped".into()); + continue; + }; + // A page listed twice is read once: a repeated reference must not repeat the unpacking. + if !seen.insert(id) { + continue; + } + match json_member(&mut z, &format!("pages/{id}.json"), limits.max_page, &mut total) { + Ok(page) if page.is_object() => pages.push((id.to_string(), page)), + Ok(_) => warnings.push("pages that aren't JSON objects were skipped".into()), + Err(e) => warnings.push(format!("a page was skipped: {e}")), + } + } + if refs.len() > MAX_PAGES { + warnings.push(format!("the file has more than {MAX_PAGES} pages; the rest were skipped")); + } + let version = meta.get("version").and_then(Value::as_u64).unwrap_or(0); + Ok(Archive { document, meta, version, pages, warnings, zip: z, limits, image_total: Cell::new(0) }) + } + + /// The bytes of the image a layer references (`images/`, with or without the folder). + /// `None` when the name is not a plain member name, there is no such member, or it is past + /// [`Limits::max_image`], [`MAX_RATIO`] or what is left of [`Limits::max_image_total`]. + pub fn image(&self, r: &str) -> Option> { + let name = r.strip_prefix("images/").unwrap_or(r); + if !valid_id(name.split('.').next().unwrap_or("")) || name.split('.').count() > 2 { + return None; + } + // A clone shares the parsed directory and the bytes; the resolver only has shared access. + let mut z = self.zip.clone(); + let mut total = self.image_total.get(); + let data = capped_member(&mut z, &format!("images/{name}"), self.limits.max_image, &mut total, self.limits.max_image_total).ok(); + self.image_total.set(total); + data + } +} + +/// The families of the fonts the document carries (Sketch 7x and newer can embed them, as +/// `fontReferences` with `fontData`), sorted, at most 20, each cut at 64 characters. A reference +/// without font data only names a font the document uses and is left out. +pub fn embedded_font_families(document: &Value) -> Vec { + let Some(refs) = document.get("fontReferences").and_then(Value::as_array) else { return Vec::new() }; + let families: BTreeSet = refs + .iter() + .filter(|r| r.get("fontData").is_some()) + .filter_map(|r| r.get("fontFamilyName").and_then(Value::as_str)) + .map(|f| f.chars().take(64).collect()) + .take(20) + .collect(); + families.into_iter().collect() +} + +/// Member `name`, refused before unpacking when it would be larger than `limit`, than +/// [`MAX_RATIO`] times what it takes in the archive (past a floor), or than what is left of +/// `max_total` (`total`: read so far, counted up). +fn capped_member(z: &mut zip::ZipArchive>, name: &str, limit: u64, total: &mut u64, max_total: u64) -> Result, Error> { + let f = z.by_name(name).map_err(|e| corrupt(format!("{name}: {e}")))?; + let (size, packed) = (f.size(), f.compressed_size()); + let allowed = limit.min(packed.saturating_mul(MAX_RATIO).max(MIN_ALLOWANCE)).min(max_total.saturating_sub(*total)); + if size > allowed { + return Err(corrupt(format!("{name} is too large"))); + } + let mut data = Vec::new(); + let read = f.take(allowed.saturating_add(1)).read_to_end(&mut data); + // A failed read (a bad checksum at the end, say) unpacked all the same: charged in full, so + // a page list naming the member again can't repeat it within the budget. + let result = match read { + Err(e) => Err(corrupt(format!("{name}: {e}"))), + Ok(_) if data.len() as u64 > allowed => Err(corrupt(format!("{name} is too large"))), + Ok(_) => Ok(data), + }; + *total = total.saturating_add(result.as_ref().map_or(size, |d| d.len() as u64)); + result +} + +/// JSON member `name`, read by [`capped_member`] within the file's [`MAX_JSON_TOTAL`]. +fn json_member(z: &mut zip::ZipArchive>, name: &str, limit: u64, total: &mut u64) -> Result { + let data = capped_member(z, name, limit, total, MAX_JSON_TOTAL)?; + serde_json::from_slice(&data).map_err(|e| corrupt(format!("{name}: {e}"))) +} + +/// The major version of the Sketch that saved the file (`meta.json` `appVersion`, "50.2" → 50). +pub fn app_major(meta: &Value) -> Option { + meta.get("appVersion").and_then(Value::as_str)?.split('.').next()?.trim().parse().ok() +} + +/// Ids name archive members: letters, digits, `-` and `_` only, never a path. +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 128 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') +} diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs new file mode 100644 index 000000000..88a46076a --- /dev/null +++ b/crates/sketch/src/tests.rs @@ -0,0 +1,286 @@ +//! Detection, the archive, the version cut, pages and images, on files built in code. +#![allow(clippy::unwrap_used, clippy::panic)] + +use std::io::Write; + +use serde_json::{Value, json}; + +use super::*; + +/// A `.sketch` zip: `document.json`, `meta.json` saying `app_version` / `version`, one +/// `pages/.json` per page and any other `files` (images, fonts). +fn sketch_zip(app_version: &str, version: u64, document: Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec { + let meta = json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": version, "compatibilityVersion": 99, "pagesAndArtboards": {}}); + raw_zip(&document.to_string(), &meta, pages, files) +} + +/// A `.sketch` zip with the given `document.json` text and `meta.json` as they are. +fn raw_zip(document: &str, meta: &Value, pages: &[Value], files: &[(&str, &[u8])]) -> Vec { + let mut out = Vec::new(); + { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + z.start_file("document.json", opts).unwrap(); + z.write_all(document.as_bytes()).unwrap(); + z.start_file("meta.json", opts).unwrap(); + z.write_all(meta.to_string().as_bytes()).unwrap(); + for p in pages { + let id = p["do_objectID"].as_str().unwrap_or("page"); + z.start_file(format!("pages/{id}.json"), opts).unwrap(); + z.write_all(p.to_string().as_bytes()).unwrap(); + } + for (name, bytes) in files { + z.start_file(*name, opts).unwrap(); + z.write_all(bytes).unwrap(); + } + z.finish().unwrap(); + } + out +} + +/// `document.json` listing `pages` (ids) in order. +fn document(pages: &[&str]) -> Value { + json!({ + "_class": "document", + "do_objectID": "DOC", + "pages": pages.iter().map(|p| json!({"_class": "MSJSONFileReference", "_ref_class": "MSImmutablePage", "_ref": format!("pages/{p}")})).collect::>(), + }) +} + +fn page(id: &str, name: &str, layers: Vec) -> Value { + json!({"_class": "page", "do_objectID": id, "name": name, "layers": layers}) +} + +fn page_names(a: &Archive) -> Vec { + a.pages.iter().map(|(_, p)| p["name"].as_str().unwrap().to_string()).collect() +} + +/// Bytes that barely compress. +fn noise(n: usize, seed: u32) -> Vec { + let mut s = seed; + (0..n) + .map(|_| { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + (s >> 8) as u8 + }) + .collect() +} + +#[test] +fn detects_sketch_and_not_others() { + let z = sketch_zip("50.2", 103, document(&["P1"]), &[page("P1", "Page 1", vec![])], &[]); + assert!(is_sketch(&z)); + assert!(!is_sketch(b"fig-kiwi....")); + assert!(!is_sketch(b"{}")); + assert!(!is_sketch(b"")); + // A zip that isn't a Sketch file. + let mut other = Vec::new(); + { + let mut w = zip::ZipWriter::new(std::io::Cursor::new(&mut other)); + w.start_file("canvas.fig", zip::write::SimpleFileOptions::default()).unwrap(); + w.write_all(b"x").unwrap(); + w.finish().unwrap(); + } + assert!(!is_sketch(&other)); + assert!(Archive::open(&other).is_err()); +} + +#[test] +fn the_parts_are_read_as_the_file_holds_them() { + let layer = json!({"_class": "rectangle", "do_objectID": "R", "frame": {"x": 1, "y": 2, "width": 3, "height": 4}}); + let z = sketch_zip("50.2", 103, document(&["B", "A"]), &[page("A", "Alpha", vec![layer.clone()]), page("B", "Beta", vec![])], &[]); + let a = Archive::open(&z).unwrap(); + assert_eq!(a.version, 103); + assert_eq!(app_major(&a.meta), Some(50)); + assert_eq!(a.document["do_objectID"], "DOC"); + // Pages come in the document's order, not the archive's. + assert_eq!(a.pages.iter().map(|(id, _)| id.as_str()).collect::>(), ["B", "A"]); + assert_eq!(page_names(&a), ["Beta", "Alpha"]); + assert_eq!(a.pages[1].1["layers"][0], layer); + assert!(a.warnings.is_empty(), "{:?}", a.warnings); +} + +#[test] +fn a_page_listed_twice_is_read_once() { + let z = sketch_zip("50.2", 103, document(&["A", "A", "A"]), &[page("A", "Alpha", vec![])], &[]); + assert_eq!(page_names(&Archive::open(&z).unwrap()), ["Alpha"]); +} + +#[test] +fn older_than_48_is_refused() { + let z = sketch_zip("47.1", 95, document(&["P"]), &[page("P", "P", vec![])], &[]); + assert_eq!(Archive::open(&z).err(), Some(Error::TooOld)); + assert_eq!(Error::TooOld.to_string(), TOO_OLD); + let mut sqlite = b"SQLite format 3\0".to_vec(); + sqlite.resize(512, 0); + assert!(is_sketch(&sqlite)); + assert_eq!(Archive::open(&sqlite).err(), Some(Error::TooOld)); + // 48 itself, and a meta.json without an app version, open. + assert!(Archive::open(&sketch_zip("48", 97, document(&["P"]), &[page("P", "P", vec![])], &[])).is_ok()); + let no_version = raw_zip(&document(&["P"]).to_string(), &json!({"app": "com.bohemiancoding.sketch3"}), &[page("P", "P", vec![])], &[]); + assert_eq!(Archive::open(&no_version).unwrap().version, 0); +} + +#[test] +fn newer_than_tested_opens_with_one_warning() { + let z = sketch_zip("2099.1", NEWEST_TESTED_VERSION + 1, document(&["P"]), &[page("P", "P", vec![])], &[]); + let a = Archive::open(&z).unwrap(); + assert_eq!(a.warnings.iter().filter(|w| w.contains("newer Sketch")).count(), 1, "{:?}", a.warnings); + let z = sketch_zip("83", NEWEST_TESTED_VERSION, document(&["P"]), &[page("P", "P", vec![])], &[]); + assert!(Archive::open(&z).unwrap().warnings.is_empty()); +} + +#[test] +fn junk_never_panics() { + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "P", vec![json!({"_class": "rectangle"})])], &[("images/a.png", b"png")]); + let read = |bytes: &[u8]| { + let _ = is_sketch(bytes); + if let Ok(a) = Archive::open(bytes) { + let _ = a.image("images/a.png"); + let _ = embedded_font_families(&a.document); + } + }; + for cut in (0..z.len()).step_by(7) { + read(&z[..cut]); + } + for (n, seed) in (0..200u32).enumerate() { + let mut b = z.clone(); + for (i, v) in noise(16, seed + 1).chunks(2).enumerate() { + let at = (usize::from(v[0]) * 251 + i * 97 + n * 13) % b.len(); + b[at] = v[1]; + } + read(&b); + } + for doc in [json!([]), json!(null), json!({"pages": 5}), json!({"pages": [{"_ref": 7}, {"_ref": "pages/../../x"}]})] { + let z = sketch_zip("50.2", 103, doc, &[json!(5), json!({"do_objectID": "Q", "layers": "x"})], &[]); + let a = Archive::open(&z).unwrap(); + assert!(a.pages.len() <= 1); + } + assert!(Archive::open(&raw_zip(&document(&["P"]).to_string(), &json!("x"), &[], &[])).is_err()); +} + +#[test] +fn page_references_that_are_not_ids_are_skipped() { + let doc = json!({"pages": [{"_ref": "pages/../meta"}, {"_ref": 7}, {"_ref": "elsewhere/A"}, {"_ref": format!("pages/{}", "a".repeat(129))}, {"_ref": "pages/A"}]}); + let z = sketch_zip("50.2", 103, doc, &[page("A", "Alpha", vec![])], &[]); + let a = Archive::open(&z).unwrap(); + assert_eq!(page_names(&a), ["Alpha"]); + assert_eq!(a.warnings.iter().filter(|w| w.contains("without a valid reference")).count(), 4, "{:?}", a.warnings); + // A page the document lists but the archive lacks; then one that isn't an object. + let doc = document(&["Gone", "A"]); + let z = sketch_zip("50.2", 103, doc, &[page("A", "Alpha", vec![])], &[]); + let a = Archive::open(&z).unwrap(); + assert_eq!(page_names(&a), ["Alpha"]); + assert!(a.warnings.iter().any(|w| w.starts_with("a page was skipped: Sketch file: pages/Gone.json")), "{:?}", a.warnings); + let numbers = raw_zip(&document(&["page"]).to_string(), &json!({"appVersion": "50"}), &[json!(5)], &[]); + let a = Archive::open(&numbers).unwrap(); + assert!(a.pages.is_empty() && a.warnings.iter().any(|w| w.contains("aren't JSON objects")), "{:?}", a.warnings); +} + +#[test] +fn deep_nesting_does_not_fail_the_file() { + let mut deep = json!({"_class": "rectangle"}); + for _ in 0..300 { + deep = json!({"_class": "group", "layers": [deep]}); + } + let z = sketch_zip("50.2", 103, document(&["A", "B"]), &[page("A", "Deep", vec![deep]), page("B", "Normal", vec![])], &[]); + let a = Archive::open(&z).unwrap(); + assert_eq!(page_names(&a), ["Normal"]); + assert!(a.warnings.iter().any(|w| w.starts_with("a page was skipped")), "{:?}", a.warnings); +} + +#[test] +fn a_document_that_cannot_be_read_keeps_the_pages() { + let pages = [page("B", "Beta", vec![]), page("A", "Alpha", vec![])]; + for broken in ["{not json", "[1, 2]"] { + let z = raw_zip(broken, &json!({"appVersion": "50.2", "version": 103}), &pages, &[]); + let a = Archive::open(&z).unwrap(); + // Archive order stands in for the document's. + assert_eq!(page_names(&a), ["Beta", "Alpha"]); + assert!(a.warnings.iter().any(|w| w.contains("shared styles and library symbols were skipped")), "{:?}", a.warnings); + } +} + +#[test] +fn pages_over_the_page_limit_are_skipped() { + let z = sketch_zip("50.2", 103, document(&["A", "B"]), &[page("A", &"Big".repeat(100), vec![]), page("B", "Small", vec![])], &[]); + let a = Archive::open_with(&z, Limits { max_page: 200, ..Limits::default() }).unwrap(); + assert_eq!(page_names(&a), ["Small"]); + assert!(a.warnings.iter().any(|w| w.contains("pages/A.json is too large")), "{:?}", a.warnings); +} + +#[test] +fn pages_past_the_page_count_are_not_read() { + let ids: Vec = (0..MAX_PAGES + 5).map(|i| format!("P{i}")).collect(); + let refs: Vec<&str> = ids.iter().map(String::as_str).collect(); + let pages: Vec = ids.iter().map(|id| page(id, id, vec![])).collect(); + let z = sketch_zip("50.2", 103, document(&refs), &pages, &[]); + let a = Archive::open(&z).unwrap(); + assert_eq!(a.pages.len(), MAX_PAGES); + assert!(a.warnings.iter().any(|w| w.contains("more than 1000 pages")), "{:?}", a.warnings); +} + +#[test] +fn a_member_that_unpacks_far_past_its_packed_size_is_refused() { + // 17 MiB of spaces around a page pack into a few KB: past the 16 MiB floor and 100 times. + let padded = format!("{}{}", page("A", "Padded", vec![]), " ".repeat(17 << 20)); + let mut out = Vec::new(); + { + let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out)); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + for (name, text) in [("document.json", document(&["A", "B"]).to_string()), ("meta.json", json!({"appVersion": "50"}).to_string())] { + z.start_file(name, opts).unwrap(); + z.write_all(text.as_bytes()).unwrap(); + } + z.start_file("pages/A.json", opts).unwrap(); + z.write_all(padded.as_bytes()).unwrap(); + z.start_file("pages/B.json", opts).unwrap(); + z.write_all(page("B", "Small", vec![]).to_string().as_bytes()).unwrap(); + z.finish().unwrap(); + } + assert!(out.len() < 1 << 20, "{} bytes", out.len()); + let a = Archive::open(&out).unwrap(); + assert_eq!(page_names(&a), ["Small"]); + assert!(a.warnings.iter().any(|w| w.contains("pages/A.json is too large")), "{:?}", a.warnings); +} + +#[test] +fn images_are_read_by_plain_names_within_their_budget() { + let (one, two) = (noise(600, 1), noise(600, 2)); + let files: [(&str, &[u8]); 3] = [("images/a1.png", &one), ("images/b2", &two), ("fonts/f", b"font")]; + let z = sketch_zip("50.2", 103, document(&["P"]), &[page("P", "P", vec![])], &files); + let a = Archive::open(&z).unwrap(); + assert_eq!(a.image("images/a1.png"), Some(one.clone())); + assert_eq!(a.image("a1.png"), Some(one.clone())); + assert_eq!(a.image("b2"), Some(two.clone())); + // Never a path, never another folder, never two extensions. + for bad in ["", "images/../meta.json", "../meta.json", "a1.png.bak", "images/missing.png", "f", "a/b.png"] { + assert_eq!(a.image(bad), None, "{bad}"); + } + // The per-image cap, and the total: the second image no longer fits. + let small = Archive::open_with(&z, Limits { max_image: 599, ..Limits::default() }).unwrap(); + assert_eq!(small.image("a1.png"), None); + let tight = Archive::open_with(&z, Limits { max_image_total: 1000, ..Limits::default() }).unwrap(); + assert_eq!(tight.image("a1.png"), Some(one)); + assert_eq!(tight.image("b2"), None); + let exact = Archive::open_with(&z, Limits { max_image_total: 1200, ..Limits::default() }).unwrap(); + assert!(exact.image("a1.png").is_some() && exact.image("b2").is_some()); +} + +#[test] +fn embedded_fonts_are_listed_by_family() { + let doc = json!({"fontReferences": [ + {"fontFamilyName": "Zeta", "fontData": {"_ref": "fonts/z"}}, + {"fontFamilyName": "Alpha", "fontData": {"_ref": "fonts/a"}}, + {"fontFamilyName": "Alpha", "fontData": {"_ref": "fonts/a2"}}, + {"fontFamilyName": "Named only"}, + {"fontData": {}}, + {"fontFamilyName": "x".repeat(100), "fontData": {}}, + ]}); + assert_eq!(embedded_font_families(&doc), ["Alpha".to_string(), "Zeta".into(), "x".repeat(64)]); + assert!(embedded_font_families(&json!({"fontReferences": 5})).is_empty()); + let many: Vec = (0..50).map(|i| json!({"fontFamilyName": format!("F{i:02}"), "fontData": {}})).collect(); + assert_eq!(embedded_font_families(&json!({"fontReferences": many})).len(), 20); +}