mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
Add craft-penpot: the Penpot export container, read and written (#916)
The archive half of FigCraft's Penpot import and export (crates/format/src/penpot.rs and penpot/embedded_fonts.rs at 3b9bf8c9): ZIP validation, the manifest, bounded JSON members with the shared byte and value budgets, pages, page shapes, media objects, font signatures, and the archive writer. Members are handed out as the JSON the file holds; mapping shapes stays in the app. Duplicate member names are now refused: the ZIP reader keeps one member per name, so the per-name check FigCraft has never fires. The directory's own member count is compared instead.
This commit is contained in:
1 parent
b249d84e92
commit
afcc37cf20
7 files changed
+1002
No files matched your search
Generated
+10
@@ -1201,6 +1201,16 @@ dependencies = [
|
||||
"wasm-bindgen-test",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-penpot"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"serde_json",
|
||||
"thiserror 2.0.21",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-plate-test"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -59,6 +59,7 @@ craft-lottie = { path = "crates/lottie" }
|
||||
craft-menu = { path = "crates/menu" }
|
||||
craft-metafile = { path = "crates/metafile" }
|
||||
craft-opc = { path = "crates/opc" }
|
||||
craft-penpot = { path = "crates/penpot" }
|
||||
craft-pcraft-format = { path = "crates/pcraft-format" }
|
||||
craft-plotter = { path = "crates/plotter" }
|
||||
craft-prepress = { path = "crates/prepress" }
|
||||
|
||||
@@ -169,6 +169,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
- [`craft-pcraft-format`](crates/pcraft-format/README.md): the envelope of PhotoCraft's `.pcraft`
|
||||
bundle: `Manifest<D>` with the document as a type parameter, `TileRef`, the entry paths, the
|
||||
manifest decode limits and the migration runner. The document model stays in PhotoCraft.
|
||||
- [`craft-penpot`](crates/penpot/README.md): clean-room reader and writer for the Penpot 2.x export
|
||||
container (`.penpot`): the ZIP archive, its manifest, the per-file, per-page and per-shape JSON
|
||||
members and media objects, as the JSON the file holds. Bounded on hostile input. Carved from
|
||||
FigCraft.
|
||||
- [`craft-plotter`](crates/plotter/README.md): cutter and pen-plotter command languages: HP-GL,
|
||||
DMPL and GP-GL (plain and the Silhouette dialect, three generations, registration marks) from a
|
||||
job in millimetres, plus the Silhouette device queries and reply parsers. No I/O.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "craft-penpot"
|
||||
description = "Clean-room reader and writer for the Penpot 2.x export container (.penpot, penpot/export-files version 1): the ZIP archive, its manifest, the per-file, per-page and per-shape JSON members and media objects, 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"] }
|
||||
@@ -0,0 +1,98 @@
|
||||
# craft-penpot
|
||||
|
||||
A clean-room reader and writer for the Penpot 2.x export container: the `.penpot` archive
|
||||
(`penpot/export-files`, version 1). Offline: no network, no converter, no `unsafe`.
|
||||
|
||||
This crate is the container layer. It validates the ZIP, checks the manifest, and hands out the
|
||||
members as the JSON the file holds (`serde_json::Value`) and media objects as bytes; it writes
|
||||
members back into an archive. What the shapes, components and tokens mean, and how they map onto
|
||||
an application's document, stays with the caller.
|
||||
|
||||
## Provenance
|
||||
|
||||
Written by FigCraft's contributors (the offline Penpot import and export, first by Guillermo
|
||||
Bueno) under FigCraft's clean-room rule: behaviour comes from public documentation and from
|
||||
observing files, no code of another product is copied, and no GPL or AGPL code is used.
|
||||
FigCraft's own notes say its tests do not establish compatibility with a live Penpot server;
|
||||
that holds here too. Moved here from `figcraft/crates/format/src/penpot.rs` (the archive half)
|
||||
and `penpot/embedded_fonts.rs`.
|
||||
|
||||
## The format
|
||||
|
||||
A ZIP of JSON members and blobs:
|
||||
|
||||
| Member | Holds |
|
||||
|---|---|
|
||||
| `manifest.json` | `type` (`penpot/export-files`), `version` (1), `files`: each with its `id` (a UUID) and `name` |
|
||||
| `files/<file>.json` | the file's header: features, version, migrations, options |
|
||||
| `files/<file>/pages/<page>.json` | a page: `id`, `name`, `index`, background, guides, flows |
|
||||
| `files/<file>/pages/<page>/<shape>.json` | one shape; the root frame has the nil UUID (`ROOT_ID`) and lists its children in `shapes` |
|
||||
| `files/<file>/components/<id>.json`, `colors/`, `typographies/` | library entries |
|
||||
| `files/<file>/tokens.json` | design tokens |
|
||||
| `files/<file>/media/<id>.json` | a media record: `mediaId` names the stored object, `mtype` its type |
|
||||
| `objects/<id>.json`, `objects/<id>.<ext>` | a stored object's record and its bytes |
|
||||
|
||||
Penpot 1.x binary exports are a different format and are not read.
|
||||
|
||||
## Reading
|
||||
|
||||
```rust
|
||||
let mut archive = craft_penpot::Archive::open(&bytes)?;
|
||||
for file in archive.files.clone() {
|
||||
let header = archive.json(&format!("files/{}.json", file.id)).ok();
|
||||
for page in archive.pages(&file.id)? { // sorted by index
|
||||
let Some(page_id) = page.get("id").and_then(|v| v.as_str()) else { continue };
|
||||
let shapes = archive.page_shapes(&file.id, page_id)?; // id -> shape JSON
|
||||
let root = shapes.get(craft_penpot::ROOT_ID);
|
||||
}
|
||||
let colors = archive.uuid_members(&format!("files/{}/colors", file.id), 2048);
|
||||
let image = archive.media(&file.id, media_id, max_bytes); // Option<Vec<u8>>
|
||||
}
|
||||
let fonts = archive.embedded_fonts(); // counted by signature
|
||||
archive.check()?; // no budget was overrun
|
||||
```
|
||||
|
||||
Members a caller treats as optional return errors it may drop; `check` then says whether a
|
||||
budget ran out along the way, so exhaustion is never swallowed.
|
||||
|
||||
## Writing
|
||||
|
||||
```rust
|
||||
let manifest = craft_penpot::manifest("my-app/1.0", &[(file_id, "Name", features)]);
|
||||
let bytes = craft_penpot::write_archive([("manifest.json", manifest_bytes), (path, member_bytes)])?;
|
||||
```
|
||||
|
||||
`write_archive` refuses paths `Archive::open` would refuse.
|
||||
|
||||
## Limits
|
||||
|
||||
Input is hostile. Nothing panics; every limit has a test.
|
||||
|
||||
| Limit | Value |
|
||||
|---|---|
|
||||
| The archive (`MAX_ARCHIVE`) | 512 MiB |
|
||||
| ZIP members (`MAX_ENTRIES`) | 100 000 |
|
||||
| Bytes unpacked, by declared sizes at open and by bytes actually read after (`MAX_DECOMPRESSED`) | 1 GiB |
|
||||
| A JSON member (`MAX_JSON`) | 16 MiB |
|
||||
| JSON values and keys, every member together, counted before parsing (`MAX_JSON_VALUES`) | 4 000 000 |
|
||||
| Shape JSON for one page (`MAX_PAGE_SHAPES_BYTES`) | 128 MiB |
|
||||
| Shapes on one page (`MAX_PAGE_SHAPES`) | 50 000 |
|
||||
| Pages in one file (`MAX_PAGES`) | 500 |
|
||||
| Files in the manifest (`MAX_FILES`) | 50 |
|
||||
| A media object (`MAX_IMAGE`; `media` also takes the caller's own cap) | 128 MiB |
|
||||
| JSON nesting | serde_json's 128 levels |
|
||||
|
||||
Duplicate member names (told by the directory's own member count, since the ZIP reader keeps one
|
||||
member per name), absolute paths, `.` and `..` parts, backslashes and control characters
|
||||
fail the archive. Ids are checked to be UUIDs before they are used in a member path.
|
||||
|
||||
## 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 shapes (frames, groups, paths, text content, fills and strokes, layout,
|
||||
components and variants, tokens, interactions). FigCraft maps the JSON straight onto its own
|
||||
document today; a model mirroring the file's JSON is the next step.
|
||||
@@ -0,0 +1,442 @@
|
||||
//! The Penpot 2.x export container (`.penpot`: `penpot/export-files`, version 1), read and
|
||||
//! written offline.
|
||||
//!
|
||||
//! The archive is a ZIP of JSON members and blobs:
|
||||
//!
|
||||
//! - `manifest.json`: `type` (`penpot/export-files`), `version` (1) and `files`, each with its
|
||||
//! `id` (a UUID) and `name`.
|
||||
//! - `files/<file>.json`: the file's header (features, version, migrations, options).
|
||||
//! - `files/<file>/pages/<page>.json`: a page (`id`, `name`, `index`, background, guides, flows).
|
||||
//! - `files/<file>/pages/<page>/<shape>.json`: one shape each; the page's root frame has the nil
|
||||
//! UUID ([`ROOT_ID`]) and lists its children in `shapes`.
|
||||
//! - `files/<file>/components/`, `colors/`, `typographies/`, `media/`: one `<uuid>.json` each.
|
||||
//! - `files/<file>/tokens.json`: design tokens.
|
||||
//! - `objects/<uuid>.json` and `objects/<uuid>.<ext>`: stored objects (images, fonts).
|
||||
//!
|
||||
//! This crate is the container layer: [`Archive`] validates the ZIP, checks the manifest and
|
||||
//! hands out members as the JSON the file holds ([`serde_json::Value`]) and media as bytes;
|
||||
//! [`write_archive`] writes members back. What the shapes mean is read from that JSON by the
|
||||
//! caller.
|
||||
//!
|
||||
//! Everything here reads untrusted bytes: the archive size, the member count, member names, the
|
||||
//! bytes actually unpacked and the amount of JSON structure are bounded; malformed input is an
|
||||
//! error, never a panic.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::io::{self, Cursor, Read, Write};
|
||||
|
||||
use serde_json::Value;
|
||||
use zip::ZipArchive;
|
||||
|
||||
/// Largest JSON member read.
|
||||
pub const MAX_JSON: u64 = 16 * 1024 * 1024;
|
||||
/// Most JSON values and object keys parsed from one archive, every member together.
|
||||
pub const MAX_JSON_VALUES: usize = 4_000_000;
|
||||
/// Most shape JSON read for one page.
|
||||
pub const MAX_PAGE_SHAPES_BYTES: u64 = 128 * 1024 * 1024;
|
||||
/// Largest media object read.
|
||||
pub const MAX_IMAGE: u64 = 128 * 1024 * 1024;
|
||||
/// Largest archive opened.
|
||||
pub const MAX_ARCHIVE: usize = 512 * 1024 * 1024;
|
||||
/// Most bytes unpacked from one archive: checked against the declared sizes when it is opened
|
||||
/// and against the bytes actually read after that.
|
||||
pub const MAX_DECOMPRESSED: u64 = 1024 * 1024 * 1024;
|
||||
/// Most ZIP members.
|
||||
pub const MAX_ENTRIES: usize = 100_000;
|
||||
/// Most files in the manifest.
|
||||
pub const MAX_FILES: usize = 50;
|
||||
/// Most pages in one file.
|
||||
pub const MAX_PAGES: usize = 500;
|
||||
/// Most shapes on one page.
|
||||
pub const MAX_PAGE_SHAPES: usize = 50_000;
|
||||
/// The id of a page's root frame.
|
||||
pub const ROOT_ID: &str = "00000000-0000-0000-0000-000000000000";
|
||||
/// The manifest's `type`.
|
||||
pub const MANIFEST_TYPE: &str = "penpot/export-files";
|
||||
|
||||
/// Why a Penpot archive could not be read or written.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum Error {
|
||||
/// The bytes are not a readable Penpot export.
|
||||
#[error("Penpot: {0}")]
|
||||
Corrupt(String),
|
||||
/// Writing failed.
|
||||
#[error("{0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
fn corrupt(msg: impl Into<String>) -> Error {
|
||||
Error::Corrupt(msg.into())
|
||||
}
|
||||
|
||||
/// Whether `s` has the shape of a UUID. Ids are checked before they are used in member paths.
|
||||
pub fn is_uuid(s: &str) -> bool {
|
||||
s.len() == 36 && s.bytes().enumerate().all(|(i, b)| if [8, 13, 18, 23].contains(&i) { b == b'-' } else { b.is_ascii_hexdigit() })
|
||||
}
|
||||
|
||||
/// Whether a member name is safe to look up: relative, `/`-separated, no `.` or `..` parts and no
|
||||
/// control characters. Nothing is ever extracted to disk, but an ambiguous name can make one
|
||||
/// member be inspected and another be read.
|
||||
fn safe_name(name: &str) -> bool {
|
||||
!(name.starts_with('/') || name.contains('\\') || name.split('/').any(|part| part == "." || part == "..") || name.chars().any(char::is_control))
|
||||
}
|
||||
|
||||
/// The number of members the archive's end-of-central-directory record declares (the ZIP64 record
|
||||
/// when the classic one is saturated). `None` when no record is found.
|
||||
/// shortcut: reads the two records by hand; goes away with the shared bounded ZIP reader (#126).
|
||||
fn declared_entries(bytes: &[u8]) -> Option<u64> {
|
||||
let eocd = bytes.windows(4).rposition(|w| w == b"PK\x05\x06")?;
|
||||
let count = u16::from_le_bytes(bytes.get(eocd + 10..eocd + 12)?.try_into().ok()?);
|
||||
if count != u16::MAX {
|
||||
return Some(u64::from(count));
|
||||
}
|
||||
let zip64 = bytes.get(..eocd)?.windows(4).rposition(|w| w == b"PK\x06\x06")?;
|
||||
Some(u64::from_le_bytes(bytes.get(zip64 + 32..zip64 + 40)?.try_into().ok()?))
|
||||
}
|
||||
|
||||
/// Counts actual ZIP output and JSON structure throughout one archive, independently of member
|
||||
/// header sizes and the serialized size of dense arrays and objects.
|
||||
struct Budget {
|
||||
remaining: u64,
|
||||
exceeded: bool,
|
||||
json_values: usize,
|
||||
json_exceeded: bool,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
fn check(&self) -> Result<(), Error> {
|
||||
if self.exceeded {
|
||||
Err(corrupt("actual decompressed ZIP contents exceed 1 GiB"))
|
||||
} else if self.json_exceeded {
|
||||
Err(corrupt("JSON structure exceeds import value budget"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit JSON before serde allocates its tree. Outside strings, each container, comma and
|
||||
/// colon covers at least one value or object key; counting one root as well deliberately
|
||||
/// overestimates structure. Serde still validates syntax.
|
||||
fn parse_json(&mut self, data: &[u8], path: &str) -> Result<Value, Error> {
|
||||
self.check()?;
|
||||
let mut values = 1;
|
||||
let (mut quoted, mut escaped) = (false, false);
|
||||
for &byte in data {
|
||||
if quoted {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if byte == b'\\' {
|
||||
escaped = true;
|
||||
} else if byte == b'"' {
|
||||
quoted = false;
|
||||
}
|
||||
} else if byte == b'"' {
|
||||
quoted = true;
|
||||
} else if matches!(byte, b'[' | b'{' | b',' | b':') {
|
||||
values += 1;
|
||||
}
|
||||
}
|
||||
if values > self.json_values {
|
||||
self.json_exceeded = true;
|
||||
return Err(corrupt("JSON structure exceeds import value budget"));
|
||||
}
|
||||
self.json_values -= values;
|
||||
serde_json::from_slice(data).map_err(|e| corrupt(format!("{path}: {e}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Charges every byte read to the archive's [`Budget`].
|
||||
struct BudgetReader<'a, R> {
|
||||
inner: R,
|
||||
budget: &'a mut Budget,
|
||||
}
|
||||
|
||||
impl<R: Read> Read for BudgetReader<'_, R> {
|
||||
fn read(&mut self, out: &mut [u8]) -> io::Result<usize> {
|
||||
if out.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
if self.budget.remaining == 0 {
|
||||
// Distinguish exact-budget EOF from data past the limit.
|
||||
let mut probe = [0_u8; 1];
|
||||
return match self.inner.read(&mut probe)? {
|
||||
0 => Ok(0),
|
||||
_ => {
|
||||
self.budget.exceeded = true;
|
||||
Err(io::Error::other("Penpot decompression budget exhausted"))
|
||||
}
|
||||
};
|
||||
}
|
||||
let limit = out.len().min(usize::try_from(self.budget.remaining).unwrap_or(usize::MAX));
|
||||
let read = self.inner.read(out.get_mut(..limit).unwrap_or_default())?;
|
||||
self.budget.remaining -= read as u64;
|
||||
Ok(read)
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry of the manifest's `files`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct FileEntry {
|
||||
/// The file's UUID: its members are under `files/<id>`.
|
||||
pub id: String,
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// An opened Penpot export.
|
||||
pub struct Archive<'b> {
|
||||
zip: ZipArchive<Cursor<&'b [u8]>>,
|
||||
/// Member names in archive order.
|
||||
names: Vec<String>,
|
||||
budget: Budget,
|
||||
/// `manifest.json`.
|
||||
pub manifest: Value,
|
||||
/// The manifest's files, in its order (1 to [`MAX_FILES`]).
|
||||
pub files: Vec<FileEntry>,
|
||||
}
|
||||
|
||||
impl<'b> Archive<'b> {
|
||||
/// Open `bytes`: the ZIP is validated whole (size, member count, duplicate and unsafe names,
|
||||
/// declared unpacked total) before any JSON is decoded, then the manifest is read and checked.
|
||||
pub fn open(bytes: &'b [u8]) -> Result<Self, Error> {
|
||||
if bytes.len() > MAX_ARCHIVE {
|
||||
return Err(corrupt("archive exceeds 512 MiB"));
|
||||
}
|
||||
let mut zip = ZipArchive::new(Cursor::new(bytes)).map_err(|e| corrupt(format!("invalid ZIP: {e}")))?;
|
||||
if zip.len() > MAX_ENTRIES {
|
||||
return Err(corrupt("too many ZIP members"));
|
||||
}
|
||||
// The ZIP reader keeps one member per name, so two members of one name never reach the
|
||||
// loop below: the directory's own count tells them.
|
||||
if declared_entries(bytes) != Some(zip.len() as u64) {
|
||||
return Err(corrupt("duplicate ZIP member names"));
|
||||
}
|
||||
// Reject oversized logical archives before decoding any JSON. This guards against ZIP bombs
|
||||
// whose compressed size is innocuous but whose advertised uncompressed size is enormous.
|
||||
let mut expanded = 0_u64;
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut names = Vec::with_capacity(zip.len());
|
||||
for index in 0..zip.len() {
|
||||
let member = zip.by_index(index).map_err(|e| corrupt(e.to_string()))?;
|
||||
let name = member.name().map_err(|e| corrupt(e.to_string()))?.into_owned();
|
||||
// Reject duplicates and traversal instead of guessing which version is real.
|
||||
if !seen.insert(name.clone()) {
|
||||
return Err(corrupt(format!("duplicate ZIP member: {}", name.chars().take(200).collect::<String>())));
|
||||
}
|
||||
if !safe_name(&name) {
|
||||
return Err(corrupt("unsafe ZIP member path"));
|
||||
}
|
||||
expanded = expanded.saturating_add(member.size());
|
||||
if expanded > MAX_DECOMPRESSED {
|
||||
return Err(corrupt("ZIP contents exceed 1 GiB"));
|
||||
}
|
||||
names.push(name);
|
||||
}
|
||||
let budget = Budget { remaining: MAX_DECOMPRESSED, exceeded: false, json_values: MAX_JSON_VALUES, json_exceeded: false };
|
||||
let mut archive = Archive { zip, names, budget, manifest: Value::Null, files: Vec::new() };
|
||||
let manifest = archive.json("manifest.json")?;
|
||||
if manifest.get("type").and_then(Value::as_str) != Some(MANIFEST_TYPE) {
|
||||
return Err(corrupt("not a Penpot export-files archive"));
|
||||
}
|
||||
if manifest.get("version").and_then(Value::as_u64) != Some(1) {
|
||||
return Err(corrupt("unsupported Penpot export version"));
|
||||
}
|
||||
let files = manifest.get("files").and_then(Value::as_array).ok_or_else(|| corrupt("no files in manifest"))?;
|
||||
if files.is_empty() || files.len() > MAX_FILES {
|
||||
return Err(corrupt("invalid number of Penpot files"));
|
||||
}
|
||||
for file in files {
|
||||
let Some(id) = file.get("id").and_then(Value::as_str).filter(|id| is_uuid(id)) else {
|
||||
return Err(corrupt("invalid file UUID"));
|
||||
};
|
||||
archive.files.push(FileEntry { id: id.to_owned(), name: file.get("name").and_then(Value::as_str).map(str::to_owned) });
|
||||
}
|
||||
archive.manifest = manifest;
|
||||
Ok(archive)
|
||||
}
|
||||
|
||||
/// Fails once a read ran past [`MAX_DECOMPRESSED`] or [`MAX_JSON_VALUES`]. A caller that
|
||||
/// treats some members as optional (and so drops their errors) calls this afterwards, so that
|
||||
/// an exhausted budget is never swallowed.
|
||||
pub fn check(&self) -> Result<(), Error> {
|
||||
self.budget.check()
|
||||
}
|
||||
|
||||
/// Bytes this archive may still unpack.
|
||||
pub fn bytes_left(&self) -> u64 {
|
||||
self.budget.remaining
|
||||
}
|
||||
|
||||
/// Whether the archive has member `path`.
|
||||
pub fn has(&self, path: &str) -> bool {
|
||||
self.zip.index_for_name(path).is_some()
|
||||
}
|
||||
|
||||
/// Every member name, in archive order.
|
||||
pub fn names(&self) -> &[String] {
|
||||
&self.names
|
||||
}
|
||||
|
||||
/// The members `<dir>/<uuid>.json`, in archive order, at most `max` of them. `Err` when there
|
||||
/// are more: the caller decides whether that fails the file or truncates the list.
|
||||
pub fn uuid_members(&self, dir: &str, max: usize) -> Result<Vec<String>, Vec<String>> {
|
||||
let found: Vec<String> = self
|
||||
.names
|
||||
.iter()
|
||||
.filter(|name| {
|
||||
name.strip_prefix(dir)
|
||||
.and_then(|rest| rest.strip_prefix('/'))
|
||||
.is_some_and(|part| part.len() == 41 && part.ends_with(".json") && part.get(..36).is_some_and(is_uuid))
|
||||
})
|
||||
.take(max.saturating_add(1))
|
||||
.cloned()
|
||||
.collect();
|
||||
if found.len() > max { Err(found.into_iter().take(max).collect()) } else { Ok(found) }
|
||||
}
|
||||
|
||||
/// JSON member `path`, within [`MAX_JSON`] and the archive's budgets.
|
||||
pub fn json(&mut self, path: &str) -> Result<Value, Error> {
|
||||
let data = self.read(path, MAX_JSON, MAX_JSON, || format!("JSON member too large: {path}"))?;
|
||||
self.budget.parse_json(&data, path)
|
||||
}
|
||||
|
||||
/// Member `path` read whole when it is at most `declared_max` bytes by its header and at most
|
||||
/// `max` bytes read.
|
||||
fn read(&mut self, path: &str, declared_max: u64, max: u64, too_large: impl Fn() -> String) -> Result<Vec<u8>, Error> {
|
||||
let entry = self.zip.by_name(path).map_err(|e| corrupt(format!("{path}: {e}")))?;
|
||||
if entry.size() > declared_max {
|
||||
return Err(corrupt(too_large()));
|
||||
}
|
||||
let mut data = Vec::new();
|
||||
BudgetReader { inner: entry, budget: &mut self.budget }
|
||||
.take(max.saturating_add(1))
|
||||
.read_to_end(&mut data)
|
||||
.map_err(|e| corrupt(e.to_string()))?;
|
||||
if data.len() as u64 > max {
|
||||
return Err(corrupt(too_large()));
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Shape JSON member `path`, within [`MAX_JSON`] and what is left of `page_budget` (bytes of
|
||||
/// shape JSON for its page, counted down).
|
||||
pub fn shape_json(&mut self, path: &str, page_budget: &mut u64) -> Result<Value, Error> {
|
||||
let declared = self.zip.by_name(path).map_err(|e| corrupt(format!("{path}: {e}")))?.size();
|
||||
if declared > MAX_JSON {
|
||||
return Err(corrupt(format!("JSON member too large: {path}")));
|
||||
}
|
||||
if declared > *page_budget {
|
||||
return Err(corrupt("page shapes exceed byte budget"));
|
||||
}
|
||||
let entry = self.zip.by_name(path).map_err(|e| corrupt(format!("{path}: {e}")))?;
|
||||
let mut data = Vec::new();
|
||||
let max_read = (MAX_JSON + 1).min(page_budget.saturating_add(1));
|
||||
BudgetReader { inner: entry, budget: &mut self.budget }.take(max_read).read_to_end(&mut data).map_err(|e| corrupt(e.to_string()))?;
|
||||
if data.len() as u64 > MAX_JSON {
|
||||
return Err(corrupt(format!("JSON member too large: {path}")));
|
||||
}
|
||||
if data.len() as u64 > *page_budget {
|
||||
return Err(corrupt("page shapes exceed byte budget"));
|
||||
}
|
||||
*page_budget -= data.len() as u64;
|
||||
self.budget.parse_json(&data, path)
|
||||
}
|
||||
|
||||
/// The pages of file `file_id` (`files/<file>/pages/<uuid>.json`), sorted by their `index`
|
||||
/// (pages without one last, ties in archive order). Fails past [`MAX_PAGES`].
|
||||
pub fn pages(&mut self, file_id: &str) -> Result<Vec<Value>, Error> {
|
||||
let paths = self.uuid_members(&format!("files/{file_id}/pages"), MAX_PAGES).map_err(|_| corrupt("too many pages"))?;
|
||||
let mut pages = Vec::with_capacity(paths.len());
|
||||
for path in paths {
|
||||
pages.push(self.json(&path)?);
|
||||
}
|
||||
pages.sort_by_key(|p| p.get("index").and_then(Value::as_i64).unwrap_or(i64::MAX));
|
||||
Ok(pages)
|
||||
}
|
||||
|
||||
/// The shapes of a page (`files/<file>/pages/<page>/<uuid>.json`) by their `id`; a shape
|
||||
/// without a UUID `id` is left out. Fails past [`MAX_PAGE_SHAPES`] shapes or
|
||||
/// [`MAX_PAGE_SHAPES_BYTES`] of shape JSON.
|
||||
pub fn page_shapes(&mut self, file_id: &str, page_id: &str) -> Result<BTreeMap<String, Value>, Error> {
|
||||
let paths =
|
||||
self.uuid_members(&format!("files/{file_id}/pages/{page_id}"), MAX_PAGE_SHAPES).map_err(|_| corrupt("too many layers on a page"))?;
|
||||
let mut shapes = BTreeMap::new();
|
||||
let mut page_budget = MAX_PAGE_SHAPES_BYTES;
|
||||
for path in paths {
|
||||
let shape = self.shape_json(&path, &mut page_budget)?;
|
||||
if let Some(id) = shape.get("id").and_then(Value::as_str).filter(|id| is_uuid(id)) {
|
||||
shapes.insert(id.to_owned(), shape);
|
||||
}
|
||||
}
|
||||
Ok(shapes)
|
||||
}
|
||||
|
||||
/// The bytes of media `media_id` of file `file_id`: `files/<file>/media/<id>.json` names the
|
||||
/// stored object (`mediaId`) and its type (`mtype`), and the bytes are
|
||||
/// `objects/<object>.<ext>` (`jpg`, `webp` or `png`). `None` when either is missing, the ids
|
||||
/// are not UUIDs, or the object is larger than `max` bytes or [`MAX_IMAGE`].
|
||||
pub fn media(&mut self, file_id: &str, media_id: &str, max: u64) -> Option<Vec<u8>> {
|
||||
if !is_uuid(file_id) || !is_uuid(media_id) {
|
||||
return None;
|
||||
}
|
||||
let meta = self.json(&format!("files/{file_id}/media/{media_id}.json")).ok()?;
|
||||
let object = meta.get("mediaId").and_then(Value::as_str).filter(|id| is_uuid(id))?;
|
||||
let extension = match meta.get("mtype").and_then(Value::as_str) {
|
||||
Some("image/jpeg") => "jpg",
|
||||
Some("image/webp") => "webp",
|
||||
Some("image/png") => "png",
|
||||
_ => "unknown",
|
||||
};
|
||||
self.read(&format!("objects/{object}.{extension}"), MAX_IMAGE, max.min(MAX_IMAGE), String::new).ok()
|
||||
}
|
||||
|
||||
/// How many members under `objects/`, `fonts/` and `assets/fonts/` start with a font
|
||||
/// signature (TrueType, OpenType, a collection, WOFF or WOFF2). Four bytes of each are read;
|
||||
/// no payload is decoded.
|
||||
pub fn embedded_fonts(&mut self) -> usize {
|
||||
let names: Vec<String> =
|
||||
self.names.iter().filter(|n| n.starts_with("objects/") || n.starts_with("fonts/") || n.starts_with("assets/fonts/")).cloned().collect();
|
||||
let mut count = 0;
|
||||
for name in names {
|
||||
let Ok(member) = self.zip.by_name(&name) else { continue };
|
||||
let mut header = [0; 4];
|
||||
if (BudgetReader { inner: member, budget: &mut self.budget }).read_exact(&mut header).is_ok()
|
||||
&& (matches!(&header, b"OTTO" | b"true" | b"ttcf" | b"wOFF" | b"wOF2") || header == [0, 1, 0, 0])
|
||||
{
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
/// A `manifest.json` for `files` (id, name, the file's `features`), saying which program wrote it.
|
||||
pub fn manifest(generated_by: &str, files: &[(&str, &str, Value)]) -> Value {
|
||||
let files: Vec<Value> = files.iter().map(|(id, name, features)| serde_json::json!({"id": id, "name": name, "features": features})).collect();
|
||||
serde_json::json!({"type": MANIFEST_TYPE, "version": 1, "generatedBy": generated_by, "refer": "penpot", "files": files, "relations": []})
|
||||
}
|
||||
|
||||
/// A Penpot archive of `members` (path, bytes), deflated, in the order given. Paths must be safe
|
||||
/// and distinct, as [`Archive::open`] requires of what it reads.
|
||||
pub fn write_archive<'a>(members: impl IntoIterator<Item = (&'a str, &'a [u8])>) -> Result<Vec<u8>, Error> {
|
||||
let io = |e: &dyn std::fmt::Display| Error::Io(e.to_string());
|
||||
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated).compression_level(Some(3));
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
{
|
||||
let mut z = zip::ZipWriter::new(&mut buf);
|
||||
let mut seen = BTreeSet::new();
|
||||
for (path, bytes) in members {
|
||||
if path.is_empty() || !safe_name(path) || !seen.insert(path) {
|
||||
return Err(Error::Io(format!("unsafe or repeated member path: {}", path.chars().take(200).collect::<String>())));
|
||||
}
|
||||
z.start_file(path, options).map_err(|e| io(&e))?;
|
||||
z.write_all(bytes).map_err(|e| io(&e))?;
|
||||
}
|
||||
z.finish().map_err(|e| io(&e))?;
|
||||
}
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
//! The archive, its manifest, members and budgets, on archives built in code.
|
||||
#![allow(clippy::unwrap_used, clippy::panic)]
|
||||
|
||||
use serde_json::json;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
use super::*;
|
||||
|
||||
const FILE: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
|
||||
const PAGE: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
|
||||
const SHAPE: &str = "cccccccc-cccc-cccc-cccc-cccccccccccc";
|
||||
const MEDIA: &str = "dddddddd-dddd-dddd-dddd-dddddddddddd";
|
||||
const OBJECT: &str = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee";
|
||||
|
||||
fn zip_of(members: &[(String, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, data) in members {
|
||||
z.start_file(name.as_str(), SimpleFileOptions::default()).unwrap();
|
||||
z.write_all(data).unwrap();
|
||||
}
|
||||
z.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn good_manifest() -> (String, Vec<u8>) {
|
||||
("manifest.json".into(), json!({"type": MANIFEST_TYPE, "version": 1, "files": [{"id": FILE, "name": "Advanced"}]}).to_string().into_bytes())
|
||||
}
|
||||
|
||||
fn member(path: String, value: Value) -> (String, Vec<u8>) {
|
||||
(path, value.to_string().into_bytes())
|
||||
}
|
||||
|
||||
/// One file with one page: the root frame, one rectangle, a component, a colour, an image.
|
||||
fn members() -> Vec<(String, Vec<u8>)> {
|
||||
vec![
|
||||
good_manifest(),
|
||||
member(format!("files/{FILE}.json"), json!({"features": ["components/v2"], "version": 67})),
|
||||
member(format!("files/{FILE}/pages/{PAGE}.json"), json!({"id": PAGE, "name": "Canvas", "index": 0})),
|
||||
member(format!("files/{FILE}/pages/{PAGE}/{ROOT_ID}.json"), json!({"id": ROOT_ID, "type": "frame", "shapes": [SHAPE]})),
|
||||
member(format!("files/{FILE}/pages/{PAGE}/{SHAPE}.json"), json!({"id": SHAPE, "type": "rect", "parentId": ROOT_ID})),
|
||||
member(format!("files/{FILE}/components/{SHAPE}.json"), json!({"id": SHAPE, "name": "Button"})),
|
||||
member(format!("files/{FILE}/colors/{SHAPE}.json"), json!({"id": SHAPE, "color": "#ff0000"})),
|
||||
member(format!("files/{FILE}/tokens.json"), json!({"$themes": []})),
|
||||
member(format!("files/{FILE}/media/{MEDIA}.json"), json!({"id": MEDIA, "mediaId": OBJECT, "mtype": "image/png"})),
|
||||
member(format!("objects/{OBJECT}.json"), json!({"id": OBJECT, "contentType": "image/png"})),
|
||||
(format!("objects/{OBJECT}.png"), b"\x89PNG-bytes".to_vec()),
|
||||
]
|
||||
}
|
||||
|
||||
fn open_err(members: &[(String, Vec<u8>)]) -> String {
|
||||
Archive::open(&zip_of(members)).err().map(|e| e.to_string()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_archive_opens_and_hands_out_its_members() {
|
||||
let bytes = zip_of(&members());
|
||||
let mut a = Archive::open(&bytes).unwrap();
|
||||
assert_eq!(a.files, [FileEntry { id: FILE.into(), name: Some("Advanced".into()) }]);
|
||||
assert_eq!(a.manifest["type"], MANIFEST_TYPE);
|
||||
assert_eq!(a.json(&format!("files/{FILE}.json")).unwrap()["version"], 67);
|
||||
let pages = a.pages(FILE).unwrap();
|
||||
assert_eq!(pages.len(), 1);
|
||||
assert_eq!(pages[0]["name"], "Canvas");
|
||||
let shapes = a.page_shapes(FILE, PAGE).unwrap();
|
||||
assert_eq!(shapes.keys().map(String::as_str).collect::<Vec<_>>(), [ROOT_ID, SHAPE]);
|
||||
assert_eq!(shapes[ROOT_ID]["shapes"][0], SHAPE);
|
||||
assert_eq!(a.uuid_members(&format!("files/{FILE}/components"), 10).unwrap(), [format!("files/{FILE}/components/{SHAPE}.json")]);
|
||||
assert!(a.has(&format!("files/{FILE}/tokens.json")) && !a.has("files/tokens.json"));
|
||||
assert_eq!(a.media(FILE, MEDIA, 1 << 20).as_deref(), Some(&b"\x89PNG-bytes"[..]));
|
||||
assert_eq!(a.names().len(), 11);
|
||||
assert_eq!(a.embedded_fonts(), 0);
|
||||
assert!(a.check().is_ok());
|
||||
assert!(a.bytes_left() < MAX_DECOMPRESSED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_written_archive_reads_back() {
|
||||
let all = members();
|
||||
let manifest = serde_json::to_vec(&manifest("craft-penpot/test", &[(FILE, "Advanced", json!(["components/v2"]))])).unwrap();
|
||||
let mut list: Vec<(&str, &[u8])> = vec![("manifest.json", &manifest)];
|
||||
list.extend(all.iter().skip(1).map(|(n, b)| (n.as_str(), b.as_slice())));
|
||||
let bytes = write_archive(list).unwrap();
|
||||
let mut a = Archive::open(&bytes).unwrap();
|
||||
assert_eq!(
|
||||
a.manifest,
|
||||
json!({"type": "penpot/export-files", "version": 1, "generatedBy": "craft-penpot/test", "refer": "penpot",
|
||||
"files": [{"id": FILE, "name": "Advanced", "features": ["components/v2"]}], "relations": []})
|
||||
);
|
||||
assert_eq!(a.page_shapes(FILE, PAGE).unwrap().len(), 2);
|
||||
assert_eq!(a.names().first().map(String::as_str), Some("manifest.json"));
|
||||
// What the reader would refuse is not written.
|
||||
for bad in ["../x", "/abs", "a/./b", "a\\b", "", "a\u{1}b"] {
|
||||
assert!(write_archive([(bad, &b"x"[..])]).is_err(), "{bad:?}");
|
||||
}
|
||||
assert!(write_archive([("a", &b"x"[..]), ("a", &b"y"[..])]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_manifest_is_checked() {
|
||||
let with = |manifest: Value| open_err(&[member("manifest.json".into(), manifest)]);
|
||||
assert!(open_err(&[("other.json".into(), b"{}".to_vec())]).contains("manifest.json"));
|
||||
assert!(with(json!({"type": "something/else", "version": 1, "files": [{"id": FILE}]})).contains("not a Penpot export-files archive"));
|
||||
assert!(with(json!({"type": MANIFEST_TYPE, "version": 2, "files": [{"id": FILE}]})).contains("unsupported Penpot export version"));
|
||||
assert!(with(json!({"type": MANIFEST_TYPE, "version": 1})).contains("no files in manifest"));
|
||||
assert!(with(json!({"type": MANIFEST_TYPE, "version": 1, "files": []})).contains("invalid number of Penpot files"));
|
||||
let many: Vec<Value> = (0..=MAX_FILES).map(|_| json!({"id": FILE})).collect();
|
||||
assert!(with(json!({"type": MANIFEST_TYPE, "version": 1, "files": many})).contains("invalid number of Penpot files"));
|
||||
assert!(with(json!({"type": MANIFEST_TYPE, "version": 1, "files": [{"id": "../../etc"}]})).contains("invalid file UUID"));
|
||||
assert!(with(json!({"type": MANIFEST_TYPE, "version": 1, "files": [{"name": "No id"}]})).contains("invalid file UUID"));
|
||||
// Not a ZIP at all, and an empty one.
|
||||
assert!(Archive::open(b"not a zip").is_err());
|
||||
assert!(Archive::open(b"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_uuids() {
|
||||
assert!(is_uuid(ROOT_ID) && is_uuid(FILE) && is_uuid("0123abcd-0123-ABCD-0123-0123456789ab"));
|
||||
for bad in [
|
||||
"",
|
||||
"aaaaaaaa",
|
||||
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa",
|
||||
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaaa",
|
||||
"aaaaaaaa/aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"gaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
] {
|
||||
assert!(!is_uuid(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsafe_and_duplicate_member_names_are_refused() {
|
||||
for bad in ["../evil.json", "files/../x.json", "a/./b.json", "back\\slash.json", "ctrl\u{7}.json"] {
|
||||
let e = open_err(&[good_manifest(), (bad.into(), b"{}".to_vec())]);
|
||||
assert!(e.contains("unsafe ZIP member path"), "{bad}: {e}");
|
||||
}
|
||||
// Two members of one name: written under two names of the same length, then renamed in the
|
||||
// local header and in the directory.
|
||||
let mut bytes = zip_of(&[good_manifest(), ("a.json".into(), b"{}".to_vec()), ("b.json".into(), b"[]".to_vec())]);
|
||||
let mut renamed = 0;
|
||||
for i in 0..bytes.len().saturating_sub(6) {
|
||||
if &bytes[i..i + 6] == b"b.json" {
|
||||
bytes[i] = b'a';
|
||||
renamed += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(renamed, 2);
|
||||
assert_eq!(Archive::open(&bytes).err(), Some(Error::Corrupt("duplicate ZIP member names".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_archive_declaring_more_than_it_may_unpack_is_refused_before_reading() {
|
||||
let mut bytes = zip_of(&[good_manifest(), ("big.bin".into(), vec![0; 64])]);
|
||||
// The directory record of big.bin: its uncompressed size, at offset 24.
|
||||
let start = {
|
||||
let mut z = ZipArchive::new(Cursor::new(&bytes[..])).unwrap();
|
||||
z.by_name("big.bin").unwrap().central_header_start() as usize
|
||||
};
|
||||
bytes[start + 24..start + 28].copy_from_slice(&u32::MAX.to_le_bytes());
|
||||
let e = Archive::open(&bytes).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(e.contains("ZIP contents exceed 1 GiB") || e.contains("invalid ZIP"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_many_members_are_refused() {
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
for i in 0..=MAX_ENTRIES {
|
||||
z.start_file(format!("{i}"), stored).unwrap();
|
||||
}
|
||||
let bytes = z.finish().unwrap().into_inner();
|
||||
assert!(bytes.len() < MAX_ARCHIVE);
|
||||
assert_eq!(Archive::open(&bytes).err(), Some(Error::Corrupt("too many ZIP members".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_archive_with_a_zip64_directory_opens() {
|
||||
// More members than the classic end record can count: the ZIP64 record's count is compared.
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let (name, data) = good_manifest();
|
||||
z.start_file(name, stored).unwrap();
|
||||
z.write_all(&data).unwrap();
|
||||
for i in 0..70_000 {
|
||||
z.start_file(format!("{i}"), stored).unwrap();
|
||||
}
|
||||
let bytes = z.finish().unwrap().into_inner();
|
||||
assert_eq!(declared_entries(&bytes), Some(70_001));
|
||||
assert_eq!(Archive::open(&bytes).unwrap().names().len(), 70_001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_byte_budget_counts_bytes_read_even_when_headers_claim_less() {
|
||||
// Two independent readers share the same actual output limit.
|
||||
let mut budget = Budget { remaining: 5, exceeded: false, json_values: MAX_JSON_VALUES, json_exceeded: false };
|
||||
let mut first = Vec::new();
|
||||
BudgetReader { inner: Cursor::new(b"abcd"), budget: &mut budget }.read_to_end(&mut first).unwrap();
|
||||
assert_eq!(first, b"abcd");
|
||||
assert_eq!(budget.remaining, 1);
|
||||
|
||||
let mut second = Vec::new();
|
||||
let result = BudgetReader { inner: Cursor::new(b"xyz"), budget: &mut budget }.read_to_end(&mut second);
|
||||
assert!(result.is_err(), "bytes beyond the combined budget must be rejected");
|
||||
assert_eq!(budget.remaining, 0);
|
||||
assert!(budget.check().is_err());
|
||||
// Ending exactly at the budget is not an overrun.
|
||||
let mut budget = Budget { remaining: 3, exceeded: false, json_values: MAX_JSON_VALUES, json_exceeded: false };
|
||||
let mut exact = Vec::new();
|
||||
BudgetReader { inner: Cursor::new(b"xyz"), budget: &mut budget }.read_to_end(&mut exact).unwrap();
|
||||
assert!(budget.check().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exhausted_byte_budget_fails_the_archive_even_for_optional_members() {
|
||||
let bytes = zip_of(&[good_manifest(), ("a.json".into(), b"[0,0]".to_vec())]);
|
||||
let mut a = Archive::open(&bytes).unwrap();
|
||||
a.budget.remaining = 3;
|
||||
assert!(a.json("a.json").is_err());
|
||||
assert_eq!(a.check().err(), Some(Error::Corrupt("actual decompressed ZIP contents exceed 1 GiB".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_json_value_budget_is_shared_by_shape_and_other_members() {
|
||||
let bytes = zip_of(&[good_manifest(), ("tokens.json".into(), b"[0,0]".to_vec()), ("shape.json".into(), b"[0,0]".to_vec())]);
|
||||
let mut a = Archive::open(&bytes).unwrap();
|
||||
a.budget.json_values = 6;
|
||||
let mut page_bytes = MAX_PAGE_SHAPES_BYTES;
|
||||
assert_eq!(a.json("tokens.json").unwrap(), json!([0, 0]));
|
||||
assert_eq!(a.shape_json("shape.json", &mut page_bytes).unwrap(), json!([0, 0]));
|
||||
assert_eq!(a.budget.json_values, 0);
|
||||
assert!(a.check().is_ok(), "an exact-budget read is allowed");
|
||||
assert!(a.json("tokens.json").is_err());
|
||||
assert!(a.check().is_err(), "optional-member callers cannot swallow exhaustion");
|
||||
}
|
||||
|
||||
fn budget_of(json_values: usize) -> Budget {
|
||||
Budget { remaining: MAX_DECOMPRESSED, exceeded: false, json_values, json_exceeded: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_json_value_budget_rejects_dense_input_before_deserialization() {
|
||||
// The malformed tail would cause a serde syntax error after allocating the array.
|
||||
// Admission must reject it for structure first, while the input is still only bytes.
|
||||
let dense = "[".to_owned() + &"0,".repeat(10_000);
|
||||
let mut budget = budget_of(100);
|
||||
let err = budget.parse_json(dense.as_bytes(), "shape.json").unwrap_err();
|
||||
assert!(err.to_string().contains("value budget"), "{err}");
|
||||
assert!(budget.check().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_json_value_budget_handles_strings_escapes_and_empty_input() {
|
||||
for text in ["[{,:", "\\", "\\\"[{,:", "é"] {
|
||||
let value = Value::String(text.into());
|
||||
let mut budget = budget_of(1);
|
||||
assert_eq!(budget.parse_json(&serde_json::to_vec(&value).unwrap(), "shape.json").unwrap(), value);
|
||||
assert!(budget.check().is_ok());
|
||||
}
|
||||
// An escaped backslash before a closing quote must not hide later containers.
|
||||
let mut budget = budget_of(3);
|
||||
assert!(budget.parse_json(br#"["\\",[0,0]]"#, "shape.json").is_err());
|
||||
assert!(budget.check().is_err());
|
||||
let mut budget = budget_of(0);
|
||||
assert!(budget.parse_json(b"", "shape.json").is_err());
|
||||
assert!(budget.check().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dense_shape_within_the_zip_byte_limits_is_refused() {
|
||||
let dense = format!(r#"{{"id":"{SHAPE}","type":"rect","unused":[{}0]}}"#, "0,".repeat(MAX_JSON_VALUES));
|
||||
assert!(dense.len() < MAX_JSON as usize);
|
||||
let mut all = members();
|
||||
all[4].1 = dense.into_bytes();
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, data) in &all {
|
||||
z.start_file(name.as_str(), SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated)).unwrap();
|
||||
z.write_all(data).unwrap();
|
||||
}
|
||||
let bytes = z.finish().unwrap().into_inner();
|
||||
assert!(bytes.len() < 1 << 20);
|
||||
let mut a = Archive::open(&bytes).unwrap();
|
||||
let err = a.page_shapes(FILE, PAGE).unwrap_err();
|
||||
assert!(err.to_string().contains("value budget"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_page_shape_budget_rejects_shapes_past_its_bytes() {
|
||||
let bytes = zip_of(&[good_manifest(), ("shape.json".into(), br#"{"id":"00000000-0000-0000-0000-000000000001","type":"rect"}"#.to_vec())]);
|
||||
let mut a = Archive::open(&bytes).unwrap();
|
||||
let mut page_budget = 10u64; // Too small for the shape JSON (about 60 bytes).
|
||||
assert_eq!(a.shape_json("shape.json", &mut page_budget).err(), Some(Error::Corrupt("page shapes exceed byte budget".into())));
|
||||
let mut page_budget = 1000u64;
|
||||
assert!(a.shape_json("shape.json", &mut page_budget).is_ok());
|
||||
assert!(page_budget < 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_json_member_past_its_size_is_refused() {
|
||||
let big = format!("\"{}\"", "x".repeat(MAX_JSON as usize));
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let deflated = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
for (name, data) in
|
||||
[good_manifest(), ("big.json".into(), big.into_bytes()), ("fits.json".into(), format!("\"{}\"", "x".repeat(1000)).into_bytes())]
|
||||
{
|
||||
z.start_file(name.as_str(), deflated).unwrap();
|
||||
z.write_all(&data).unwrap();
|
||||
}
|
||||
let bytes = z.finish().unwrap().into_inner();
|
||||
let mut a = Archive::open(&bytes).unwrap();
|
||||
let left = a.bytes_left();
|
||||
assert_eq!(a.json("big.json").err(), Some(Error::Corrupt("JSON member too large: big.json".into())));
|
||||
assert_eq!(a.bytes_left(), left, "refused by its header, before unpacking");
|
||||
let mut page_budget = MAX_PAGE_SHAPES_BYTES;
|
||||
assert!(a.shape_json("big.json", &mut page_budget).is_err());
|
||||
assert!(a.json("fits.json").is_ok());
|
||||
assert!(a.json("missing.json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_and_shapes_past_their_counts_are_refused() {
|
||||
let uuid = |i: usize| format!("{i:08x}-0000-0000-0000-000000000000");
|
||||
let mut all = vec![good_manifest()];
|
||||
all.extend((0..=MAX_PAGES).map(|i| member(format!("files/{FILE}/pages/{}.json", uuid(i)), json!({"id": uuid(i), "index": MAX_PAGES - i}))));
|
||||
let bytes = zip_of(&all);
|
||||
assert_eq!(Archive::open(&bytes).unwrap().pages(FILE).err(), Some(Error::Corrupt("too many pages".into())));
|
||||
all.pop();
|
||||
let bytes = zip_of(&all);
|
||||
let pages = Archive::open(&bytes).unwrap().pages(FILE).unwrap();
|
||||
assert_eq!(pages.len(), MAX_PAGES);
|
||||
// Sorted by index, whatever the archive order.
|
||||
assert_eq!(pages[0]["index"], 1);
|
||||
assert_eq!(pages[0]["id"], uuid(MAX_PAGES - 1));
|
||||
|
||||
let mut z = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
let (name, data) = good_manifest();
|
||||
z.start_file(name, stored).unwrap();
|
||||
z.write_all(&data).unwrap();
|
||||
for i in 0..=MAX_PAGE_SHAPES {
|
||||
z.start_file(format!("files/{FILE}/pages/{PAGE}/{}.json", uuid(i)), stored).unwrap();
|
||||
z.write_all(b"{}").unwrap();
|
||||
}
|
||||
let bytes = z.finish().unwrap().into_inner();
|
||||
let mut a = Archive::open(&bytes).unwrap();
|
||||
assert_eq!(a.page_shapes(FILE, PAGE).err(), Some(Error::Corrupt("too many layers on a page".into())));
|
||||
// A caller that truncates instead gets the first `max`.
|
||||
assert_eq!(a.uuid_members(&format!("files/{FILE}/pages/{PAGE}"), 3).unwrap_err().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_uuid_json_members_of_the_directory_are_listed() {
|
||||
let all = vec![
|
||||
good_manifest(),
|
||||
member(format!("files/{FILE}/colors/{SHAPE}.json"), json!({})),
|
||||
member(format!("files/{FILE}/colors/not-a-uuid.json"), json!({})),
|
||||
member(format!("files/{FILE}/colors/{SHAPE}.txt"), json!({})),
|
||||
member(format!("files/{FILE}/colors/{SHAPE}/{SHAPE}.json"), json!({})),
|
||||
member(format!("files/{FILE}/colorsX/{SHAPE}.json"), json!({})),
|
||||
];
|
||||
let bytes = zip_of(&all);
|
||||
let a = Archive::open(&bytes).unwrap();
|
||||
assert_eq!(a.uuid_members(&format!("files/{FILE}/colors"), 10).unwrap(), [format!("files/{FILE}/colors/{SHAPE}.json")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_is_read_through_its_record_within_the_callers_budget() {
|
||||
let bytes = zip_of(&members());
|
||||
let mut a = Archive::open(&bytes).unwrap();
|
||||
assert_eq!(a.media(FILE, MEDIA, 10).map(|b| b.len()), Some(10));
|
||||
assert_eq!(a.media(FILE, MEDIA, 9), None, "larger than the caller allows");
|
||||
assert_eq!(a.media(FILE, SHAPE, 100), None, "no such media record");
|
||||
assert_eq!(a.media("../..", MEDIA, 100), None);
|
||||
assert_eq!(a.media(FILE, "../../manifest", 100), None);
|
||||
// A record naming an object that isn't a UUID, or a type whose file isn't there.
|
||||
let mut all = members();
|
||||
all[8] = member(format!("files/{FILE}/media/{MEDIA}.json"), json!({"mediaId": "../manifest", "mtype": "image/png"}));
|
||||
assert_eq!(Archive::open(&zip_of(&all)).unwrap().media(FILE, MEDIA, 100), None);
|
||||
all[8] = member(format!("files/{FILE}/media/{MEDIA}.json"), json!({"mediaId": OBJECT, "mtype": "image/jpeg"}));
|
||||
assert_eq!(Archive::open(&zip_of(&all)).unwrap().media(FILE, MEDIA, 100), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fonts_are_counted_by_signature_without_reading_their_payload() {
|
||||
let mut font = b"wOF2".to_vec();
|
||||
font.resize(100_000, 0);
|
||||
let bytes = zip_of(&[
|
||||
good_manifest(),
|
||||
(format!("objects/{OBJECT}"), font),
|
||||
("fonts/a.otf".into(), b"OTTOrest".to_vec()),
|
||||
("assets/fonts/b.ttf".into(), vec![0, 1, 0, 0, 9, 9]),
|
||||
("objects/short".into(), b"wO".to_vec()),
|
||||
(format!("objects/{OBJECT}.png"), b"\x89PNG".to_vec()),
|
||||
("elsewhere/c.ttf".into(), b"true".to_vec()),
|
||||
]);
|
||||
let mut a = Archive::open(&bytes).unwrap();
|
||||
let before = a.bytes_left();
|
||||
assert_eq!(a.embedded_fonts(), 3);
|
||||
// Four bytes of each of the five candidates (two of the short one).
|
||||
assert_eq!(before - a.bytes_left(), 4 * 4 + 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mangled_archives_never_panic() {
|
||||
let bytes = zip_of(&members());
|
||||
let read = |bytes: &[u8]| {
|
||||
if let Ok(mut a) = Archive::open(bytes) {
|
||||
let _ = a.pages(FILE);
|
||||
let _ = a.page_shapes(FILE, PAGE);
|
||||
let _ = a.media(FILE, MEDIA, 1 << 20);
|
||||
let _ = a.embedded_fonts();
|
||||
let _ = a.check();
|
||||
}
|
||||
};
|
||||
for cut in (0..bytes.len()).step_by(5) {
|
||||
read(&bytes[..cut]);
|
||||
}
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
for _ in 0..400 {
|
||||
let mut b = bytes.clone();
|
||||
for _ in 0..6 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
let i = seed as usize % b.len();
|
||||
b[i] = (seed >> 8) as u8;
|
||||
}
|
||||
read(&b);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user