diff --git a/Cargo.lock b/Cargo.lock index 073e2aa33..0a7e71ea7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 371698976..b17dbdd88 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/README.md b/README.md index 1a3986f86..89f8f6cdb 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` 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. diff --git a/crates/penpot/Cargo.toml b/crates/penpot/Cargo.toml new file mode 100644 index 000000000..800377f3f --- /dev/null +++ b/crates/penpot/Cargo.toml @@ -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"] } diff --git a/crates/penpot/README.md b/crates/penpot/README.md new file mode 100644 index 000000000..cfcb6da96 --- /dev/null +++ b/crates/penpot/README.md @@ -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/.json` | the file's header: features, version, migrations, options | +| `files//pages/.json` | a page: `id`, `name`, `index`, background, guides, flows | +| `files//pages//.json` | one shape; the root frame has the nil UUID (`ROOT_ID`) and lists its children in `shapes` | +| `files//components/.json`, `colors/`, `typographies/` | library entries | +| `files//tokens.json` | design tokens | +| `files//media/.json` | a media record: `mediaId` names the stored object, `mtype` its type | +| `objects/.json`, `objects/.` | 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> +} +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. diff --git a/crates/penpot/src/lib.rs b/crates/penpot/src/lib.rs new file mode 100644 index 000000000..fcea41da6 --- /dev/null +++ b/crates/penpot/src/lib.rs @@ -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/.json`: the file's header (features, version, migrations, options). +//! - `files//pages/.json`: a page (`id`, `name`, `index`, background, guides, flows). +//! - `files//pages//.json`: one shape each; the page's root frame has the nil +//! UUID ([`ROOT_ID`]) and lists its children in `shapes`. +//! - `files//components/`, `colors/`, `typographies/`, `media/`: one `.json` each. +//! - `files//tokens.json`: design tokens. +//! - `objects/.json` and `objects/.`: 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) -> 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 { + 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 { + 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 Read for BudgetReader<'_, R> { + fn read(&mut self, out: &mut [u8]) -> io::Result { + 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/`. + pub id: String, + pub name: Option, +} + +/// An opened Penpot export. +pub struct Archive<'b> { + zip: ZipArchive>, + /// Member names in archive order. + names: Vec, + budget: Budget, + /// `manifest.json`. + pub manifest: Value, + /// The manifest's files, in its order (1 to [`MAX_FILES`]). + pub files: Vec, +} + +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 { + 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::()))); + } + 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 `/.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> { + let found: Vec = 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 { + 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, 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 { + 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//pages/.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, 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//pages//.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, 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//media/.json` names the + /// stored object (`mediaId`) and its type (`mtype`), and the bytes are + /// `objects/.` (`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> { + 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 = + 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 = 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) -> Result, 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::()))); + } + 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()) +} diff --git a/crates/penpot/src/tests.rs b/crates/penpot/src/tests.rs new file mode 100644 index 000000000..c949818ce --- /dev/null +++ b/crates/penpot/src/tests.rs @@ -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)]) -> Vec { + 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) { + ("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) { + (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)> { + 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)]) -> 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::>(), [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 = (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); + } +}