mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
Add craft-fig: the .fig container, read and written (#905)
* Add craft-kiwi: the kiwi binary schema format, decoded and encoded The generic kiwi decoder and encoder from FigCraft's .fig import and export (crates/format/src/fig/kiwi.rs at 3b9bf8c9), moved as it is: binary schema decode and encode, the compact text form of a schema, message decode into a tree keyed by field name, and message encode checked against the schema. The value budget that follows a .fig file's compressed size stays with the .fig reader; here decode_message_within takes the budget and DEFAULT_MAX_VALUES is the floor FigCraft used. * Add craft-fig: the .fig container, read and written The app-independent half of FigCraft's .fig import and export (crates/format/src/fig at 3b9bf8c9): the ZIP archive with its image budget, the chunked canvas (deflate, zlib and zstd chunks), the value budget that follows the compressed canvas, images carried in the message's blobs, and the vector network and geometry blob encodings. The message stays a craft-kiwi tree read by field name; mapping it onto a document stays in the app. * craft-kiwi: no scan per field in schema decode; the text form keeps the limits Schema::decode compared each message field id with every earlier one: 20 definitions of 10 000 fields, within every cap, took a billion comparisons. A set per definition now. Schema::parse applies the definition and field caps of the binary form, looks type names up in an index instead of scanning the lines per field, accepts a struct or message without fields, and refuses two fields of one name and a message field id that is 0 (it ends a message) or repeated.
This commit is contained in:
1 parent
a1e9eb8a31
commit
26b36b4f9e
12 files changed
+1338
No files matched your search
Generated
+12
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -73,6 +73,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
- [`craft-ecproj-format`](crates/ecproj-format/README.md): the envelope of EffectCraft `.ecproj`
|
||||
project files: `savedBy`, the schema version and its check, and the file object with the
|
||||
project and its saved view as type parameters. The project model stays in EffectCraft.
|
||||
- [`craft-fig`](crates/fig/README.md): clean-room reader and writer for the `.fig` container
|
||||
(Figma Design and FigJam local copies): the ZIP archive, the chunked canvas with its kiwi schema
|
||||
and message, images, and the vector network and geometry blobs. Bounded on hostile input.
|
||||
Carved from FigCraft.
|
||||
- [`craft-epub`](crates/epub/README.md): EPUB packages: the OCF ZIP container, the package document (metadata, manifest, spine) and the navigation document, written as EPUB 3 (reflowable and fixed layout) and read (EPUB 2 and 3) with bounded entry sizes. Content documents are the caller's.
|
||||
- [`craft-font-catalog`](crates/font-catalog/README.md): the installed fonts on every platform
|
||||
(font folders, fontconfig and Flatpak host folders, CoreText, DirectWrite, the font store),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "craft-fig"
|
||||
description = "Clean-room reader and writer for the .fig container (Figma Design and FigJam local copies): the ZIP archive, the chunked canvas with its kiwi schema and message, images, vector network and geometry blobs, with bounded reads."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-kiwi = { workspace = true }
|
||||
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"] }
|
||||
ruzstd = { version = "0.9", default-features = false, features = ["std"] }
|
||||
@@ -0,0 +1,90 @@
|
||||
# craft-fig
|
||||
|
||||
A clean-room reader and writer for the `.fig` container: the local copies Figma Design saves
|
||||
(`.fig`) and FigJam whiteboards (`.jam`, the same archive with another canvas header). No `unsafe`.
|
||||
|
||||
This crate is the wire layer. It opens the archive, decompresses the canvas, hands the schema and
|
||||
the message to [`craft-kiwi`](../kiwi/README.md), finds images, and reads and writes the two blob
|
||||
encodings (vector networks and outline geometry). What the decoded node changes mean, and how
|
||||
they map onto an application's document, stays with the caller: the message is a `kiwi::Kv` tree
|
||||
read by field name, in the file's own terms.
|
||||
|
||||
## Provenance
|
||||
|
||||
Written by FigCraft's contributors, from public format knowledge only: public descriptions of the
|
||||
container, the kiwi format's published specification, and black-box observation of files made
|
||||
from synthetic documents. Nothing inside the incumbent's application bundle, web bundle or
|
||||
network traffic was read, disassembled or copied, and no GPL, LGPL or AGPL code was consulted.
|
||||
Written files were checked by opening them in other software. Moved here from
|
||||
`figcraft/crates/format/src/fig.rs`, `fig/network.rs`, `fig/export.rs` (the container half) and
|
||||
`fig/map/layer.rs` (the geometry blob).
|
||||
|
||||
## The format
|
||||
|
||||
- A `.fig` file is a ZIP archive: `canvas.fig` (the document), `meta.json` (`file_name`,
|
||||
`client_meta.background_color`, `client_meta.thumbnail_size`), `thumbnail.png` and
|
||||
`images/<hash>` (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<Network>
|
||||
let outline = craft_fig::path::decode(blob_bytes); // Option<Vec<Command>>
|
||||
```
|
||||
|
||||
## 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.
|
||||
@@ -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<u8>,
|
||||
/// `meta.json`'s `file_name`.
|
||||
pub file_name: Option<String>,
|
||||
/// `images/<hash>` members (or nothing for a bare canvas).
|
||||
zip: Option<zip::ZipArchive<Cursor<&'b [u8]>>>,
|
||||
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<u64>,
|
||||
skipped: Cell<usize>,
|
||||
}
|
||||
|
||||
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> {
|
||||
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::<serde_json::Value>(&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<Vec<u8>> {
|
||||
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<R: Read + Seek>(z: &mut zip::ZipArchive<R>, name: &str, limit: u64) -> Result<Vec<u8>, 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/<name>` 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<Vec<u8>, 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())
|
||||
}
|
||||
@@ -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<u8>), 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<Vec<&[u8]>, 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<Vec<u8>, Error> {
|
||||
let capped = |r: &mut dyn Read| -> Option<Vec<u8>> {
|
||||
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<Vec<u8>, 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)
|
||||
}
|
||||
@@ -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<String, usize> {
|
||||
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<String, Arc<[u8]>> {
|
||||
let blobs = msg.get("blobs").map(Kv::list).unwrap_or_default();
|
||||
let mut copies: BTreeMap<usize, Arc<[u8]>> = 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<String, Arc<[u8]>>, in_zip: impl Fn(&str) -> bool) -> BTreeMap<String, String> {
|
||||
let mut first: BTreeMap<usize, &String> = 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)
|
||||
}
|
||||
@@ -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/<hash>` 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<String>) -> Error {
|
||||
Error::Corrupt(msg.into())
|
||||
}
|
||||
@@ -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<Vec<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Network {
|
||||
pub vertices: Vec<Vertex>,
|
||||
pub segments: Vec<Segment>,
|
||||
pub regions: Vec<Region>,
|
||||
}
|
||||
|
||||
struct Reader<'b> {
|
||||
b: &'b [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
fn u32(&mut self) -> Option<u32> {
|
||||
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<f64> {
|
||||
Some(f64::from(f32::from_bits(self.u32()?))).filter(|x| x.is_finite())
|
||||
}
|
||||
|
||||
/// An index below `len`.
|
||||
fn index(&mut self, len: usize) -> Option<usize> {
|
||||
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<usize> {
|
||||
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<Network> {
|
||||
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::<Option<Vec<usize>>>()?);
|
||||
}
|
||||
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<u8> {
|
||||
let mut out = Vec::new();
|
||||
let u = |v: u32, out: &mut Vec<u8>| out.extend_from_slice(&v.to_le_bytes());
|
||||
let fl = |v: f64, out: &mut Vec<u8>| 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
|
||||
}
|
||||
@@ -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<Vec<Command>> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0usize;
|
||||
let point = |i: &mut usize| -> Option<Point> {
|
||||
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<u8> {
|
||||
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
|
||||
}
|
||||
@@ -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<u8>) -> 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<u64>) -> 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<u8> {
|
||||
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<u8> {
|
||||
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<u8>)]) -> Vec<u8> {
|
||||
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<String> = (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<u8> = (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);
|
||||
}
|
||||
Reference in new issue
Block a user