From fa1a657476650d870bd5982fa6a0ea83fa69ecba Mon Sep 17 00:00:00 2001 From: ".CooCoo" Date: Mon, 2 Nov 2026 13:29:51 -0800 Subject: [PATCH] Add craft-doc: read PhotoCraft, VectorCraft and EffectCraft documents without the apps (#404) * Add craft-doc: read PhotoCraft, VectorCraft and EffectCraft documents without the apps A new crate, craft-doc, reads the native files of three apps from bytes: PhotoCraft .pcraft (a ZIP bundle), VectorCraft .vectorcraft (JSON, plain or gzip, with the version 4 data after the JSON) and EffectCraft .ecproj (JSON). - sniff tells which app wrote a file from its first bytes. - info gives the format version, the writer the file names, and whether the version is one the crate reads. - renditions returns VectorCraft's preview and PDF and PhotoCraft's thumbnail and composite, when the file has them. - outline returns PhotoCraft's canvas and layer tree, VectorCraft's artboards and top-level layers, and EffectCraft's compositions with their layers. A file whose version is newer than the crate reads is refused with Error::TooNew. Limits bound the JSON, each entry, the total bytes decompressed and the JSON nesting, and each is checked before the bytes it covers are allocated. ZIP entries are read by a small STORE and DEFLATE reader in the crate. The crate depends on serde, serde_json, thiserror and flate2 and builds for wasm32-unknown-unknown. The tests build every document they read: JSON written by hand, PNG, ZIP and gzip bytes assembled in the test. doc.yml runs fmt, clippy, the tests and a wasm32 check. * ci: do not keep the checkout token in the doc workflow The checkout step sets persist-credentials: false, as jpeg.yml and media.yml do. No later step in the job uses the token. * craft-doc: read each format's envelope through its format crate craft-doc no longer keeps its own copies of the envelope types and constants. It reads them with craft-pcraft-format (`MANIFEST`, `FORMAT_VERSION`, `THUMB`, `COMPOSITE`, `check_manifest`), craft-vectorcraft-format (`Head`, `Image`, `sniff`, `check`, `VERSION`, `BLOB_MAGIC`, base64) and craft-ecproj-format (`Head::read`, `SCHEMA_VERSION`). Its own base64 module is removed. A `.pcraft` manifest is still read in one pass without building a `serde_json::Value`, with the checks `read_manifest` applies. The outlines are still read with craft-doc's read-only views of each document, and the public API (`sniff`, `info`, `renditions`, `outline`, `Limits`) does not change. These inputs are now read as the format crates read them: - A `.pcraft` manifest without `generator`, or with a `thumbnail` other than `thumb.png` or a `composite` other than `composite/preview.png`, is malformed for `renditions` and `outline`; `info` still reports its version. A manifest with more than 4,194,304 JSON items is refused with `LimitExceeded`. - A `.vectorcraft` preview or PDF member without `mime` gives no rendition. - `sniff` takes `format` only from a top-level member, so `{"meta":{"format":"vectorcraft"}}` is not a VectorCraft file. tests/envelopes.rs writes a file of each format with the crates' types and reads it through every call, and checks the inputs above. doc.yml also runs when one of the three crates changes. * craft-doc: sniff reads only top-level members of an EffectCraft project `sniff` took any JSON object whose first member is `savedBy` for an EffectCraft project when `"schema"` appeared anywhere in its first bytes, so `{"savedBy":"1.0","metadata":{"schema":1}}` was claimed. It now checks the top-level member names only, one at a time, without keeping them. * craft-doc: bound list elements with max_items; no rendition without a MIME type Limits gains max_items (default 1,048,576): EffectCraft project items and composition layers, VectorCraft artboards and layers, and PhotoCraft layers at every level are counted before each one is read, and a file that lists more gives Error::LimitExceeded. A list of empty objects within max_json no longer expands into gigabytes of layers. A VectorCraft preview or pdf member with an empty mime gives no rendition, as one without a mime does; a preview may be PNG or JPEG. An EffectCraft composition without a duration or a frame rate gets no timing instead of a duration of 0. --- .github/workflows/doc.yml | 34 +++ Cargo.lock | 14 + Cargo.toml | 1 + README.md | 3 + crates/doc/Cargo.toml | 25 ++ crates/doc/README.md | 113 +++++++ crates/doc/src/ecproj.rs | 221 ++++++++++++++ crates/doc/src/lib.rs | 536 +++++++++++++++++++++++++++++++++ crates/doc/src/pcraft.rs | 158 ++++++++++ crates/doc/src/vectorcraft.rs | 302 +++++++++++++++++++ crates/doc/src/zip.rs | 183 +++++++++++ crates/doc/tests/common/mod.rs | 222 ++++++++++++++ crates/doc/tests/envelopes.rs | 138 +++++++++ crates/doc/tests/formats.rs | 264 ++++++++++++++++ crates/doc/tests/hostile.rs | 353 ++++++++++++++++++++++ 15 files changed, 2567 insertions(+) create mode 100644 .github/workflows/doc.yml create mode 100644 crates/doc/Cargo.toml create mode 100644 crates/doc/README.md create mode 100644 crates/doc/src/ecproj.rs create mode 100644 crates/doc/src/lib.rs create mode 100644 crates/doc/src/pcraft.rs create mode 100644 crates/doc/src/vectorcraft.rs create mode 100644 crates/doc/src/zip.rs create mode 100644 crates/doc/tests/common/mod.rs create mode 100644 crates/doc/tests/envelopes.rs create mode 100644 crates/doc/tests/formats.rs create mode 100644 crates/doc/tests/hostile.rs diff --git a/.github/workflows/doc.yml b/.github/workflows/doc.yml new file mode 100644 index 00000000..19156ec0 --- /dev/null +++ b/.github/workflows/doc.yml @@ -0,0 +1,34 @@ +name: Craft documents + +on: + workflow_dispatch: + push: + branches: [main] + paths: ['crates/doc/**', 'crates/pcraft-format/**', 'crates/vectorcraft-format/**', 'crates/ecproj-format/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/doc.yml'] + pull_request: + paths: ['crates/doc/**', 'crates/pcraft-format/**', 'crates/vectorcraft-format/**', 'crates/ecproj-format/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/doc.yml'] + +permissions: + contents: read + +jobs: + doc: + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # macOS runners are kept for releases: macOS runs only when started by hand. + os: ${{ github.event_name == 'workflow_dispatch' && fromJSON('["ubuntu-latest","windows-latest","macos-latest"]') || fromJSON('["ubuntu-latest","windows-latest"]') }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + components: rustfmt, clippy + targets: wasm32-unknown-unknown + - run: cargo fmt --check + - run: cargo clippy -p craft-doc --all-targets --locked -- -D warnings + - run: cargo test -p craft-doc --locked -- --nocapture + - run: cargo check -p craft-doc --all-features --locked --target wasm32-unknown-unknown diff --git a/Cargo.lock b/Cargo.lock index 4a3a9a61..1af6fd17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -798,6 +798,20 @@ dependencies = [ "rfd", ] +[[package]] +name = "craft-doc" +version = "0.1.0" +dependencies = [ + "craft-ecproj-format", + "craft-pcraft-format", + "craft-vectorcraft-format", + "flate2", + "proptest", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-docpath" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 410091e9..24e75777 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ craft-atomic = { path = "crates/atomic" } craft-cms = { path = "crates/cms" } craft-crw = { path = "crates/crw" } craft-dialogs = { path = "crates/dialogs" } +craft-doc = { path = "crates/doc" } craft-docpath = { path = "crates/docpath" } craft-ecproj-format = { path = "crates/ecproj-format" } craft-font-catalog = { path = "crates/font-catalog" } diff --git a/README.md b/README.md index 8f9f68f1..7cf4ff91 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " { + let pdf = craft_doc::renditions(&bytes, &limits)?.into_iter().find(|r| r.kind == RenditionKind::Pdf); + } + Some(kind) => { /* ... */ } + None => { /* not a Craft document */ } +} +let outline = craft_doc::outline(&bytes, &limits)?; +``` + +- `sniff(head) -> Option` reads the first bytes. The first 64 KiB are enough for every file + the apps write. A re-zipped `.pcraft` whose `manifest.json` entry starts after the head is + recognized only when `head` holds the whole file. +- `info(bytes, &Limits) -> Result`: the kind, the version the file declares, the + writer it names, and `supported`, which is `false` when the version is newer than the crate + reads. +- `renditions(bytes, &Limits) -> Result, Error>`: each `Rendition` has a `kind` + (`Pdf`, `Thumbnail`, `Composite`), a `mime` and the `bytes`. +- `outline(bytes, &Limits) -> Result`: `canvas`, `pages` and `layers`. +- `Error` is `Unrecognized`, `Malformed`, `TooNew`, `Unsupported` or `LimitExceeded`. A file + whose version is newer than the crate reads gives `TooNew` from `renditions` and `outline`. + +## What each format gives + +| | `.vectorcraft` | `.pcraft` | `.ecproj` | +|---|---|---|---| +| Container | JSON, plain or gzip; since version 4, large members after the JSON (`\n\0VCBLOBS\0`) | ZIP of STORE or DEFLATE entries | JSON | +| Version read | `version` up to 4 (`"format": "vectorcraft"` or `"drawcraft"`) | `format_version` 1 | `schema` up to 2 | +| Writer | `generator` | `generator` | `savedBy` | +| Renditions | `preview` (Thumbnail) and `pdf` (Pdf), when the file was saved with them | the `thumbnail` and `composite` the manifest names | none | +| Pages | artboards, in points | none | compositions by item id, in pixels, with frame rate and duration | +| Layers | top-level layers | the layer tree with groups | each composition's layers | + +Layers are listed bottom first in every format. A layer's `kind` is the name the file uses: +`layer` in VectorCraft; `raster`, `group`, `adjustment`, `fill`, `text`, `shape` or `smart` in +PhotoCraft; the source type in EffectCraft (`Footage`, `Comp`, `Solid`, `Text` and others). +EffectCraft layers have no `opacity`, because opacity is an animated property there. + +A `.pcraft` bundle saved as a folder, ZIP64 archives and encrypted entries are not read. The +`.ecprojx` XML copies are not read. + +## Limits + +`Limits` (fields `max_json`, `max_entry`, `max_total`, `max_depth`) bounds each call: + +- `max_json` (256 MiB): the JSON read, which is the VectorCraft file's JSON, the PhotoCraft + manifest or the EffectCraft project. The names and other strings returned are parts of it. +- `max_entry` (256 MiB): one rendition or one ZIP entry. The size a file states is checked before + any of it is copied or inflated. +- `max_total` (1 GiB): every byte one call decompresses from gzip, reads out of ZIP entries or + returns as a rendition, together. +- `max_depth` (400, also the largest value it takes, `MAX_DEPTH`): the nesting of JSON arrays and + objects, checked before parsing. PhotoCraft nests up to 100 groups of layers, three levels each. + +A PhotoCraft manifest with more than 4,194,304 JSON items +(`craft_pcraft_format::MAX_MANIFEST_ITEMS`) is refused with `Error::LimitExceeded`, as PhotoCraft +reads it. + +On a compressed `.vectorcraft` file, `info` and `outline` decompress only as far as the end of +the JSON. `renditions` checks the size of each member after the JSON against `max_entry` +before it decompresses any of that data, then decompresses only as far as the members end. The +struct is `#[non_exhaustive]`: start from the defaults and set the fields you +change. + +```rust +let mut limits = craft_doc::Limits::default(); +limits.max_entry = 32 << 20; +``` + +## Testing + +`cargo test -p craft-doc` runs: + +- `tests/formats.rs`: documents of each format written by the tests (JSON by hand, PNG and ZIP + bytes assembled in the test, gzip through `flate2`), read back through each call: plain and + gzip VectorCraft files, members inline as base64 and after the JSON, `.pcraft` bundles with + STORE and DEFLATE entries and the manifest last, `.ecproj` compositions, newer versions, and + files `sniff` must not claim (PDF, PNG, other ZIP and JSON files). +- `tests/hostile.rs`: every truncation of each sample, seeded mutations, proptest on arbitrary + bytes, and inputs that pass each limit: a gzip bomb, a DEFLATE entry larger than its stated + size, members longer than `max_entry`, JSON nested past `max_depth`, ZIP64, encryption, other + compression methods and CRC mismatches. +- `tests/envelopes.rs`: files whose envelope is written with the format crates' types read + through each call, and envelopes those crates refuse (a manifest without `generator`, a preview + that names another entry, a `format` member inside another member) are refused. A preview or + PDF member without `mime` gives no rendition. + +`.github/workflows/doc.yml` runs fmt, clippy and the tests on Linux and Windows, and a +`wasm32-unknown-unknown` check. diff --git a/crates/doc/src/ecproj.rs b/crates/doc/src/ecproj.rs new file mode 100644 index 00000000..03c437fa --- /dev/null +++ b/crates/doc/src/ecproj.rs @@ -0,0 +1,221 @@ +//! EffectCraft `.ecproj` projects: one UTF-8 JSON object, uncompressed: +//! +//! ```text +//! { "savedBy": "0.12.0", "schema": 2, "settings": {...}, +//! "items": { "1": { "id": 1, "name": "Comp 1", "kind": { "type": "Comp", "width": 1920, ... } } }, +//! ... } +//! ``` +//! +//! Projects saved before `savedBy` existed start with `schema`. A project holds no rendered +//! frames: footage is referenced by path. `savedBy` and `schema` are read with +//! [`craft_ecproj_format::Head`]. + +use std::collections::BTreeMap; + +use craft_ecproj_format::{Head, ReadError}; +use serde::Deserialize; + +use crate::{Budget, Error, Kind, Layer, Outline, Page, Rendition, Timing, check_depth, counted_map, counted_option, malformed, parse, too_new}; + +/// EffectCraft's time unit. +const TICKS_PER_SECOND: f64 = 254_016_000_000.0; + +/// Calls `stop` with each member name of the JSON object `text` starts with, in order, as far as +/// `text` goes, until it returns `true`. Members of nested objects are skipped. A name is given as +/// written, escapes included. +fn top_level_keys<'a>(text: &'a [u8], mut stop: impl FnMut(&'a [u8]) -> bool) { + let Some(body) = text.trim_ascii_start().strip_prefix(b"{") else { return }; + let (mut depth, mut want_key, mut i) = (0u32, true, 0); + while let Some(&b) = body.get(i) { + match b { + b'"' => { + let start = i + 1; + let mut end = start; + loop { + match body.get(end) { + None => return, + Some(b'\\') => end += 2, + Some(b'"') => break, + Some(_) => end += 1, + } + } + if depth == 0 + && want_key + && let Some(key) = body.get(start..end) + && stop(key) + { + return; + } + want_key = false; + i = end; + } + b'{' | b'[' => depth += 1, + b'}' | b']' if depth == 0 => return, + b'}' | b']' => depth -= 1, + b',' if depth == 0 => want_key = true, + _ => {} + } + i += 1; + } +} + +/// A project starts with `savedBy` followed by `schema`, or, saved before `savedBy` existed, with +/// `schema` followed by `items` or `savedBy`, all top-level members. +pub(crate) fn sniff(head: &[u8]) -> bool { + let (mut first, mut found) = (None::<&[u8]>, false); + top_level_keys(head, |key| match first { + None => { + first = Some(key); + !matches!(key, b"savedBy" | b"schema") + } + Some(b"savedBy") => { + found = key == b"schema"; + found + } + Some(_) => { + found = key == b"items" || key == b"savedBy"; + found + } + }); + found +} + +fn json<'a>(bytes: &'a [u8], budget: &Budget) -> Result<&'a [u8], Error> { + budget.check_json(bytes.len() as u64, "the project")?; + Ok(bytes) +} + +/// The project's [`Head`] read with [`Head::read`]; [`ReadError::TooNew`] when its schema is +/// newer than this crate reads. +fn read_head(json: &[u8], budget: &Budget) -> Result, Error> { + check_depth(json, budget.limits.max_depth)?; + let text = std::str::from_utf8(json).map_err(|e| malformed(format!("the project is not UTF-8: {e}")))?; + Ok(Head::read(text)) +} + +/// The project's [`Head`], refused when its schema is newer than this crate reads. +fn head(json: &[u8], budget: &Budget) -> Result { + read_head(json, budget)?.map_err(|e| match e { + ReadError::TooNew { too_new: e, .. } => too_new(Kind::EffectCraft, e.schema), + e => malformed(e), + }) +} + +pub(crate) fn info(bytes: &[u8], budget: &mut Budget) -> Result<(u32, Option), Error> { + match read_head(json(bytes, budget)?, budget)? { + Ok(head) => Ok((head.schema, head.saved_by)), + Err(ReadError::TooNew { too_new, saved_by, .. }) => Ok((too_new.schema, saved_by)), + Err(e) => Err(malformed(e)), + } +} + +pub(crate) fn renditions(bytes: &[u8], budget: &mut Budget) -> Result, Error> { + head(json(bytes, budget)?, budget)?; + Ok(vec![]) +} + +#[derive(Deserialize)] +struct Project { + #[serde(default, deserialize_with = "counted_map")] + items: BTreeMap, +} + +#[derive(Deserialize)] +struct ItemM { + id: u64, + #[serde(default)] + name: String, + kind: ItemKindM, +} + +/// The members of an item's kind that a composition has. Footage and solids share some of them +/// with the same types; folders have none. +#[derive(Deserialize)] +struct ItemKindM { + #[serde(rename = "type")] + kind: String, + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, + #[serde(default)] + frame_rate: Option, + /// In ticks. + #[serde(default)] + duration: Option, + /// A composition's layers, the top one first. + #[serde(default, deserialize_with = "counted_option")] + layers: Option>, +} + +#[derive(Deserialize)] +struct FrameRateM { + num: i64, + den: i64, +} + +fn yes() -> bool { + true +} + +#[derive(Deserialize)] +struct LayerM { + #[serde(default)] + name: String, + #[serde(default)] + source: Option, + #[serde(default)] + switches: Option, + #[serde(default)] + blend_mode: Option, +} + +#[derive(Deserialize)] +struct SourceM { + #[serde(rename = "type")] + kind: String, +} + +#[derive(Deserialize)] +struct SwitchesM { + #[serde(default = "yes")] + video: bool, +} + +pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result { + let json = json(bytes, budget)?; + head(json, budget)?; + let p: Project = parse(json, budget.limits)?; + let mut comps: Vec = p.items.into_values().filter(|i| i.kind.kind == "Comp").collect(); + comps.sort_by_key(|i| i.id); + let pages = comps + .into_iter() + .map(|i| { + let k = i.kind; + let (Some(width), Some(height)) = (k.width, k.height) else { + return Err(malformed(format!("composition `{}` has no size", i.name))); + }; + let timing = k.frame_rate.zip(k.duration).map(|(r, duration)| Timing { + frame_rate_num: r.num, + frame_rate_den: r.den, + duration_seconds: duration as f64 / TICKS_PER_SECOND, + }); + let layers = k + .layers + .unwrap_or_default() + .into_iter() + .rev() + .map(|l| Layer { + name: l.name, + kind: l.source.map(|s| s.kind).unwrap_or_default(), + visible: l.switches.is_none_or(|s| s.video), + opacity: None, + blend: l.blend_mode, + children: vec![], + }) + .collect(); + Ok(Page { name: i.name, x: 0.0, y: 0.0, width: f64::from(width), height: f64::from(height), timing, layers }) + }) + .collect::>()?; + Ok(Outline { canvas: None, pages, layers: vec![] }) +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs new file mode 100644 index 00000000..5d27cf83 --- /dev/null +++ b/crates/doc/src/lib.rs @@ -0,0 +1,536 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! Read-only access to the Craft apps' native documents, without the apps: PhotoCraft +//! `.pcraft`, VectorCraft `.vectorcraft` (and `.drawcraft`) and EffectCraft `.ecproj`. +//! +//! - [`sniff`] tells which app wrote a file, from its first bytes. +//! - [`info`] reads the format version and the writer the file names. +//! - [`renditions`] returns the previews and the PDF a file carries. +//! - [`outline`] returns the canvas, the pages (artboards or compositions) and the layers. +//! +//! Bytes go in and plain data comes out: no file system access, no rendering and no app crate. +//! Each format's envelope is read through its craft-libs crate: `craft-pcraft-format`, +//! `craft-vectorcraft-format` and `craft-ecproj-format`. +//! A file whose format version is newer than this crate reads gives [`Info::supported`] `false` +//! from [`info`] and [`Error::TooNew`] from the other calls. +//! +//! Nothing panics on input. Every length a file states is checked against [`Limits`] before +//! anything is allocated for it, and every decompressed byte is counted against them. +//! +//! ``` +//! let file = br#"{"format":"vectorcraft","version":4,"generator":"VectorCraft 0.1.0","document":{"artboards":[],"layers":[]}}"#; +//! assert_eq!(craft_doc::sniff(file), Some(craft_doc::Kind::VectorCraft)); +//! let info = craft_doc::info(file, &craft_doc::Limits::default()).unwrap(); +//! assert_eq!(info.version, 4); +//! assert!(info.supported); +//! ``` + +mod ecproj; +mod pcraft; +mod vectorcraft; +mod zip; + +use std::cell::Cell; +use std::collections::BTreeMap; +use std::fmt; +use std::marker::PhantomData; + +use serde::Deserialize; +use serde::de::{self, DeserializeOwned, DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor}; + +/// The app that wrote a document. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum Kind { + /// A PhotoCraft `.pcraft` bundle (a ZIP archive). + PhotoCraft, + /// A VectorCraft `.vectorcraft` document (JSON, plain or gzip), or one saved under the + /// earlier name `.drawcraft`. + VectorCraft, + /// An EffectCraft `.ecproj` project (JSON). + EffectCraft, +} + +impl Kind { + /// The file extension the app saves with, without the dot. + pub fn extension(self) -> &'static str { + match self { + Kind::PhotoCraft => "pcraft", + Kind::VectorCraft => "vectorcraft", + Kind::EffectCraft => "ecproj", + } + } + + /// The newest format version this crate reads: PhotoCraft's `format_version`, VectorCraft's + /// `version` or EffectCraft's `schema`. + pub fn supported_version(self) -> u32 { + match self { + Kind::PhotoCraft => craft_pcraft_format::FORMAT_VERSION, + Kind::VectorCraft => craft_vectorcraft_format::VERSION, + Kind::EffectCraft => craft_ecproj_format::SCHEMA_VERSION, + } + } +} + +/// What a document says about itself. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct Info { + pub kind: Kind, + /// The format version the file declares. + pub version: u32, + /// The writer, as the file names it: VectorCraft's `generator` (`"VectorCraft 0.13.1"`), + /// PhotoCraft's `generator` (`"photocraft-format 0.1.0"`) or EffectCraft's `savedBy` (the + /// app version, `"0.12.0"`). `None` when the file has none. + pub generator: Option, + /// `false` when [`Info::version`] is newer than [`Kind::supported_version`]; the other calls + /// then return [`Error::TooNew`]. + pub supported: bool, +} + +/// Why a document could not be read. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// [`sniff`] does not recognize the bytes as a Craft document. + #[error("not a PhotoCraft, VectorCraft or EffectCraft document")] + Unrecognized, + /// The file is damaged or does not follow its format. + #[error("malformed document: {0}")] + Malformed(String), + /// The file's format version is newer than this crate reads. + #[error("{} version {version} is newer than this reader supports ({supported})", kind.extension())] + TooNew { kind: Kind, version: u32, supported: u32 }, + /// A valid container feature this reader does not implement (ZIP64, encryption, a ZIP + /// compression method other than STORE or DEFLATE). + #[error("{0}")] + Unsupported(String), + /// Reading the file would exceed the caller's [`Limits`]. + #[error("limit exceeded: {0}")] + LimitExceeded(String), +} + +/// Bounds on the work and memory one call may use. Each is checked before the bytes it covers +/// are allocated. +/// +/// ``` +/// let mut limits = craft_doc::Limits::default(); +/// limits.max_entry = 16 << 20; +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct Limits { + /// Most bytes of JSON read: the VectorCraft file's JSON, the PhotoCraft manifest or the + /// EffectCraft project. The names and other strings returned are parts of it, so this also + /// bounds them. Default 256 MiB. + pub max_json: u64, + /// Most bytes of one rendition or one ZIP entry. Default 256 MiB. + pub max_entry: u64, + /// Most bytes one call decompresses from gzip, reads out of ZIP entries and returns as + /// renditions, together. Default 1 GiB. + pub max_total: u64, + /// Deepest nesting of JSON arrays and objects, at most [`MAX_DEPTH`] (a larger value counts + /// as [`MAX_DEPTH`]). Default [`MAX_DEPTH`]. + pub max_depth: u32, + /// Most list elements one call reads, together: EffectCraft project items and composition + /// layers, VectorCraft artboards and layers, and PhotoCraft layers at every level. Each is + /// counted before it is read. Default 1,048,576. + pub max_items: u64, +} + +/// The deepest JSON nesting any call reads: 400 levels, which holds PhotoCraft's 100 nested +/// groups of layers at three levels each. +pub const MAX_DEPTH: u32 = 400; + +impl Default for Limits { + fn default() -> Self { + Limits { max_json: 256 << 20, max_entry: 256 << 20, max_total: 1 << 30, max_depth: MAX_DEPTH, max_items: 1 << 20 } + } +} + +/// What a rendition shows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum RenditionKind { + /// A PDF of the document (VectorCraft's `pdf`, saved with Create PDF-Compatible File). + Pdf, + /// A small preview image: VectorCraft's `preview` (the first artboard) or PhotoCraft's + /// `thumbnail`. + Thumbnail, + /// PhotoCraft's flattened `composite` preview. + Composite, +} + +/// An image or PDF the document carries. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub struct Rendition { + pub kind: RenditionKind, + /// The MIME type: the one a VectorCraft file gives (a member without one gives no + /// rendition), or `image/png` for PhotoCraft's previews. + pub mime: String, + pub bytes: Vec, +} + +/// The structure of a document, without its content. +#[derive(Debug, Clone, PartialEq, Default)] +#[non_exhaustive] +pub struct Outline { + /// PhotoCraft's canvas; `None` for the other formats. + pub canvas: Option, + /// VectorCraft's artboards (in points) or EffectCraft's compositions (in pixels), in file + /// order; empty for PhotoCraft. + pub pages: Vec, + /// VectorCraft's top-level layers or PhotoCraft's layer tree, bottom first; empty for + /// EffectCraft, whose layers are in each [`Page`]. + pub layers: Vec, +} + +/// A PhotoCraft canvas. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct Canvas { + pub width: u32, + pub height: u32, + pub resolution_dpi: f32, + /// The color mode as the manifest writes it: `Rgb`, `Cmyk`, `Grayscale`, `Lab`, `Indexed`, + /// `Bitmap`, `Duotone` or `Multichannel`. + pub mode: String, + /// The sample type as the manifest writes it: `U8`, `U16` or `F32`. + pub depth: String, +} + +/// A VectorCraft artboard or an EffectCraft composition. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct Page { + pub name: String, + /// The left edge (0 for a composition). + pub x: f64, + /// The top edge (0 for a composition). + pub y: f64, + pub width: f64, + pub height: f64, + /// A composition's frame rate and duration; `None` for an artboard, and for a composition + /// that gives either one without the other. + pub timing: Option, + /// A composition's layers, bottom first; empty for an artboard. + pub layers: Vec, +} + +/// A composition's timing. +#[derive(Debug, Clone, Copy, PartialEq)] +#[non_exhaustive] +pub struct Timing { + /// Frames per second as `frame_rate_num / frame_rate_den`. + pub frame_rate_num: i64, + pub frame_rate_den: i64, + pub duration_seconds: f64, +} + +/// One layer. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub struct Layer { + pub name: String, + /// What the layer holds, as the file names it: `layer` for VectorCraft; `raster`, `group`, + /// `adjustment`, `fill`, `text`, `shape` or `smart` for PhotoCraft; the source type for + /// EffectCraft (`Footage`, `Comp`, `Solid`, `Text`, `Shape`, `Null`, `Camera`, `Light`, + /// `Model`, `Primitive`). + pub kind: String, + pub visible: bool, + /// Opacity from 0 to 1; `None` for EffectCraft, where opacity is an animated property. + pub opacity: Option, + /// The blend mode as the file names it (`Normal`, `Multiply`…). + pub blend: Option, + /// A PhotoCraft group's layers, bottom first. + pub children: Vec, +} + +/// Which app wrote the document whose first bytes are `head`, or `None`. The first 64 KiB are +/// enough for every file the apps write. A re-zipped `.pcraft` whose `manifest.json` entry +/// starts past `head` is recognized only when `head` holds the whole file. +pub fn sniff(head: &[u8]) -> Option { + if pcraft::sniff(head) { + Some(Kind::PhotoCraft) + } else if vectorcraft::sniff(head) { + Some(Kind::VectorCraft) + } else if ecproj::sniff(head) { + Some(Kind::EffectCraft) + } else { + None + } +} + +/// The document's kind, version and writer. A version newer than this crate reads is not an +/// error here: [`Info::supported`] is `false`. +pub fn info(bytes: &[u8], limits: &Limits) -> Result { + let mut budget = Budget::new(limits); + let kind = sniff(bytes).ok_or(Error::Unrecognized)?; + let (version, generator) = match kind { + Kind::PhotoCraft => pcraft::info(bytes, &mut budget)?, + Kind::VectorCraft => vectorcraft::info(bytes, &mut budget)?, + Kind::EffectCraft => ecproj::info(bytes, &mut budget)?, + }; + Ok(Info { kind, version, generator, supported: version <= kind.supported_version() }) +} + +/// The previews and the PDF the document carries, the thumbnail first. A +/// `.vectorcraft` file has a `preview` only when saved with one and a `pdf` only when saved with +/// Create PDF-Compatible File; a `.pcraft` bundle has the `thumbnail` and `composite` its +/// manifest names; an `.ecproj` project has none. +pub fn renditions(bytes: &[u8], limits: &Limits) -> Result, Error> { + let mut budget = Budget::new(limits); + match sniff(bytes).ok_or(Error::Unrecognized)? { + Kind::PhotoCraft => pcraft::renditions(bytes, &mut budget), + Kind::VectorCraft => vectorcraft::renditions(bytes, &mut budget), + Kind::EffectCraft => ecproj::renditions(bytes, &mut budget), + } +} + +/// The document's canvas, pages and layers (see [`Outline`]). +pub fn outline(bytes: &[u8], limits: &Limits) -> Result { + let mut budget = Budget::new(limits); + match sniff(bytes).ok_or(Error::Unrecognized)? { + Kind::PhotoCraft => pcraft::outline(bytes, &mut budget), + Kind::VectorCraft => vectorcraft::outline(bytes, &mut budget), + Kind::EffectCraft => ecproj::outline(bytes, &mut budget), + } +} + +fn malformed(m: impl std::fmt::Display) -> Error { + Error::Malformed(m.to_string()) +} + +/// The bytes one call may still decompress, read out of ZIP entries or return as renditions. +pub(crate) struct Budget<'a> { + pub(crate) limits: &'a Limits, + left: u64, +} + +impl<'a> Budget<'a> { + fn new(limits: &'a Limits) -> Self { + Budget { limits, left: limits.max_total } + } + + /// Bytes still allowed. + pub(crate) fn left(&self) -> u64 { + self.left + } + + /// Count `n` bytes of `what` against the total. + pub(crate) fn take(&mut self, n: u64, what: &str) -> Result<(), Error> { + self.left = self + .left + .checked_sub(n) + .ok_or_else(|| Error::LimitExceeded(format!("{what}: more than max_total ({} bytes) in all", self.limits.max_total)))?; + Ok(()) + } + + /// Check one rendition or ZIP entry of `n` bytes against `max_entry`, then count it. + pub(crate) fn take_entry(&mut self, n: u64, what: &str) -> Result<(), Error> { + if n > self.limits.max_entry { + return Err(Error::LimitExceeded(format!("{what} is {n} bytes, more than max_entry ({})", self.limits.max_entry))); + } + self.take(n, what) + } + + /// Check `n` bytes of JSON against `max_json`. + pub(crate) fn check_json(&self, n: u64, what: &str) -> Result<(), Error> { + if n > self.limits.max_json { + return Err(Error::LimitExceeded(format!("{what} is more than max_json ({} bytes)", self.limits.max_json))); + } + Ok(()) + } +} + +/// Is the JSON text nested at most `max` levels deep (arrays and objects), and at most +/// [`MAX_DEPTH`]? +pub(crate) fn check_depth(json: &[u8], max: u32) -> Result<(), Error> { + let max = max.min(MAX_DEPTH); + let (mut depth, mut in_str, mut escaped) = (0u32, false, false); + for &b in json { + if in_str { + match b { + _ if escaped => escaped = false, + b'\\' => escaped = true, + b'"' => in_str = false, + _ => {} + } + continue; + } + match b { + b'"' => in_str = true, + b'[' | b'{' => { + depth += 1; + if depth > max { + return Err(Error::LimitExceeded(format!("JSON nested deeper than max_depth ({max} levels)"))); + } + } + b']' | b'}' => depth = depth.saturating_sub(1), + _ => {} + } + } + Ok(()) +} + +thread_local! { + /// The elements [`counted`] lists may still read in the current [`parse`], and whether a + /// list went past them. + static ITEMS: Cell<(u64, bool)> = const { Cell::new((u64::MAX, false)) }; +} + +/// Run `f` with `max` elements for [`counted`] lists; when `f` fails after a list went past +/// them, the error is [`Error::LimitExceeded`]. +fn counting(max: u64, f: impl FnOnce() -> Result) -> Result { + let saved = ITEMS.with(|c| c.replace((max, false))); + let r = f(); + let (_, over) = ITEMS.with(|c| c.replace(saved)); + match r { + Err(_) if over => Err(Error::LimitExceeded(format!("the file lists more than max_items ({max}) items, layers and pages"))), + r => r, + } +} + +/// Count one list element against [`Limits::max_items`]. +fn take_item() -> Result<(), E> { + ITEMS.with(|c| match c.get().0.checked_sub(1) { + Some(left) => { + c.set((left, false)); + Ok(()) + } + None => { + c.set((0, true)); + Err(E::custom("more than max_items list elements")) + } + }) +} + +/// A `T` read after one element is counted with [`take_item`]. +struct Item(PhantomData); + +impl<'de, T: Deserialize<'de>> DeserializeSeed<'de> for Item { + type Value = T; + + fn deserialize>(self, d: D) -> Result { + take_item()?; + T::deserialize(d) + } +} + +/// A list read by [`parse`] (as `#[serde(deserialize_with = "counted")]`), each element counted +/// against [`Limits::max_items`] before it is read. +pub(crate) fn counted<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { + struct List(PhantomData); + impl<'de, T: Deserialize<'de>> Visitor<'de> for List { + type Value = Vec; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a list") + } + + fn visit_seq>(self, mut seq: A) -> Result, A::Error> { + let mut out = Vec::new(); + while let Some(v) = seq.next_element_seed(Item(PhantomData))? { + out.push(v); + } + Ok(out) + } + } + d.deserialize_seq(List(PhantomData)) +} + +/// [`counted`] for a list that may be `null`. +pub(crate) fn counted_option<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result>, D::Error> { + struct Counted(Vec); + impl<'de, T: Deserialize<'de>> Deserialize<'de> for Counted { + fn deserialize>(d: D) -> Result { + counted(d).map(Counted) + } + } + Ok(Option::>::deserialize(d)?.map(|c| c.0)) +} + +/// [`counted`] for a map, each value counted before it is read. +pub(crate) fn counted_map<'de, D: Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { + struct Map(PhantomData); + impl<'de, T: Deserialize<'de>> Visitor<'de> for Map { + type Value = BTreeMap; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("a map") + } + + fn visit_map>(self, mut map: A) -> Result, A::Error> { + let mut out = BTreeMap::new(); + while let Some(k) = map.next_key::()? { + let v = map.next_value_seed(Item(PhantomData))?; + out.insert(k, v); + } + Ok(out) + } + } + d.deserialize_map(Map(PhantomData)) +} + +/// Parse one JSON value of type `T` from `json`, after checking its nesting against the limits. +pub(crate) fn parse(json: &[u8], limits: &Limits) -> Result { + check_depth(json, limits.max_depth)?; + counting(limits.max_items, || parse_checked(json).map_err(malformed)) +} + +/// Parse JSON whose nesting [`check_depth`] has accepted. +fn parse_checked(json: &[u8]) -> Result { + let mut de = serde_json::Deserializer::from_slice(json); + de.disable_recursion_limit(); + let v = T::deserialize(&mut de)?; + de.end()?; + Ok(v) +} + +/// A document's version number: VectorCraft's `version`, PhotoCraft's `format_version` or +/// EffectCraft's `schema`. +#[derive(serde::Deserialize)] +struct Version { + #[serde(alias = "format_version", alias = "schema")] + version: u32, +} + +/// Parse `T` from `json`; when that fails, report [`Error::TooNew`] instead if the file's version +/// (its top-level `version`, `format_version` or `schema`) is newer than `kind` supports, since a +/// newer format may change what `T` expects. +pub(crate) fn parse_versioned(json: &[u8], limits: &Limits, kind: Kind) -> Result { + check_depth(json, limits.max_depth)?; + counting(limits.max_items, || { + parse_checked(json).map_err(|e| match parse_checked::(json) { + Ok(v) if v.version > kind.supported_version() => too_new(kind, v.version), + _ => malformed(e), + }) + }) +} + +pub(crate) fn too_new(kind: Kind, version: u32) -> Error { + Error::TooNew { kind, version, supported: kind.supported_version() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn depth_counts_brackets_outside_strings() { + assert!(check_depth(br#"[[["[[[[[[[["]]]"#, 3).is_ok()); + assert!(check_depth(br#"[[[["\"[["]]]]"#, 3).is_err()); + assert!(check_depth(br#"{"a":{"b":[1]}}"#, 3).is_ok()); + } + + #[test] + fn the_budget_refuses_past_its_limits() { + let limits = Limits { max_json: 10, max_entry: 5, max_total: 8, max_depth: 4, max_items: 2 }; + let mut b = Budget::new(&limits); + assert!(b.take_entry(6, "x").is_err()); + assert!(b.take_entry(5, "x").is_ok()); + assert!(matches!(b.take_entry(4, "x"), Err(Error::LimitExceeded(_)))); + assert_eq!(b.left(), 3); + assert!(b.check_json(11, "x").is_err() && b.check_json(10, "x").is_ok()); + } +} diff --git a/crates/doc/src/pcraft.rs b/crates/doc/src/pcraft.rs new file mode 100644 index 00000000..dd079869 --- /dev/null +++ b/crates/doc/src/pcraft.rs @@ -0,0 +1,158 @@ +//! PhotoCraft `.pcraft` bundles: a ZIP archive holding `manifest.json` (the document tree, with +//! `format_version`), tiles and blobs, and optionally the previews the manifest names +//! (`thumbnail`, `composite`). Only the ZIP form is read; a bundle saved as a folder is not. The +//! manifest's envelope is [`craft_pcraft_format::Manifest`]'s. + +use craft_pcraft_format::{self as format, COMPOSITE, FORMAT_VERSION, MANIFEST, THUMB}; +use serde::Deserialize; +use serde::de::{DeserializeOwned, IgnoredAny}; + +use crate::zip::{Zip, local_names_include}; +use crate::{Budget, Canvas, Error, Kind, Layer, MAX_DEPTH, Outline, Rendition, RenditionKind, counted, malformed, parse, parse_versioned, too_new}; + +pub(crate) fn sniff(head: &[u8]) -> bool { + head.starts_with(b"PK\x03\x04") && (local_names_include(head, MANIFEST) || Zip::open(head).is_ok_and(|z| z.find(MANIFEST).is_some())) +} + +/// The archive and its `manifest.json`. +fn manifest<'a>(bytes: &'a [u8], budget: &mut Budget) -> Result<(Zip<'a>, Vec), Error> { + let zip = Zip::open(bytes)?; + let entry = zip.find(MANIFEST).ok_or_else(|| malformed("the bundle has no manifest.json"))?; + let max = budget.limits.max_json.min(budget.limits.max_entry); + let json = zip.read(entry, max, budget, MANIFEST)?; + Ok((zip, json)) +} + +/// The version and the writer, read without the rest of the manifest, so that a manifest of a +/// newer format still reports them. +#[derive(Deserialize)] +struct Stamp { + format_version: u32, + #[serde(default)] + generator: Option, +} + +fn checked(version: u32) -> Result { + if version == 0 { + return Err(malformed("format_version 0 is invalid")); + } + Ok(version) +} + +/// The members of [`craft_pcraft_format::Manifest`], with the document as `D`, read without +/// building the manifest as a `serde_json::Value` first. +#[derive(Deserialize)] +struct Envelope { + format_version: u32, + /// Required, and a string, as in the manifest; [`info`] reports it. + #[serde(rename = "generator")] + _generator: String, + document: D, + #[serde(default)] + thumbnail: Option, + #[serde(default)] + composite: Option, +} + +/// The manifest's envelope, checked as [`craft_pcraft_format::read_manifest`] checks it: the +/// size, nesting and item count with [`craft_pcraft_format::check_manifest`], a version of 0 or +/// newer than [`FORMAT_VERSION`] refused, and a preview that names an entry other than [`THUMB`] +/// or [`COMPOSITE`] refused. A member that appears twice is refused. +fn read(json: &[u8], budget: &Budget) -> Result, Error> { + let mut limits = format::Limits::default(); + limits.max_manifest_bytes = json.len(); + limits.max_depth = budget.limits.max_depth.min(MAX_DEPTH) as usize; + format::check_manifest(json, &limits).map_err(|e| match e { + format::Error::LimitExceeded(m) => Error::LimitExceeded(m), + e => malformed(e), + })?; + let m: Envelope = parse_versioned(json, budget.limits, Kind::PhotoCraft)?; + let version = checked(m.format_version)?; + if version > FORMAT_VERSION { + return Err(too_new(Kind::PhotoCraft, version)); + } + for (member, entry, expected) in [("thumbnail", &m.thumbnail, THUMB), ("composite", &m.composite, COMPOSITE)] { + if let Some(entry) = entry + && entry != expected + { + return Err(malformed(format!("`{member}` names {entry:?}, not {expected:?}"))); + } + } + Ok(m) +} + +pub(crate) fn info(bytes: &[u8], budget: &mut Budget) -> Result<(u32, Option), Error> { + let (_, json) = manifest(bytes, budget)?; + let stamp: Stamp = parse(&json, budget.limits)?; + Ok((checked(stamp.format_version)?, stamp.generator)) +} + +pub(crate) fn renditions(bytes: &[u8], budget: &mut Budget) -> Result, Error> { + let (zip, json) = manifest(bytes, budget)?; + let m: Envelope = read(&json, budget)?; + let mut out = vec![]; + for (kind, path) in [(RenditionKind::Thumbnail, m.thumbnail), (RenditionKind::Composite, m.composite)] { + let Some(path) = path else { continue }; + let entry = zip.find(&path).ok_or_else(|| malformed(format!("the manifest names `{path}`, which the bundle does not have")))?; + let max = budget.limits.max_entry; + let bytes = zip.read(entry, max, budget, &path)?; + out.push(Rendition { kind, mime: "image/png".into(), bytes }); + } + Ok(out) +} + +#[derive(Deserialize)] +struct DocM { + size: SizeM, + resolution_dpi: f32, + mode: String, + depth: String, + /// Bottom to top. + #[serde(deserialize_with = "counted")] + layers: Vec, +} + +#[derive(Deserialize)] +struct SizeM { + width: u32, + height: u32, +} + +#[derive(Deserialize)] +struct LayerM { + name: String, + visible: bool, + blend: String, + opacity: f32, + content: ContentM, +} + +#[derive(Deserialize)] +struct ContentM { + kind: String, + #[serde(default, deserialize_with = "counted")] + children: Vec, +} + +impl From for Layer { + fn from(l: LayerM) -> Self { + Layer { + name: l.name, + kind: l.content.kind, + visible: l.visible, + opacity: Some(l.opacity), + blend: Some(l.blend), + children: l.content.children.into_iter().map(Layer::from).collect(), + } + } +} + +pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result { + let (_, json) = manifest(bytes, budget)?; + let d = read::(&json, budget)?.document; + Ok(Outline { + canvas: Some(Canvas { width: d.size.width, height: d.size.height, resolution_dpi: d.resolution_dpi, mode: d.mode, depth: d.depth }), + pages: vec![], + layers: d.layers.into_iter().map(Layer::from).collect(), + }) +} diff --git a/crates/doc/src/vectorcraft.rs b/crates/doc/src/vectorcraft.rs new file mode 100644 index 00000000..5be99eec --- /dev/null +++ b/crates/doc/src/vectorcraft.rs @@ -0,0 +1,302 @@ +//! VectorCraft `.vectorcraft` documents: one JSON object, gzip-compressed when saved with Use +//! Compression: +//! +//! ```text +//! { "format": "vectorcraft", "version": 4, "generator": "VectorCraft 0.1.0", +//! "preview": { "mime": "image/png", "data": "" }, +//! "pdf": { "mime": "application/pdf", "blob": [offset, length] }, +//! "document": { "artboards": [...], "layers": [...], ... }, ... } +//! ``` +//! +//! Since version 4, large members may follow the JSON after the marker `\n\0VCBLOBS\0`; such a +//! member gives `"blob": [offset, length]` (from the end of the marker) instead of `data`. In a +//! compressed file, the marker and the data after it are inside the gzip stream too. Files +//! written before the rename say `"format": "drawcraft"`. The envelope's members are +//! [`craft_vectorcraft_format::Head`] and [`craft_vectorcraft_format::Image`]. + +use std::borrow::Cow; +use std::io::Read; + +use craft_vectorcraft_format::{BLOB_MAGIC, Head, Image, is_compressed}; +use flate2::read::GzDecoder; +use serde::Deserialize; + +use crate::{Budget, Error, Kind, Layer, Outline, Page, Rendition, RenditionKind, counted, malformed, parse, parse_versioned, too_new}; + +/// Bytes decompressed at a time. +const CHUNK: u64 = 64 << 10; + +pub(crate) fn sniff(head: &[u8]) -> bool { + craft_vectorcraft_format::sniff(head) +} + +/// Where the JSON ends and the data after the marker starts, when `text` holds a NUL. JSON holds +/// no raw NUL and the marker's second byte is one, so the JSON ends right before the first NUL; +/// a NUL that is not the marker's makes the file malformed. +fn marker(text: &[u8], nul: usize) -> Result<(usize, usize), Error> { + match nul.checked_sub(1) { + Some(at) if text.get(at..).is_some_and(|r| r.starts_with(BLOB_MAGIC)) => Ok((at, at + BLOB_MAGIC.len())), + _ => Err(malformed("the file holds a NUL byte outside the marker after its JSON")), + } +} + +fn damaged(e: std::io::Error) -> Error { + malformed(format!("damaged compressed data: {e}")) +} + +/// A file's JSON, and the data after it. +struct Unpacked<'a> { + json: Cow<'a, [u8]>, + tail: Tail<'a>, +} + +/// The data after the marker. +enum Tail<'a> { + /// In an uncompressed file. + Slice(&'a [u8]), + /// Still in the gzip stream: `read` holds what was decompressed past the marker already, + /// of which `counted` bytes are counted against the budget. + Stream { dec: Box>, read: Vec, counted: usize }, +} + +impl Tail<'_> { + /// The data up to `end` (less where the file ends first), decompressing only that far. + fn up_to(&mut self, end: u64, budget: &mut Budget) -> Result<&[u8], Error> { + match self { + Tail::Slice(s) => Ok(s), + Tail::Stream { dec, read, counted } => { + let end = usize::try_from(end).map_err(|_| Error::LimitExceeded("the data after the JSON does not fit in memory".into()))?; + let had = read.len().min(end); + if had > *counted { + budget.take((had - *counted) as u64, "the decompressed document")?; + *counted = had; + } + if read.len() < end { + let want = ((end - read.len()) as u64).min(budget.left().saturating_add(1)); + let n = (&mut **dec).take(want).read_to_end(read).map_err(damaged)?; + budget.take(n as u64, "the decompressed document")?; + *counted += n; + } + Ok(read.as_slice()) + } + } + } +} + +/// A file's JSON and where the data after it is. A compressed file is decompressed only as far +/// as the end of the marker, every byte used counted against the budget, and its JSON is +/// checked against `max_json` as it grows. +fn unpack<'a>(bytes: &'a [u8], budget: &mut Budget) -> Result, Error> { + const JSON: &str = "the document's JSON"; + let too_long = |budget: &Budget| Error::LimitExceeded(format!("{JSON} is more than max_json ({} bytes)", budget.limits.max_json)); + if !is_compressed(bytes) { + let (json, tail) = match bytes.iter().position(|&b| b == 0) { + Some(nul) => { + let (end, tail) = marker(bytes, nul)?; + (bytes.get(..end).unwrap_or_default(), bytes.get(tail..).unwrap_or_default()) + } + None => (bytes, &[][..]), + }; + budget.check_json(json.len() as u64, JSON)?; + return Ok(Unpacked { json: Cow::Borrowed(json), tail: Tail::Slice(tail) }); + } + let mut dec = Box::new(GzDecoder::new(bytes)); + let mut buf = Vec::new(); + let mut split: Option<(usize, usize)> = None; + let mut nul: Option = None; + loop { + let start = buf.len(); + let want = CHUNK.min(budget.left().saturating_add(1)); + let n = (&mut *dec).take(want).read_to_end(&mut buf).map_err(damaged)?; + // The JSON ends before the first NUL: until there is one, all of it is JSON. The marker + // may end in a later chunk than its first NUL. + if nul.is_none() { + nul = buf.get(start..).and_then(|b| b.iter().position(|&c| c == 0)).map(|p| p + start); + } + if nul.unwrap_or(buf.len()).saturating_sub(1) as u64 > budget.limits.max_json { + return Err(too_long(budget)); + } + let mut used = n; + if let Some(nul) = nul + && buf.len() >= nul + BLOB_MAGIC.len() - 1 + { + let s = marker(&buf, nul)?; + split = Some(s); + // The data after the marker in this chunk is counted when it is read. + used = s.1.saturating_sub(start).min(n); + } + budget.take(used as u64, "the decompressed document")?; + if (n as u64) < want || split.is_some() { + break; + } + } + let (json_len, tail_at) = match (split, nul) { + (Some(s), _) => s, + // The stream ended inside the marker. + (None, Some(nul)) => marker(&buf, nul)?, + (None, None) => (buf.len(), buf.len()), + }; + budget.check_json(json_len as u64, JSON)?; + let read = if tail_at <= buf.len() { buf.split_off(tail_at) } else { vec![] }; + buf.truncate(json_len); + Ok(Unpacked { json: Cow::Owned(buf), tail: Tail::Stream { dec, read, counted: 0 } }) +} + +/// The format name, the version and the writer, read without the rest of the file, so that a file +/// of a newer version still reports them. +#[derive(Deserialize)] +struct Stamp { + format: String, + version: u32, + #[serde(default)] + generator: Option, +} + +/// The file's [`Stamp`], refused when the format is not VectorCraft's, and when the version is +/// newer than this crate reads unless `newer` is true. +fn stamp(json: &[u8], budget: &Budget, newer: bool) -> Result { + let s: Stamp = if newer { parse(json, budget.limits)? } else { parse_versioned(json, budget.limits, Kind::VectorCraft)? }; + match craft_vectorcraft_format::check(&s.format, s.version) { + Ok(()) => Ok(s), + Err(craft_vectorcraft_format::Error::Format(format)) => Err(malformed(format!("format is `{format}`"))), + Err(craft_vectorcraft_format::Error::TooNew(_)) if newer => Ok(s), + Err(craft_vectorcraft_format::Error::TooNew(version)) => Err(too_new(Kind::VectorCraft, version)), + Err(e) => Err(malformed(e)), + } +} + +pub(crate) fn info(bytes: &[u8], budget: &mut Budget) -> Result<(u32, Option), Error> { + let json = unpack(bytes, budget)?.json; + let s = stamp(&json, budget, true)?; + Ok((s.version, s.generator)) +} + +/// Check the entry's size against `max_entry` and count it; for an entry after the JSON, the +/// offset where it ends. +fn take(image: &Image, budget: &mut Budget, what: &str) -> Result, Error> { + budget.take_entry(image.len(), what)?; + Ok(image.blob.map(|[offset, len]| offset.saturating_add(len))) +} + +/// The entry's bytes, after [`take`] has checked its size. +fn entry_bytes(image: &Image, tail: &[u8], what: &str) -> Result, Error> { + image.bytes_at_most(tail, u64::MAX).ok_or_else(|| match image.blob { + Some(_) => malformed(format!("the file ends before its {what}")), + None => malformed(format!("the {what} is not valid base64")), + }) +} + +pub(crate) fn renditions(bytes: &[u8], budget: &mut Budget) -> Result, Error> { + let Unpacked { json, mut tail } = unpack(bytes, budget)?; + stamp(&json, budget, false)?; + let head: Head = parse(&json, budget.limits)?; + // A member without a MIME type gives no rendition: a preview may be PNG or JPEG. + let members: Vec<_> = [(RenditionKind::Thumbnail, head.preview, "preview"), (RenditionKind::Pdf, head.pdf, "PDF")] + .into_iter() + .filter_map(|(kind, m, what)| Some((kind, m.filter(|m| !m.mime.is_empty())?, what))) + .collect(); + // Every size is checked before any data after the JSON is decompressed. + let mut end = 0u64; + for (_, m, what) in &members { + end = end.max(take(m, budget, what)?.unwrap_or(0)); + } + let tail = if end > 0 { tail.up_to(end, budget)? } else { &[] }; + members + .into_iter() + .map(|(kind, m, what)| { + let bytes = entry_bytes(&m, tail, what)?; + Ok(Rendition { kind, mime: m.mime, bytes }) + }) + .collect() +} + +#[derive(Deserialize)] +struct WithDocument { + document: DocM, +} + +#[derive(Deserialize)] +struct DocM { + #[serde(default, deserialize_with = "counted")] + artboards: Vec, + /// Bottom first. + #[serde(default, deserialize_with = "counted")] + layers: Vec, +} + +#[derive(Deserialize)] +struct ArtboardM { + #[serde(default)] + name: String, + rect: RectM, +} + +#[derive(Deserialize)] +struct RectM { + x0: f64, + y0: f64, + x1: f64, + y1: f64, +} + +fn yes() -> bool { + true +} + +fn one() -> f32 { + 1.0 +} + +/// A node, written without the fields at their defaults. +#[derive(Deserialize)] +struct NodeM { + #[serde(default)] + name: Option, + #[serde(default = "yes")] + visible: bool, + #[serde(default = "one")] + opacity: f32, + #[serde(default)] + blend: Option, + kind: KindM, +} + +#[derive(Deserialize)] +struct KindM { + #[serde(rename = "type")] + kind: String, +} + +pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result { + let json = unpack(bytes, budget)?.json; + stamp(&json, budget, false)?; + let f: WithDocument = parse(&json, budget.limits)?; + let pages = f + .document + .artboards + .into_iter() + .map(|a| Page { + name: a.name, + x: a.rect.x0.min(a.rect.x1), + y: a.rect.y0.min(a.rect.y1), + width: (a.rect.x1 - a.rect.x0).abs(), + height: (a.rect.y1 - a.rect.y0).abs(), + timing: None, + layers: vec![], + }) + .collect(); + let layers = f + .document + .layers + .into_iter() + .map(|n| Layer { + name: n.name.unwrap_or_default(), + kind: n.kind.kind, + visible: n.visible, + opacity: Some(n.opacity), + blend: Some(n.blend.unwrap_or_else(|| "Normal".into())), + children: vec![], + }) + .collect(); + Ok(Outline { canvas: None, pages, layers }) +} diff --git a/crates/doc/src/zip.rs b/crates/doc/src/zip.rs new file mode 100644 index 00000000..ec0a4469 --- /dev/null +++ b/crates/doc/src/zip.rs @@ -0,0 +1,183 @@ +//! A ZIP reader for `.pcraft` bundles: STORE and DEFLATE entries, found through the central +//! directory, CRC-checked. No ZIP64, no encryption, no archives split across disks. + +use std::io::Read; +use std::ops::Range; + +use crate::{Budget, Error, malformed}; + +const LOCAL_SIG: u32 = 0x0403_4b50; +const CENTRAL_SIG: u32 = 0x0201_4b50; +const EOCD_SIG: u32 = 0x0605_4b50; +const ZIP64_LOCATOR_SIG: u32 = 0x0706_4b50; +const EOCD_LEN: usize = 22; +const CENTRAL_LEN: usize = 46; +const LOCAL_LEN: usize = 30; + +fn u16_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(2)?)?; + Some(u16::from_le_bytes([*s.first()?, *s.get(1)?])) +} + +fn u32_at(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes([*s.first()?, *s.get(1)?, *s.get(2)?, *s.get(3)?])) +} + +fn truncated() -> Error { + malformed("truncated ZIP archive") +} + +/// One entry of the central directory. +pub(crate) struct Entry { + name: Range, + flags: u16, + method: u16, + crc: u32, + compressed: usize, + size: u64, + /// Where the entry's data starts. + data: usize, +} + +pub(crate) struct Zip<'a> { + bytes: &'a [u8], + entries: Vec, +} + +impl<'a> Zip<'a> { + pub(crate) fn open(bytes: &'a [u8]) -> Result { + let last = bytes.len().checked_sub(EOCD_LEN).ok_or_else(|| malformed("not a ZIP archive (too short)"))?; + // The comment may itself hold the signature: accept only a record whose comment ends the + // file and whose central directory ends where the record starts. + let eocd = (last.saturating_sub(65535)..=last) + .rev() + .find(|&i| { + let comment_end = u16_at(bytes, i + 20).and_then(|n| i.checked_add(EOCD_LEN)?.checked_add(usize::from(n))); + let dir_end = u32_at(bytes, i + 16) + .zip(u32_at(bytes, i + 12)) + .and_then(|(off, size)| usize::try_from(off).ok()?.checked_add(usize::try_from(size).ok()?)); + u32_at(bytes, i) == Some(EOCD_SIG) && comment_end == Some(bytes.len()) && dir_end == Some(i) + }) + .ok_or_else(|| { + let zip64 = (last.saturating_sub(65535 + 20)..=last.saturating_sub(20)).any(|i| u32_at(bytes, i) == Some(ZIP64_LOCATOR_SIG)); + if zip64 { + Error::Unsupported("ZIP64 archives are not supported".into()) + } else { + malformed("ZIP end of central directory not found") + } + })?; + let disk = u16_at(bytes, eocd + 4).ok_or_else(truncated)?; + let dir_disk = u16_at(bytes, eocd + 6).ok_or_else(truncated)?; + let count = usize::from(u16_at(bytes, eocd + 10).ok_or_else(truncated)?); + if disk != 0 || dir_disk != 0 { + return Err(Error::Unsupported("ZIP archives split across disks are not supported".into())); + } + let dir_size = u32_at(bytes, eocd + 12).ok_or_else(truncated)? as usize; + let dir = eocd - dir_size; + let mut entries = Vec::with_capacity(count.min(dir_size / CENTRAL_LEN)); + let mut p = dir; + for _ in 0..count { + if u32_at(bytes, p) != Some(CENTRAL_SIG) || p + CENTRAL_LEN > eocd { + return Err(malformed("bad ZIP central directory entry")); + } + let field16 = |o: usize| u16_at(bytes, p + o).ok_or_else(truncated); + let field32 = |o: usize| u32_at(bytes, p + o).ok_or_else(truncated); + let (flags, method, crc) = (field16(8)?, field16(10)?, field32(16)?); + let (compressed, size) = (field32(20)?, field32(24)?); + let (name_len, extra_len, comment_len) = (usize::from(field16(28)?), usize::from(field16(30)?), usize::from(field16(32)?)); + let local = field32(42)? as usize; + if compressed == u32::MAX || size == u32::MAX || local == u32::MAX as usize { + return Err(Error::Unsupported("ZIP64 entries are not supported".into())); + } + let name_start = p + CENTRAL_LEN; + let name_end = name_start + name_len; + let next = name_end + extra_len + comment_len; + if next > eocd { + return Err(malformed("ZIP central directory entry out of range")); + } + if u32_at(bytes, local) != Some(LOCAL_SIG) { + return Err(malformed("bad ZIP local header")); + } + let local_name = usize::from(u16_at(bytes, local + 26).ok_or_else(truncated)?); + let local_extra = usize::from(u16_at(bytes, local + 28).ok_or_else(truncated)?); + let data = local.checked_add(LOCAL_LEN + local_name + local_extra).ok_or_else(truncated)?; + let compressed = compressed as usize; + if data.checked_add(compressed).is_none_or(|end| end > bytes.len()) { + return Err(malformed("ZIP entry data out of range")); + } + entries.push(Entry { name: name_start..name_end, flags, method, crc, compressed, size: u64::from(size), data }); + p = next; + } + Ok(Zip { bytes, entries }) + } + + /// The first entry named `name`. + pub(crate) fn find(&self, name: &str) -> Option<&Entry> { + self.entries.iter().find(|e| self.bytes.get(e.name.clone()) == Some(name.as_bytes())) + } + + /// The entry's bytes, decompressed and CRC-checked; their size is checked against `max` and + /// counted against `budget` first. + pub(crate) fn read(&self, e: &Entry, max: u64, budget: &mut Budget, what: &str) -> Result, Error> { + if e.flags & 1 != 0 { + return Err(Error::Unsupported(format!("{what} is encrypted"))); + } + if !matches!(e.method, 0 | 8) { + return Err(Error::Unsupported(format!("{what} uses ZIP compression method {}", e.method))); + } + if e.size > max { + return Err(Error::LimitExceeded(format!("{what} is {} bytes, more than the limit ({max})", e.size))); + } + budget.take(e.size, what)?; + let raw = self.bytes.get(e.data..e.data + e.compressed).ok_or_else(truncated)?; + let size = usize::try_from(e.size).map_err(|_| Error::LimitExceeded(format!("{what} does not fit in memory")))?; + let data = if e.method == 0 { + if e.compressed != size { + return Err(malformed(format!("{what} has the wrong size"))); + } + raw.to_vec() + } else { + // DEFLATE expands at most 1032 times. + let mut out = Vec::with_capacity(size.min(raw.len().saturating_mul(1032))); + flate2::read::DeflateDecoder::new(raw).take(e.size + 1).read_to_end(&mut out).map_err(|err| malformed(format!("{what}: {err}")))?; + if out.len() != size { + return Err(malformed(format!("{what} has the wrong size"))); + } + out + }; + let mut crc = flate2::Crc::new(); + crc.update(&data); + if crc.sum() != e.crc { + return Err(malformed(format!("{what} failed its CRC check"))); + } + Ok(data) + } +} + +/// Does a local header in `head` name `name`? Walks the local headers from the start, for a +/// head too short to reach the central directory; stops at an entry whose size is only in a +/// data descriptor. +pub(crate) fn local_names_include(head: &[u8], name: &str) -> bool { + let mut p = 0usize; + while u32_at(head, p) == Some(LOCAL_SIG) { + let (Some(flags), Some(compressed), Some(name_len), Some(extra_len)) = + (u16_at(head, p + 6), u32_at(head, p + 18), u16_at(head, p + 26), u16_at(head, p + 28)) + else { + return false; + }; + let start = p + LOCAL_LEN; + let end = start + usize::from(name_len); + if head.get(start..end) == Some(name.as_bytes()) { + return true; + } + if flags & 8 != 0 { + return false; + } + p = match end.checked_add(usize::from(extra_len)).and_then(|q| q.checked_add(compressed as usize)) { + Some(q) => q, + None => return false, + }; + } + false +} diff --git a/crates/doc/tests/common/mod.rs b/crates/doc/tests/common/mod.rs new file mode 100644 index 00000000..727379d2 --- /dev/null +++ b/crates/doc/tests/common/mod.rs @@ -0,0 +1,222 @@ +//! Synthetic documents built from scratch for the tests: hand-written JSON, a small literal PDF, +//! PNG files encoded here, ZIP archives and gzip streams assembled here. +#![allow(dead_code)] + +use std::io::Write; + +/// A one-page PDF with an empty page. +pub const PDF: &[u8] = b"%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 72 72] >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF\n"; + +pub const BLOB_MAGIC: &[u8] = b"\n\0VCBLOBS\0"; + +pub fn gzip(bytes: &[u8]) -> Vec { + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + gz.write_all(bytes).unwrap(); + gz.finish().unwrap() +} + +pub fn deflate(bytes: &[u8]) -> Vec { + let mut d = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + d.write_all(bytes).unwrap(); + d.finish().unwrap() +} + +pub fn crc32(bytes: &[u8]) -> u32 { + let mut c = flate2::Crc::new(); + c.update(bytes); + c.sum() +} + +pub fn base64(data: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for c in data.chunks(3) { + let n = (u32::from(c[0]) << 16) | (u32::from(*c.get(1).unwrap_or(&0)) << 8) | u32::from(*c.get(2).unwrap_or(&0)); + out.push(A[(n >> 18) as usize & 63] as char); + out.push(A[(n >> 12) as usize & 63] as char); + out.push(if c.len() > 1 { A[(n >> 6) as usize & 63] as char } else { '=' }); + out.push(if c.len() > 2 { A[n as usize & 63] as char } else { '=' }); + } + out +} + +/// A gray `w` × `h` PNG with every pixel `value`. +pub fn png(w: u32, h: u32, value: u8) -> Vec { + fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend((data.len() as u32).to_be_bytes()); + let mut body = kind.to_vec(); + body.extend(data); + out.extend(&body); + out.extend(crc32(&body).to_be_bytes()); + } + let mut ihdr = w.to_be_bytes().to_vec(); + ihdr.extend(h.to_be_bytes()); + ihdr.extend([8, 0, 0, 0, 0]); + let mut raw = vec![]; + for _ in 0..h { + raw.push(0); + raw.extend(std::iter::repeat_n(value, w as usize)); + } + let mut z = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default()); + z.write_all(&raw).unwrap(); + let mut out = b"\x89PNG\r\n\x1a\n".to_vec(); + chunk(&mut out, b"IHDR", &ihdr); + chunk(&mut out, b"IDAT", &z.finish().unwrap()); + chunk(&mut out, b"IEND", &[]); + out +} + +/// One ZIP entry: its name, its bytes, and whether it is DEFLATE-compressed. +pub struct Entry<'a> { + pub name: &'a str, + pub data: &'a [u8], + pub deflate: bool, +} + +pub fn store<'a>(name: &'a str, data: &'a [u8]) -> Entry<'a> { + Entry { name, data, deflate: false } +} + +pub fn deflated<'a>(name: &'a str, data: &'a [u8]) -> Entry<'a> { + Entry { name, data, deflate: true } +} + +/// A ZIP archive of `entries`, in order, with a central directory and an end record. +pub fn zip(entries: &[Entry]) -> Vec { + let mut out = vec![]; + let mut central = vec![]; + for e in entries { + let payload = if e.deflate { deflate(e.data) } else { e.data.to_vec() }; + let crc = crc32(e.data); + let method: u16 = if e.deflate { 8 } else { 0 }; + let offset = out.len() as u32; + let fields = |out: &mut Vec| { + out.extend(20u16.to_le_bytes()); + out.extend(0x0800u16.to_le_bytes()); + out.extend(method.to_le_bytes()); + out.extend(0u16.to_le_bytes()); + out.extend(0x21u16.to_le_bytes()); + out.extend(crc.to_le_bytes()); + out.extend((payload.len() as u32).to_le_bytes()); + out.extend((e.data.len() as u32).to_le_bytes()); + out.extend((e.name.len() as u16).to_le_bytes()); + out.extend(0u16.to_le_bytes()); + }; + out.extend(0x0403_4b50u32.to_le_bytes()); + fields(&mut out); + out.extend(e.name.as_bytes()); + out.extend(&payload); + central.extend(0x0201_4b50u32.to_le_bytes()); + central.extend(20u16.to_le_bytes()); + fields(&mut central); + // Comment length, disk, internal and external attributes. + central.extend([0u8; 10]); + central.extend(offset.to_le_bytes()); + central.extend(e.name.as_bytes()); + } + let dir_offset = out.len() as u32; + out.extend(¢ral); + out.extend(0x0605_4b50u32.to_le_bytes()); + out.extend([0u8; 4]); + out.extend((entries.len() as u16).to_le_bytes()); + out.extend((entries.len() as u16).to_le_bytes()); + out.extend((central.len() as u32).to_le_bytes()); + out.extend(dir_offset.to_le_bytes()); + out.extend(0u16.to_le_bytes()); + out +} + +/// A VectorCraft document member holding `bytes` as base64. +pub fn inline(mime: &str, bytes: &[u8]) -> String { + format!(r#"{{"mime":"{mime}","data":"{}"}}"#, base64(bytes)) +} + +/// A VectorCraft document member pointing at the data after the JSON. +pub fn blob(mime: &str, offset: usize, len: usize) -> String { + format!(r#"{{"mime":"{mime}","blob":[{offset},{len}]}}"#) +} + +/// The JSON of a VectorCraft file: `members` go between `generator` and `document`. +pub fn vectorcraft_json(version: u32, members: &str) -> String { + let document = r#"{"version":1,"title":"","units":"Points","color_mode":"Rgb", + "artboards":[{"id":1,"name":"Front","rect":{"x0":0.0,"y0":0.0,"x1":612.0,"y1":792.0},"show_center_mark":false,"show_cross_hairs":false}, + {"id":2,"name":"Back","rect":{"x0":700.0,"y0":800.0,"x1":650.0,"y1":700.0},"show_center_mark":false,"show_cross_hairs":false}], + "layers":[{"id":1,"name":"Art","kind":{"type":"layer","color":{"Preset":0},"template":false,"printable":true, + "children":[{"id":3,"kind":{"type":"group","children":[{"id":4,"kind":{"type":"path","path":{"subpaths":[]}}}]}}]}}, + {"id":2,"name":"Notes","visible":false,"opacity":0.5,"blend":"Multiply","kind":{"type":"layer","color":{"Preset":1},"template":false,"printable":false,"children":[]}}], + "next_id":5}"#; + let sep = if members.is_empty() { "" } else { "," }; + format!(r#"{{"format":"vectorcraft","version":{version},"generator":"VectorCraft 0.1.0"{sep}{members},"document":{document},"images":{{}}}}"#) +} + +/// A PhotoCraft layer as the manifest writes it. +pub fn pcraft_layer(name: &str, visible: bool, blend: &str, opacity: f32, content: &str) -> String { + format!( + r#"{{"id":1,"name":"{name}","visible":{visible},"locks":{{}},"blend":"{blend}","opacity":{opacity},"fill_opacity":1.0,"clipped":false,"mask":null, + "effects":{{"enabled":true,"items":[],"psd_raw":null}},"label":"None","content":{content},"psd_blocks":[],"psd_id":null,"fill_cache":null}}"# + ) +} + +pub fn raster() -> &'static str { + r#"{"kind":"raster","surface":{"format":{"mode":"Rgb","sample":"U8","alpha":true},"default":"00000000","tiles":[]}}"# +} + +/// A PhotoCraft manifest with `layers` (a JSON array) and the preview paths given. +pub fn pcraft_manifest(version: u32, layers: &str, previews: &str) -> String { + format!( + r#"{{"format_version":{version},"generator":"photocraft-format 0.1.0", + "document":{{"id":1,"name":"Untitled","size":{{"width":640,"height":480}},"resolution_dpi":300.0,"mode":"Rgb","depth":"U16", + "icc_profile":null,"layers":{layers},"channels":[],"guides":{{}},"selection":null,"metadata":{{}},"global_light":{{}}}}{previews}}}"# + ) +} + +/// A `.pcraft` bundle: a tile, the previews, and the manifest last. +pub fn pcraft(thumb: &[u8], composite: &[u8]) -> Vec { + let layers = format!( + "[{},{}]", + pcraft_layer("Background", true, "Normal", 1.0, raster()), + pcraft_layer( + "Group", + false, + "Screen", + 0.25, + &format!(r#"{{"kind":"group","expanded":true,"children":[{}]}}"#, pcraft_layer("Inner", true, "Multiply", 0.5, raster())) + ) + ); + let manifest = pcraft_manifest(1, &layers, r#","thumbnail":"thumb.png","composite":"composite/preview.png""#); + zip(&[ + store("tiles/0123.zst", b"not really zstd"), + store("thumb.png", thumb), + deflated("composite/preview.png", composite), + deflated("manifest.json", manifest.as_bytes()), + ]) +} + +/// An EffectCraft project with a folder, a footage item and two compositions. +pub fn ecproj(schema: u32) -> String { + format!( + r#"{{ + "savedBy": "0.12.0", + "activeComp": 10, + "schema": {schema}, + "settings": {{}}, + "items": {{ + "1": {{"id": 1, "name": "Footage", "label": 0, "kind": {{"type": "Folder"}}}}, + "2": {{"id": 2, "name": "clip.mov", "label": 0, "parent": 1, + "kind": {{"type": "Footage", "path": "clip.mov", "kind": "Video", "width": 640, "height": 360, "pixel_aspect": 1.0, + "frame_rate": {{"num": 30, "den": 1}}, "duration": 2540160000000}}}}, + "10": {{"id": 10, "name": "Main", "label": 0, + "kind": {{"type": "Comp", "width": 1920, "height": 1080, "pixel_aspect": 1.0, "frame_rate": {{"num": 24000, "den": 1001}}, + "duration": 1270080000000, "display_start": 0, "background": [0.0, 0.0, 0.0], "work_area": [0, 1270080000000], + "layers": [ + {{"id": 1, "name": "Title", "source": {{"type": "Text"}}, "switches": {{"video": true, "audio": false}}, "blend_mode": "Add"}}, + {{"id": 2, "name": "Plate", "source": {{"type": "Footage", "item": 2}}, "switches": {{"video": false, "audio": true}}, "blend_mode": "Normal"}} + ]}}}}, + "3": {{"id": 3, "name": "Intro", "label": 0, + "kind": {{"type": "Comp", "width": 640, "height": 360, "pixel_aspect": 1.0, "frame_rate": {{"num": 30, "den": 1}}, + "duration": 254016000000, "layers": []}}}} + }}, + "next_id": 11 +}}"# + ) +} diff --git a/crates/doc/tests/envelopes.rs b/crates/doc/tests/envelopes.rs new file mode 100644 index 00000000..15828919 --- /dev/null +++ b/crates/doc/tests/envelopes.rs @@ -0,0 +1,138 @@ +//! Documents whose envelope is written with the format crates' own types read through craft-doc, +//! and envelopes those crates refuse are refused here too. + +mod common; + +use std::collections::BTreeMap; + +use common::*; +use craft_doc::{Error, Kind, Limits, RenditionKind, info, outline, renditions, sniff}; + +fn limits() -> Limits { + Limits::default() +} + +fn vectorcraft_document() -> serde_json::Value { + serde_json::json!({ + "artboards": [{"id": 1, "name": "Front", "rect": {"x0": 0.0, "y0": 0.0, "x1": 612.0, "y1": 792.0}}], + "layers": [{"id": 1, "name": "Art", "kind": {"type": "layer", "children": []}}], + }) +} + +#[test] +fn a_vectorcraft_file_written_with_the_envelope_types_reads() { + let preview = png(16, 16, 3); + let pdf: Vec = PDF.iter().copied().cycle().take(craft_vectorcraft_format::INLINE_MAX + 1).collect(); + let mut tail = craft_vectorcraft_format::Tail::new(craft_vectorcraft_format::VERSION); + let mut file = craft_vectorcraft_format::File::new(vectorcraft_document()); + file.generator = Some("VectorCraft 0.1.0".into()); + file.preview = Some(tail.image("image/png", &preview, false)); + file.pdf = Some(tail.image("application/pdf", &pdf, false)); + assert!(file.pdf.as_ref().unwrap().blob.is_some() && file.preview.as_ref().unwrap().blob.is_none()); + let mut bytes = serde_json::to_vec(&file).unwrap(); + tail.write_to(&mut bytes).unwrap(); + + for bytes in [bytes.clone(), gzip(&bytes)] { + assert_eq!(sniff(&bytes), Some(Kind::VectorCraft)); + let i = info(&bytes, &limits()).unwrap(); + assert_eq!((i.version, i.generator.as_deref()), (craft_vectorcraft_format::VERSION, Some("VectorCraft 0.1.0"))); + let r = renditions(&bytes, &limits()).unwrap(); + assert_eq!((r[0].kind, r[0].bytes.as_slice()), (RenditionKind::Thumbnail, preview.as_slice())); + assert_eq!((r[1].kind, r[1].bytes.as_slice()), (RenditionKind::Pdf, pdf.as_slice())); + let o = outline(&bytes, &limits()).unwrap(); + assert_eq!((o.pages[0].name.as_str(), o.pages[0].width, o.layers[0].name.as_str()), ("Front", 612.0, "Art")); + } +} + +#[test] +fn a_pcraft_manifest_written_with_the_envelope_type_reads() { + let document = serde_json::json!({ + "size": {"width": 64, "height": 32}, "resolution_dpi": 72.0, "mode": "Rgb", "depth": "U8", + "layers": [{"name": "Background", "visible": true, "blend": "Normal", "opacity": 1.0, "content": {"kind": "raster"}}], + }); + let mut manifest = craft_pcraft_format::Manifest::new("photocraft-format 0.1.0", document); + manifest.thumbnail = Some(craft_pcraft_format::THUMB.into()); + let json = serde_json::to_vec(&manifest).unwrap(); + let thumb = png(4, 4, 1); + let bundle = zip(&[store(craft_pcraft_format::MANIFEST, &json), store(craft_pcraft_format::THUMB, &thumb)]); + + assert_eq!(sniff(&bundle), Some(Kind::PhotoCraft)); + assert_eq!(info(&bundle, &limits()).unwrap().generator.as_deref(), Some("photocraft-format 0.1.0")); + let r = renditions(&bundle, &limits()).unwrap(); + assert_eq!((r.len(), r[0].kind, r[0].bytes.as_slice()), (1, RenditionKind::Thumbnail, thumb.as_slice())); + let o = outline(&bundle, &limits()).unwrap(); + assert_eq!((o.canvas.unwrap().width, o.layers[0].name.as_str()), (64, "Background")); +} + +#[test] +fn an_ecproj_file_written_with_the_envelope_type_reads() { + let view = BTreeMap::from([("activeComp", 1)]); + let project = serde_json::json!({ + "schema": craft_ecproj_format::SCHEMA_VERSION, + "items": {"1": {"id": 1, "name": "Main", "kind": {"type": "Comp", "width": 1280, "height": 720, "layers": []}}}, + }); + let text = craft_ecproj_format::File::new("0.12.0", &view, &project).to_json().unwrap(); + + assert_eq!(sniff(text.as_bytes()), Some(Kind::EffectCraft)); + let i = info(text.as_bytes(), &limits()).unwrap(); + assert_eq!((i.version, i.generator.as_deref()), (craft_ecproj_format::SCHEMA_VERSION, Some("0.12.0"))); + let o = outline(text.as_bytes(), &limits()).unwrap(); + assert_eq!((o.pages[0].name.as_str(), o.pages[0].width), ("Main", 1280.0)); +} + +#[test] +fn a_pcraft_manifest_without_a_generator_is_malformed() { + let manifest = pcraft_manifest(1, "[]", "").replacen(r#""generator":"photocraft-format 0.1.0","#, "", 1); + let bundle = zip(&[store("manifest.json", manifest.as_bytes())]); + assert_eq!(info(&bundle, &limits()).unwrap().generator, None); + assert!(matches!(renditions(&bundle, &limits()), Err(Error::Malformed(_)))); + assert!(matches!(outline(&bundle, &limits()), Err(Error::Malformed(_)))); +} + +#[test] +fn a_pcraft_preview_naming_another_entry_is_malformed() { + let manifest = pcraft_manifest(1, "[]", r#","thumbnail":"../thumb.png""#); + let bundle = zip(&[store("manifest.json", manifest.as_bytes()), store("../thumb.png", &png(1, 1, 0))]); + assert!(matches!(renditions(&bundle, &limits()), Err(Error::Malformed(_)))); + assert!(matches!(outline(&bundle, &limits()), Err(Error::Malformed(_)))); +} + +#[test] +fn an_ecproj_nested_to_max_depth_reads() { + let deep = |levels: usize| format!(r#"{{"savedBy":"0.12.0","schema":2,"items":{{}},"x":{}{}}}"#, "[".repeat(levels), "]".repeat(levels)); + let levels = craft_doc::MAX_DEPTH as usize - 1; + let objects = format!(r#"{{"savedBy":"0.12.0","schema":2,"items":{{}},"x":{}1{}}}"#, r#"{"a":"#.repeat(levels), "}".repeat(levels)); + for ok in [deep(levels), objects] { + assert_eq!(info(ok.as_bytes(), &limits()).unwrap().version, 2); + assert_eq!(renditions(ok.as_bytes(), &limits()).unwrap(), vec![]); + assert_eq!(outline(ok.as_bytes(), &limits()).unwrap().pages.len(), 0); + } +} + +#[test] +fn a_pcraft_manifest_with_a_repeated_member_or_a_late_newer_version_is_refused() { + let repeated = pcraft_manifest(1, "[]", r#","thumbnail":"thumb.png","thumbnail":"thumb.png""#); + let bundle = zip(&[store("manifest.json", repeated.as_bytes())]); + assert!(matches!(renditions(&bundle, &limits()), Err(Error::Malformed(_)))); + let late = r#"{"generator":"photocraft-format 9","document":"elsewhere","thumbnail":7,"format_version":2}"#; + let bundle = zip(&[store("manifest.json", late.as_bytes())]); + let refused = Error::TooNew { kind: Kind::PhotoCraft, version: 2, supported: Kind::PhotoCraft.supported_version() }; + assert_eq!(renditions(&bundle, &limits()), Err(refused.clone())); + assert_eq!(outline(&bundle, &limits()), Err(refused)); +} + +#[test] +fn a_vectorcraft_member_without_a_mime_type_is_skipped() { + let file = vectorcraft_json(4, &format!(r#""preview":{{"data":"{}"}}"#, base64(&png(1, 1, 0)))); + assert!(renditions(file.as_bytes(), &limits()).unwrap().is_empty()); + let empty = vectorcraft_json(4, &format!(r#""preview":{},"pdf":{}"#, inline("", &png(1, 1, 0)), inline("", PDF))); + assert!(renditions(empty.as_bytes(), &limits()).unwrap().is_empty()); +} + +#[test] +fn a_format_member_inside_another_member_is_not_vectorcraft() { + let nested = br#"{"meta":{"format":"vectorcraft"},"version":4}"#; + assert_eq!(sniff(nested), None); + let second = br#"{"version":4,"format":"vectorcraft","document":{}}"#; + assert_eq!(sniff(second), Some(Kind::VectorCraft)); +} diff --git a/crates/doc/tests/formats.rs b/crates/doc/tests/formats.rs new file mode 100644 index 00000000..d18b888c --- /dev/null +++ b/crates/doc/tests/formats.rs @@ -0,0 +1,264 @@ +//! Each format's documents, built from scratch: what `sniff`, `info`, `renditions` and `outline` +//! return for them. + +mod common; + +use common::*; +use craft_doc::{Error, Kind, Limits, RenditionKind, info, outline, renditions, sniff}; + +fn limits() -> Limits { + Limits::default() +} + +#[test] +fn sniff_tells_the_three_formats() { + let vc = vectorcraft_json(4, ""); + assert_eq!(sniff(vc.as_bytes()), Some(Kind::VectorCraft)); + assert_eq!(sniff(&gzip(vc.as_bytes())), Some(Kind::VectorCraft)); + let legacy = vc.replacen("\"vectorcraft\"", "\"drawcraft\"", 1); + assert_eq!(sniff(legacy.as_bytes()), Some(Kind::VectorCraft)); + // A gzip stream cut short still shows its start. + let gz = gzip(vc.as_bytes()); + assert_eq!(sniff(gz.get(..gz.len() - 8).unwrap()), Some(Kind::VectorCraft)); + + let pc = pcraft(&png(4, 4, 9), &png(8, 8, 9)); + assert_eq!(sniff(&pc), Some(Kind::PhotoCraft), "the manifest last, found through the central directory"); + let first = zip(&[store("manifest.json", b"{}"), store("thumb.png", &png(1, 1, 0))]); + assert_eq!(sniff(first.get(..60).unwrap()), Some(Kind::PhotoCraft), "the manifest first, in a short head"); + let second = zip(&[store("tiles/a.zst", b"x"), store("manifest.json", b"{}")]); + assert_eq!(sniff(second.get(..100).unwrap()), Some(Kind::PhotoCraft), "a later local header in the head"); + + assert_eq!(sniff(ecproj(2).as_bytes()), Some(Kind::EffectCraft)); + let old = r#"{"schema":1,"settings":{},"items":{},"next_id":1}"#; + assert_eq!(sniff(old.as_bytes()), Some(Kind::EffectCraft), "saved before savedBy"); +} + +#[test] +fn sniff_refuses_other_files() { + let plain_zip = zip(&[store("word/document.xml", b""), deflated("readme.txt", b"hello hello hello")]); + let behind = zip(&[store("tiles/a.zst", &[7; 4096]), store("manifest.json", b"{}")]); + for (what, bytes) in [ + ("empty", &b""[..]), + ("a PDF", PDF), + ("a PNG", &png(2, 2, 1)), + ("a ZIP without a manifest", &plain_zip), + ("a manifest past the head", behind.get(..100).unwrap()), + ("other JSON", br#"{"name":"x","items":[],"schema":"y"}"#), + ("JSON naming the format in a value", br#"{"title":"vectorcraft"}"#), + ("another format", br#"{"format":"vectorcraftx","version":4}"#), + ("a JSON array", br#"["vectorcraft"]"#), + ("savedBy without schema", br#"{"savedBy":"1.0","items":{}}"#), + ("savedBy with schema only in a nested object", br#"{"savedBy":"1.0","metadata":{"schema":1}}"#), + ("savedBy with schema only in a value", br#"{"savedBy":"1.0","note":"\"schema\""}"#), + ("schema with items only in a nested object", br#"{"schema":1,"meta":{"items":[]}}"#), + ("gzip of other JSON", &gzip(br#"{"format":"svg"}"#)), + ("PK alone", b"PK\x03\x04"), + ] { + assert_eq!(sniff(bytes), None, "{what}"); + } +} + +#[test] +fn info_reads_version_and_writer() { + let i = info(vectorcraft_json(3, "").as_bytes(), &limits()).unwrap(); + assert_eq!((i.kind, i.version, i.generator.as_deref(), i.supported), (Kind::VectorCraft, 3, Some("VectorCraft 0.1.0"), true)); + let i = info(&gzip(vectorcraft_json(4, "").as_bytes()), &limits()).unwrap(); + assert_eq!((i.version, i.supported), (4, true)); + + let i = info(&pcraft(&png(1, 1, 0), &png(1, 1, 0)), &limits()).unwrap(); + assert_eq!((i.kind, i.version, i.generator.as_deref(), i.supported), (Kind::PhotoCraft, 1, Some("photocraft-format 0.1.0"), true)); + + let i = info(ecproj(2).as_bytes(), &limits()).unwrap(); + assert_eq!((i.kind, i.version, i.generator.as_deref(), i.supported), (Kind::EffectCraft, 2, Some("0.12.0"), true)); + let i = info(br#"{"schema":1,"settings":{},"items":{},"next_id":1}"#, &limits()).unwrap(); + assert_eq!((i.version, i.generator), (1, None)); + + assert_eq!(info(PDF, &limits()), Err(Error::Unrecognized)); +} + +#[test] +fn too_new_files_are_reported_and_refused() { + // Newer versions may change everything after the version: a member of a new shape. + let vc = vectorcraft_json(5, r#""pdf":"elsewhere""#); + let manifest = r#"{"format_version":2,"generator":"photocraft-format 9","document":"elsewhere","thumbnail":7}"#; + let pc = zip(&[store("manifest.json", manifest.as_bytes())]); + let ec = r#"{"savedBy":"9.0.0","schema":3,"items":[]}"#.to_string(); + for (bytes, kind, version) in [(vc.into_bytes(), Kind::VectorCraft, 5), (pc, Kind::PhotoCraft, 2), (ec.into_bytes(), Kind::EffectCraft, 3)] { + let i = info(&bytes, &limits()).unwrap(); + assert_eq!((i.kind, i.version, i.supported), (kind, version, false)); + let refused = Error::TooNew { kind, version, supported: kind.supported_version() }; + assert_eq!(renditions(&bytes, &limits()), Err(refused.clone()), "{kind:?}"); + assert_eq!(outline(&bytes, &limits()), Err(refused), "{kind:?}"); + } +} + +#[test] +fn a_pcraft_format_version_of_zero_is_malformed() { + let pc = zip(&[store("manifest.json", pcraft_manifest(0, "[]", "").as_bytes())]); + assert!(matches!(info(&pc, &limits()), Err(Error::Malformed(_)))); + assert!(matches!(outline(&pc, &limits()), Err(Error::Malformed(_)))); +} + +#[test] +fn vectorcraft_members_inline_as_base64() { + let preview = png(16, 8, 200); + let members = format!(r#""preview":{},"pdf":{}"#, inline("image/png", &preview), inline("application/pdf", PDF)); + let json = vectorcraft_json(3, &members); + for bytes in [json.clone().into_bytes(), gzip(json.as_bytes())] { + let r = renditions(&bytes, &limits()).unwrap(); + assert_eq!(r.len(), 2); + assert_eq!((r[0].kind, r[0].mime.as_str(), r[0].bytes.as_slice()), (RenditionKind::Thumbnail, "image/png", preview.as_slice())); + assert_eq!((r[1].kind, r[1].mime.as_str(), r[1].bytes.as_slice()), (RenditionKind::Pdf, "application/pdf", PDF)); + } +} + +#[test] +fn vectorcraft_members_after_the_json() { + // Version 4: the PDF after the marker, behind an image the document uses; the preview inline. + let image = vec![0xAB; 9000]; + let preview = png(3, 3, 1); + let members = format!(r#""preview":{},"pdf":{}"#, inline("image/png", &preview), blob("application/pdf", image.len(), PDF.len())); + let mut file = vectorcraft_json(4, &members).into_bytes(); + file.extend(BLOB_MAGIC); + file.extend(&image); + file.extend(PDF); + for bytes in [file.clone(), gzip(&file)] { + let r = renditions(&bytes, &limits()).unwrap(); + assert_eq!(r.iter().map(|r| r.kind).collect::>(), [RenditionKind::Thumbnail, RenditionKind::Pdf]); + assert_eq!(r[0].bytes, preview); + assert_eq!(r[1].bytes, PDF); + // The JSON alone answers info and outline. + assert_eq!(info(&bytes, &limits()).unwrap().version, 4); + assert_eq!(outline(&bytes, &limits()).unwrap().pages.len(), 2); + } +} + +#[test] +fn a_compressed_marker_across_the_reader_s_64_kib_chunks() { + // The marker starts at every offset around the first 64 KiB boundary of the unpacked stream. + let base = vectorcraft_json(4, &format!(r#""pdf":{},"pad":"""#, blob("application/pdf", 0, PDF.len()))); + for json_len in (64 << 10) - 12..(64 << 10) + 2 { + let json = base.replacen(r#""pad":"""#, &format!(r#""pad":"{}""#, "x".repeat(json_len - base.len())), 1); + assert_eq!(json.len(), json_len); + let mut file = json.into_bytes(); + file.extend(BLOB_MAGIC); + file.extend(PDF); + let gz = gzip(&file); + assert_eq!(info(&gz, &limits()).unwrap().version, 4, "{json_len}"); + assert_eq!(outline(&gz, &limits()).unwrap().pages.len(), 2, "{json_len}"); + assert_eq!(renditions(&gz, &limits()).unwrap()[0].bytes, PDF, "{json_len}"); + } +} + +#[test] +fn a_padded_base64_member_fits_a_limit_of_its_own_size() { + for len in 1..=3 { + let data = vec![7u8; len]; + let bytes = vectorcraft_json(3, &format!(r#""preview":{}"#, inline("image/png", &data))).into_bytes(); + let mut l = limits(); + l.max_entry = len as u64; + assert_eq!(renditions(&bytes, &l).unwrap()[0].bytes, data); + } +} + +#[test] +fn vectorcraft_without_members_has_no_renditions() { + let json = vectorcraft_json(4, ""); + assert_eq!(renditions(json.as_bytes(), &limits()).unwrap(), vec![]); + assert_eq!(renditions(&gzip(json.as_bytes()), &limits()).unwrap(), vec![]); +} + +#[test] +fn vectorcraft_outline_has_artboards_and_top_level_layers() { + for bytes in [vectorcraft_json(4, "").into_bytes(), gzip(vectorcraft_json(4, "").as_bytes())] { + let o = outline(&bytes, &limits()).unwrap(); + assert_eq!(o.canvas, None); + let pages: Vec<_> = o.pages.iter().map(|p| (p.name.as_str(), p.x, p.y, p.width, p.height)).collect(); + assert_eq!(pages, [("Front", 0.0, 0.0, 612.0, 792.0), ("Back", 650.0, 700.0, 50.0, 100.0)]); + assert!(o.pages.iter().all(|p| p.timing.is_none() && p.layers.is_empty())); + let layers: Vec<_> = o.layers.iter().map(|l| (l.name.as_str(), l.kind.as_str(), l.visible, l.opacity, l.blend.as_deref())).collect(); + assert_eq!(layers, [("Art", "layer", true, Some(1.0), Some("Normal")), ("Notes", "layer", false, Some(0.5), Some("Multiply"))]); + assert!(o.layers.iter().all(|l| l.children.is_empty())); + } +} + +#[test] +fn pcraft_previews_stored_and_deflated_with_the_manifest_last() { + let (thumb, composite) = (png(4, 3, 10), png(40, 30, 20)); + let r = renditions(&pcraft(&thumb, &composite), &limits()).unwrap(); + assert_eq!(r.len(), 2); + assert_eq!((r[0].kind, r[0].mime.as_str(), &r[0].bytes), (RenditionKind::Thumbnail, "image/png", &thumb)); + assert_eq!((r[1].kind, r[1].mime.as_str(), &r[1].bytes), (RenditionKind::Composite, "image/png", &composite)); +} + +#[test] +fn pcraft_without_previews_has_none() { + let pc = zip(&[store("manifest.json", pcraft_manifest(1, "[]", "").as_bytes())]); + assert_eq!(renditions(&pc, &limits()).unwrap(), vec![]); + let missing = zip(&[store("manifest.json", pcraft_manifest(1, "[]", r#","thumbnail":"thumb.png""#).as_bytes())]); + assert!(matches!(renditions(&missing, &limits()), Err(Error::Malformed(_)))); +} + +#[test] +fn pcraft_outline_has_the_canvas_and_layer_tree() { + let o = outline(&pcraft(&png(1, 1, 0), &png(1, 1, 0)), &limits()).unwrap(); + let c = o.canvas.unwrap(); + assert_eq!((c.width, c.height, c.resolution_dpi, c.mode.as_str(), c.depth.as_str()), (640, 480, 300.0, "Rgb", "U16")); + assert!(o.pages.is_empty()); + let layers: Vec<_> = o.layers.iter().map(|l| (l.name.as_str(), l.kind.as_str(), l.visible, l.opacity, l.blend.as_deref())).collect(); + assert_eq!(layers, [("Background", "raster", true, Some(1.0), Some("Normal")), ("Group", "group", false, Some(0.25), Some("Screen"))]); + let inner = &o.layers[1].children; + assert_eq!(inner.len(), 1); + assert_eq!((inner[0].name.as_str(), inner[0].blend.as_deref(), inner[0].opacity), ("Inner", Some("Multiply"), Some(0.5))); +} + +#[test] +fn pcraft_groups_nest_to_the_depth_cap() { + // 3 levels to the layer list, 3 per group and 4 for the leaf: 397 of the 400 levels. + let mut layer = pcraft_layer("Leaf", true, "Normal", 1.0, raster()); + for i in 0..130 { + layer = pcraft_layer(&format!("G{i}"), true, "Normal", 1.0, &format!(r#"{{"kind":"group","expanded":false,"children":[{layer}]}}"#)); + } + let pc = zip(&[deflated("manifest.json", pcraft_manifest(1, &format!("[{layer}]"), "").as_bytes())]); + let o = outline(&pc, &limits()).unwrap(); + let mut depth = 0; + let mut l = &o.layers[0]; + while let Some(c) = l.children.first() { + depth += 1; + l = c; + } + assert_eq!((depth, l.name.as_str()), (130, "Leaf")); +} + +#[test] +fn ecproj_has_no_renditions_and_lists_its_comps() { + let p = ecproj(2); + assert_eq!(renditions(p.as_bytes(), &limits()).unwrap(), vec![]); + let o = outline(p.as_bytes(), &limits()).unwrap(); + assert!(o.canvas.is_none() && o.layers.is_empty()); + // By item id (3 before 10), the folder and the footage left out. + let pages: Vec<_> = o.pages.iter().map(|p| (p.name.as_str(), p.width, p.height)).collect(); + assert_eq!(pages, [("Intro", 640.0, 360.0), ("Main", 1920.0, 1080.0)]); + let t = o.pages[1].timing.unwrap(); + assert_eq!((t.frame_rate_num, t.frame_rate_den, t.duration_seconds), (24000, 1001, 5.0)); + assert_eq!(o.pages[0].timing.unwrap().duration_seconds, 1.0); + // Bottom first: the file lists the top layer first. + let layers: Vec<_> = o.pages[1].layers.iter().map(|l| (l.name.as_str(), l.kind.as_str(), l.visible, l.opacity, l.blend.as_deref())).collect(); + assert_eq!(layers, [("Plate", "Footage", false, None, Some("Normal")), ("Title", "Text", true, None, Some("Add"))]); +} + +#[test] +fn an_ecproj_comp_without_a_duration_has_no_timing() { + let p = ecproj(2).replacen(r#""duration": 254016000000, "#, "", 1); + let o = outline(p.as_bytes(), &limits()).unwrap(); + assert_eq!((o.pages[0].name.as_str(), o.pages[0].timing), ("Intro", None)); + assert!(o.pages[1].timing.is_some()); +} + +#[test] +fn kinds_name_their_extension_and_version() { + assert_eq!( + [Kind::PhotoCraft, Kind::VectorCraft, Kind::EffectCraft].map(|k| (k.extension(), k.supported_version())), + [("pcraft", 1), ("vectorcraft", 4), ("ecproj", 2)] + ); +} diff --git a/crates/doc/tests/hostile.rs b/crates/doc/tests/hostile.rs new file mode 100644 index 00000000..a5d22eb4 --- /dev/null +++ b/crates/doc/tests/hostile.rs @@ -0,0 +1,353 @@ +//! Truncated, oversized and hostile input: the reader returns an error within its `Limits` and +//! never panics. + +mod common; + +use common::*; +use craft_doc::{Error, Limits, info, outline, renditions, sniff}; +use proptest::prelude::*; + +fn small() -> Limits { + let mut l = Limits::default(); + l.max_json = 64 << 10; + l.max_entry = 64 << 10; + l.max_total = 256 << 10; + l +} + +fn limit_exceeded(r: Result) -> bool { + matches!(r, Err(Error::LimitExceeded(_))) +} + +/// Every call on `bytes`; none may panic. +fn exercise(bytes: &[u8], limits: &Limits) { + let _ = sniff(bytes); + let _ = info(bytes, limits); + let _ = renditions(bytes, limits); + let _ = outline(bytes, limits); +} + +fn samples() -> Vec> { + let members = format!(r#""preview":{},"pdf":{}"#, inline("image/png", &png(2, 2, 3)), blob("application/pdf", 0, PDF.len())); + let mut v4 = vectorcraft_json(4, &members).into_bytes(); + v4.extend(BLOB_MAGIC); + v4.extend(PDF); + vec![gzip(&v4), v4, pcraft(&png(3, 3, 1), &png(6, 6, 2)), ecproj(2).into_bytes()] +} + +#[test] +fn every_truncation_is_an_error_not_a_panic() { + for s in samples() { + for n in 0..s.len() { + exercise(&s[..n], &small()); + } + } +} + +#[test] +fn a_blob_longer_than_max_entry_is_refused_before_it_is_copied() { + let mut bytes = vectorcraft_json(4, &format!(r#""pdf":{}"#, blob("application/pdf", 0, 1 << 40))).into_bytes(); + bytes.extend(BLOB_MAGIC); + bytes.extend(PDF); + assert!(limit_exceeded(renditions(&bytes, &small()))); +} + +#[test] +fn a_blob_past_the_end_of_the_file_is_malformed() { + for at in [[PDF.len() as u64, 1], [u64::MAX, 1], [1, PDF.len() as u64]] { + let members = format!(r#""pdf":{{"mime":"application/pdf","blob":[{},{}]}}"#, at[0], at[1]); + let mut bytes = vectorcraft_json(4, &members).into_bytes(); + bytes.extend(BLOB_MAGIC); + bytes.extend(PDF); + assert!(matches!(renditions(&bytes, &small()), Err(Error::Malformed(_))), "{at:?}"); + } +} + +#[test] +fn base64_past_max_entry_is_refused() { + let big = vec![1u8; 100 << 10]; + let mut limits = small(); + limits.max_json = 1 << 20; + let bytes = vectorcraft_json(3, &format!(r#""pdf":{}"#, inline("application/pdf", &big))).into_bytes(); + assert!(limit_exceeded(renditions(&bytes, &limits))); + limits.max_entry = 1 << 20; + assert_eq!(renditions(&bytes, &limits).unwrap()[0].bytes, big); + let bad = vectorcraft_json(3, r#""pdf":{"mime":"application/pdf","data":"QU*D"}"#).into_bytes(); + assert!(matches!(renditions(&bad, &limits), Err(Error::Malformed(_)))); +} + +#[test] +fn a_gzip_bomb_stops_at_the_limits() { + // 8 MiB of padding in the JSON, compressed to a few KiB. + let pad = "x".repeat(8 << 20); + let json = vectorcraft_json(4, &format!(r#""pad":"{pad}""#)); + let gz = gzip(json.as_bytes()); + assert!(gz.len() < 64 << 10); + assert!(limit_exceeded(info(&gz, &small()))); + assert!(limit_exceeded(outline(&gz, &small()))); + assert!(limit_exceeded(renditions(&gz, &small()))); + // The data after the JSON counts against max_total as far as it is read. + let mut tail = vectorcraft_json(4, &format!(r#""pdf":{}"#, blob("application/pdf", 8 << 20, 1))).into_bytes(); + tail.extend(BLOB_MAGIC); + tail.extend(vec![0u8; (8 << 20) + 1]); + let gz = gzip(&tail); + assert!(limit_exceeded(renditions(&gz, &small()))); + let mut more = small(); + more.max_total = 9 << 20; + assert_eq!(renditions(&gz, &more).unwrap()[0].bytes, [0]); + // info stops at the marker and reads the JSON only. + assert_eq!(info(&gz, &small()).unwrap().version, 4); +} + +#[test] +fn a_compressed_member_past_max_entry_is_refused_before_the_tail_is_inflated() { + // A member declares 8 MiB; the data after the JSON is 8 MiB that compress to a few KiB. + let mut file = vectorcraft_json(4, &format!(r#""pdf":{}"#, blob("application/pdf", 0, 8 << 20))).into_bytes(); + file.extend(BLOB_MAGIC); + file.extend(vec![1u8; 8 << 20]); + let gz = gzip(&file); + let mut limits = small(); + // Enough for the tail, not for the member. + limits.max_total = 64 << 20; + match renditions(&gz, &limits) { + Err(Error::LimitExceeded(m)) => assert!(m.contains("max_entry"), "{m}"), + other => panic!("{other:?}"), + } +} + +#[test] +fn a_nul_outside_the_marker_is_malformed_without_reading_on() { + // A NUL where the marker is not, then 8 MiB that compress to almost nothing. + let mut file = vectorcraft_json(4, "").into_bytes(); + file.extend(b"\n\0NOTBLOBS\0"); + file.extend(vec![0u8; 8 << 20]); + let mut limits = small(); + limits.max_total = 1 << 20; + for bytes in [gzip(&file), file] { + assert!(matches!(info(&bytes, &limits), Err(Error::Malformed(_)))); + assert!(matches!(renditions(&bytes, &limits), Err(Error::Malformed(_)))); + assert!(matches!(outline(&bytes, &limits), Err(Error::Malformed(_)))); + } + // A NUL as the very first byte of the decompressed stream. + let mut first = b"\0".to_vec(); + first.extend(vectorcraft_json(4, "").as_bytes()); + exercise(&gzip(&first), &limits); +} + +#[test] +fn a_manifest_past_max_entry_is_refused() { + let pc = pcraft(&png(1, 1, 0), &png(1, 1, 0)); + let mut limits = small(); + limits.max_entry = 64; + assert!(limit_exceeded(info(&pc, &limits))); +} + +#[test] +fn an_uncompressed_document_past_max_json_is_refused() { + let json = vectorcraft_json(4, &format!(r#""pad":"{}""#, "x".repeat(100 << 10))); + assert!(limit_exceeded(info(json.as_bytes(), &small()))); + let ec = ecproj(2).replacen(r#""settings": {}"#, &format!(r#""settings": {{"pad":"{}"}}"#, "x".repeat(100 << 10)), 1); + assert!(limit_exceeded(outline(ec.as_bytes(), &small()))); +} + +#[test] +fn json_nested_past_max_depth_is_refused() { + let deep = format!("{}{}", "[".repeat(2000), "]".repeat(2000)); + let vc = vectorcraft_json(4, &format!(r#""deep":{deep}"#)); + assert!(limit_exceeded(outline(vc.as_bytes(), &Limits::default()))); + let ec = ecproj(2).replacen(r#""settings": {}"#, &format!(r#""settings": {deep}"#), 1); + assert!(limit_exceeded(info(ec.as_bytes(), &Limits::default()))); + let pc = zip(&[store("manifest.json", pcraft_manifest(1, &deep, "").as_bytes())]); + assert!(limit_exceeded(outline(&pc, &Limits::default()))); + // At the limit (the file's object is one level), the document still reads. + let n = Limits::default().max_depth as usize - 1; + let at_limit = vectorcraft_json(4, &format!(r#""deep":{}{}"#, "[".repeat(n), "]".repeat(n))); + assert_eq!(outline(at_limit.as_bytes(), &Limits::default()).unwrap().pages.len(), 2); + let past = vectorcraft_json(4, &format!(r#""deep":{}{}"#, "[".repeat(n + 1), "]".repeat(n + 1))); + assert!(limit_exceeded(outline(past.as_bytes(), &Limits::default()))); + // A raised limit stops at MAX_DEPTH: 2000 nested groups are refused, not parsed. + let mut raised = Limits::default(); + raised.max_depth = u32::MAX; + assert!(limit_exceeded(outline(past.as_bytes(), &raised))); + let mut layer = pcraft_layer("Leaf", true, "Normal", 1.0, raster()); + for _ in 0..2000 { + layer = pcraft_layer("G", true, "Normal", 1.0, &format!(r#"{{"kind":"group","expanded":false,"children":[{layer}]}}"#)); + } + let pc = zip(&[deflated("manifest.json", pcraft_manifest(1, &format!("[{layer}]"), "").as_bytes())]); + raised.max_json = 1 << 30; + raised.max_entry = 1 << 30; + assert!(limit_exceeded(outline(&pc, &raised))); +} + +#[test] +fn lists_past_max_items_are_refused() { + let with_max = |n: u64| { + let mut l = Limits::default(); + l.max_items = n; + l + }; + // Four project items and two layers; layers of `{}` count as well. + let ec = ecproj(2); + assert_eq!(outline(ec.as_bytes(), &with_max(6)).unwrap().pages.len(), 2); + assert!(limit_exceeded(outline(ec.as_bytes(), &with_max(5)))); + let empty = ec.replacen(r#""layers": []"#, &format!(r#""layers": [{}{{}}]"#, "{},".repeat(9)), 1); + assert_eq!(outline(empty.as_bytes(), &with_max(16)).unwrap().pages[0].layers.len(), 10); + assert!(limit_exceeded(outline(empty.as_bytes(), &with_max(15)))); + let null = ec.replacen(r#""layers": []"#, r#""layers": null"#, 1); + assert!(outline(null.as_bytes(), &with_max(6)).unwrap().pages[0].layers.is_empty()); + // Two artboards and two layers. + let vc = vectorcraft_json(4, ""); + assert_eq!(outline(vc.as_bytes(), &with_max(4)).unwrap().layers.len(), 2); + assert!(limit_exceeded(outline(vc.as_bytes(), &with_max(3)))); + // An element past the limit is refused before it is read. + let bad = vc.replacen(r#""artboards":["#, r#""artboards":[{"rect":0},"#, 1); + assert!(matches!(outline(bad.as_bytes(), &with_max(4)), Err(Error::Malformed(_)))); + assert!(limit_exceeded(outline(bad.as_bytes(), &with_max(0)))); + // A group and its one layer. + let leaf = pcraft_layer("Leaf", true, "Normal", 1.0, raster()); + let group = pcraft_layer("G", true, "Normal", 1.0, &format!(r#"{{"kind":"group","expanded":false,"children":[{leaf}]}}"#)); + let pc = zip(&[store("manifest.json", pcraft_manifest(1, &format!("[{group}]"), "").as_bytes())]); + assert_eq!(outline(&pc, &with_max(2)).unwrap().layers[0].children.len(), 1); + assert!(limit_exceeded(outline(&pc, &with_max(1)))); +} + +#[test] +fn only_the_json_of_a_compressed_file_is_counted_when_no_member_follows_it() { + let mut file = vectorcraft_json(4, "").into_bytes(); + let json_len = file.len() as u64; + file.extend(BLOB_MAGIC); + file.extend((0..1u32 << 20).map(|i| (i % 251) as u8)); + let gz = gzip(&file); + let mut limits = small(); + limits.max_total = json_len + BLOB_MAGIC.len() as u64; + assert_eq!(info(&gz, &limits).unwrap().version, 4); + assert_eq!(outline(&gz, &limits).unwrap().pages.len(), 2); + assert_eq!(renditions(&gz, &limits).unwrap(), vec![]); +} + +#[test] +fn a_zip_entry_past_max_entry_is_refused_before_it_is_inflated() { + let big = vec![0u8; 1 << 20]; + let pc = zip(&[ + store("manifest.json", pcraft_manifest(1, "[]", r#","composite":"composite/preview.png""#).as_bytes()), + deflated("composite/preview.png", &big), + ]); + assert!(limit_exceeded(renditions(&pc, &small()))); + let mut limits = small(); + limits.max_entry = 2 << 20; + limits.max_total = 4 << 20; + assert_eq!(renditions(&pc, &limits).unwrap()[0].bytes.len(), big.len()); +} + +/// Where the central directory entry of the `index`th entry starts. +fn central(zip: &[u8], index: usize) -> usize { + zip.windows(4).enumerate().filter(|(_, w)| *w == [0x50, 0x4b, 0x01, 0x02]).nth(index).unwrap().0 +} + +#[test] +fn a_deflate_entry_that_inflates_past_its_size_is_malformed() { + let manifest = pcraft_manifest(1, "[]", r#","composite":"c.png""#); + let mut pc = zip(&[store("manifest.json", manifest.as_bytes()), deflated("c.png", &vec![5u8; 1 << 20])]); + // Declare 16 bytes in the central directory. + let c = central(&pc, 1); + pc[c + 24..c + 28].copy_from_slice(&16u32.to_le_bytes()); + assert!(matches!(renditions(&pc, &small()), Err(Error::Malformed(_)))); +} + +#[test] +fn zip_features_outside_the_reader_are_unsupported() { + let manifest = pcraft_manifest(1, "[]", ""); + let base = zip(&[store("manifest.json", manifest.as_bytes())]); + let c = central(&base, 0); + let mut encrypted = base.clone(); + encrypted[c + 8] |= 1; + assert!(matches!(info(&encrypted, &small()), Err(Error::Unsupported(_)))); + let mut method = base.clone(); + method[c + 10] = 12; + assert!(matches!(info(&method, &small()), Err(Error::Unsupported(_)))); + let mut zip64 = base.clone(); + zip64[c + 20..c + 24].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(matches!(info(&zip64, &small()), Err(Error::Unsupported(_)))); + // A ZIP64 end locator in front of the end record. + let eocd = base.len() - 22; + let mut locator = base[..eocd].to_vec(); + locator.extend(0x0706_4b50u32.to_le_bytes()); + locator.extend([0u8; 16]); + locator.extend(&base[eocd..]); + assert!(matches!(info(&locator, &small()), Err(Error::Unsupported(_)))); + let mut crc = base.clone(); + crc[c + 16] ^= 0xff; + assert!(matches!(info(&crc, &small()), Err(Error::Malformed(_)))); +} + +#[test] +fn a_comment_holding_an_end_signature_does_not_hide_the_archive() { + let mut pc = pcraft(&png(1, 1, 0), &png(1, 1, 0)); + let comment = b"PK\x05\x06 not the end record"; + let n = pc.len(); + pc[n - 2..].copy_from_slice(&(comment.len() as u16).to_le_bytes()); + pc.extend(comment); + assert_eq!(info(&pc, &small()).unwrap().version, 1); +} + +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + // xorshift64* + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } +} + +#[test] +fn seeded_mutations_never_panic() { + let mut rng = Rng(0x5eed); + for s in samples() { + for _ in 0..1500 { + let mut d = s.clone(); + for _ in 0..1 + rng.below(4) { + let at = rng.below(d.len()); + match rng.below(4) { + 0 => d[at] ^= 1 << rng.below(8), + 1 => d[at] = rng.next() as u8, + 2 => { + let v = [0u32, 1, u32::MAX, 0x8000_0000][rng.below(4)]; + let end = (at + 4).min(d.len()); + d[at..end].copy_from_slice(&v.to_le_bytes()[..end - at]); + } + _ => { + let end = (at + 1 + rng.below(64)).min(d.len()); + d.drain(at..end); + if d.is_empty() { + break; + } + } + } + } + exercise(&d, &small()); + } + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn arbitrary_bytes_never_panic(bytes in proptest::collection::vec(any::(), 0..512)) { + exercise(&bytes, &small()); + } + + #[test] + fn arbitrary_bytes_behind_each_magic_never_panic(prefix in 0usize..4, rest in proptest::collection::vec(any::(), 0..512)) { + let heads: [&[u8]; 4] = [b"PK\x03\x04", &[0x1f, 0x8b, 8, 0], br#"{"format":"vectorcraft","#, br#"{"savedBy":"1","schema":"#]; + let mut bytes = heads[prefix].to_vec(); + bytes.extend(rest); + exercise(&bytes, &small()); + } +}