Add craft-sketch: the .sketch container, read (#912)

* Add craft-sketch: the .sketch container, read

The archive half of FigCraft's .sketch import
(crates/format/src/sketch.rs at 3b9bf8c9): detection, the version cut
(Sketch 48 and newer), document.json with its fallback, the pages in
document order, images, and the budgets on member size, compression
ratio, totals and page count. The document and the pages are handed out
as the JSON the file holds; mapping the layers stays in the app.

* craft-sketch: credit line in the README
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 17:46:57 -05:00
1 parent f806e149bb
commit b249d84e92
7 files changed
+652

No files matched your search

Generated
+10
View File
@@ -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"
+1
View File
@@ -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" }
+1
View File
@@ -188,6 +188,7 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
for a small neutral model (paragraphs, character formatting, outline levels, tables).
- [`craft-settings`](crates/settings/README.md): `config.toml` settings storage for every app: platform folders, portable mode and overrides, atomic saves while running, backup, damaged-file recovery and schema migrations. Policy in [docs/settings-storage.md](docs/settings-storage.md).
- [`craft-silhouette-studio`](crates/silhouette-studio/src/lib.rs): bounded, fail-closed reader for Silhouette Studio `.studio` and `.studio3` documents: outlines, line colours and page size in millimetres. Protected designs are refused.
- [`craft-sketch`](crates/sketch/README.md): clean-room reader for the `.sketch` container (Sketch 48 and newer): the ZIP archive, the version cut, `document.json`, the pages in document order and images, as the JSON the file holds. Bounded on hostile input. Carved from FigCraft.
- [`craft-svg`](crates/svg/src/lib.rs): SVG. `sniff` recognises SVG and SVGZ data; `svgz` unpacks
SVGZ within a byte limit, decodes UTF-8, UTF-16 and ISO 8859-1, and compresses; `guard` bounds
element nesting and entity expansion before any XML reader sees the text; `paths` reads geometry
+20
View File
@@ -0,0 +1,20 @@
[package]
name = "craft-sketch"
description = "Clean-room reader for the .sketch container (Sketch 48 and newer): the ZIP archive, meta.json and its version cut, document.json, the pages in document order and images, with bounded reads."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
serde_json = { workspace = true }
thiserror = { workspace = true }
# `deflate-flate2` with flate2's default miniz_oxide backend, as craft-update pins it (zip's own
# `deflate` feature would switch a whole app's flate2 to zlib-rs). A shared bounded ZIP reader is
# planned (#126); until it lands this crate reads through `zip` directly.
zip = { version = "9", default-features = false, features = ["deflate-flate2"] }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
+80
View File
@@ -0,0 +1,80 @@
# craft-sketch
A clean-room reader for the `.sketch` container, for files saved by Sketch 48 and newer.
No `unsafe`.
This crate is the container layer. It opens the archive, applies the version cut, and hands out
`document.json`, `meta.json` and the pages as the JSON the file holds (`serde_json::Value`, every
object with its kind in `_class`) and the images as bytes. What the layers mean, and how they map
onto an application's document, stays with the caller.
## Provenance
Written by FigCraft's contributors (the `.sketch` import is Jindřich Šaršon's work), from the
public description of the format only, and files built for each case, checked against Sketch's
own renders and real files (Sketch 50, 63 and 2026). No code of Sketch or of any other product was copied, and no GPL, LGPL
or AGPL code was consulted. Moved here from `figcraft/crates/format/src/sketch.rs` (the archive
half).
## The format
- A `.sketch` file is a ZIP archive: `document.json` (the pages in order as `_ref`s to
`pages/<id>`, shared styles, swatches, library symbols copied into the document),
`meta.json` (`appVersion`, the format `version`), `user.json`, one `pages/<id>.json` per page
(the layer tree), `images/<name>` 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<Vec<u8>>
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.
+254
View File
@@ -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/<id>.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<String>) -> 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<String>,
zip: zip::ZipArchive<Cursor<&'b [u8]>>,
limits: Limits,
/// Image bytes read so far.
image_total: Cell<u64>,
}
impl<'b> Archive<'b> {
/// Open `bytes` with the default [`Limits`].
pub fn open(bytes: &'b [u8]) -> Result<Self, Error> {
Self::open_with(bytes, Limits::default())
}
pub fn open_with(bytes: &'b [u8], limits: Limits) -> Result<Self, Error> {
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<Value> = 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/<name>`, 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<Vec<u8>> {
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<String> {
let Some(refs) = document.get("fontReferences").and_then(Value::as_array) else { return Vec::new() };
let families: BTreeSet<String> = 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<Cursor<&[u8]>>, name: &str, limit: u64, total: &mut u64, max_total: u64) -> Result<Vec<u8>, 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<Cursor<&[u8]>>, name: &str, limit: u64, total: &mut u64) -> Result<Value, Error> {
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<u32> {
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'_')
}
+286
View File
@@ -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/<id>.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<u8> {
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<u8> {
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::<Vec<_>>(),
})
}
fn page(id: &str, name: &str, layers: Vec<Value>) -> Value {
json!({"_class": "page", "do_objectID": id, "name": name, "layers": layers})
}
fn page_names(a: &Archive) -> Vec<String> {
a.pages.iter().map(|(_, p)| p["name"].as_str().unwrap().to_string()).collect()
}
/// Bytes that barely compress.
fn noise(n: usize, seed: u32) -> Vec<u8> {
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::<Vec<_>>(), ["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<String> = (0..MAX_PAGES + 5).map(|i| format!("P{i}")).collect();
let refs: Vec<&str> = ids.iter().map(String::as_str).collect();
let pages: Vec<Value> = 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<Value> = (0..50).map(|i| json!({"fontFamilyName": format!("F{i:02}"), "fontData": {}})).collect();
assert_eq!(embedded_font_families(&json!({"fontReferences": many})).len(), 20);
}