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.
This commit is contained in:
.CooCoo authored and GitHub committed 2026-11-02 16:29:51 -05:00
1 parent 431b34ad13
commit fa1a657476
15 files changed
+2567

No files matched your search

+34
View File
@@ -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
Generated
+14
View File
@@ -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"
+1
View File
@@ -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" }
+3
View File
@@ -42,6 +42,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
the event loop: queued requests shown at the end of the frame and answered on a later frame,
free-standing Open and Folder panels and Save sheets on macOS, case-insensitive filters. What
to do with the answer stays in each app.
- [`craft-doc`](crates/doc/README.md): reads PhotoCraft `.pcraft`, VectorCraft `.vectorcraft`
and EffectCraft `.ecproj` documents without the apps: the kind and version, the previews and
PDF they carry, and their pages and layers. Rendering and editing stay in each app.
- [`craft-docpath`](crates/docpath/src/lib.rs): decides whether a path or `file:` URL stored in a
document may be opened. Paths stored in documents are followed only when they are local or on the
document's own network share; other network paths, device paths and non-file URLs are refused.
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "craft-doc"
description = "Read-only access to PhotoCraft, VectorCraft and EffectCraft documents: format detection, version, embedded previews and PDF, and the layer and page outline."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
craft-ecproj-format = { workspace = true }
craft-pcraft-format = { workspace = true }
craft-vectorcraft-format = { workspace = true }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
serde = { workspace = true }
# `unbounded_depth`: PhotoCraft manifests nest deeper than serde_json's 128 levels; the crate
# checks the nesting against `Limits::max_depth` before parsing.
serde_json = { workspace = true, features = ["unbounded_depth"] }
thiserror = { workspace = true }
[dev-dependencies]
proptest = { workspace = true }
+113
View File
@@ -0,0 +1,113 @@
# craft-doc
Reads PhotoCraft `.pcraft`, VectorCraft `.vectorcraft` and EffectCraft `.ecproj` documents
without the apps: which app wrote a file, its format version, the previews and PDF it carries,
and its pages and layers. It takes bytes and returns plain data. It does not open files, render
or composite. No `unsafe`, no panics on malformed input, and it builds for
`wasm32-unknown-unknown`. Each format's envelope is read through its crate in this repository:
[`craft-pcraft-format`](../pcraft-format/README.md) (the `Manifest` members and checks, and
`check_manifest`),
[`craft-vectorcraft-format`](../vectorcraft-format/README.md) (`Head`, `Image`, `sniff`, the
version and the marker) and [`craft-ecproj-format`](../ecproj-format/README.md) (`Head`, the
schema check). The outlines are read with craft-doc's own read-only views of the documents. The
other dependencies are `serde`, `serde_json`, `thiserror` and `flate2` (its pure-Rust backend).
```toml
craft-doc = { git = "https://github.com/storytold/craft-libs", rev = "<commit>" }
```
## Use
```rust
use craft_doc::{Kind, Limits, RenditionKind};
let limits = Limits::default();
match craft_doc::sniff(&bytes) {
Some(Kind::VectorCraft) => {
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<Kind>` 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<Info, Error>`: 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<Vec<Rendition>, Error>`: each `Rendition` has a `kind`
(`Pdf`, `Thumbnail`, `Composite`), a `mime` and the `bytes`.
- `outline(bytes, &Limits) -> Result<Outline, Error>`: `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.
+221
View File
@@ -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<Result<Head, ReadError>, 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<Head, Error> {
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<String>), 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<Vec<Rendition>, Error> {
head(json(bytes, budget)?, budget)?;
Ok(vec![])
}
#[derive(Deserialize)]
struct Project {
#[serde(default, deserialize_with = "counted_map")]
items: BTreeMap<String, ItemM>,
}
#[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<u32>,
#[serde(default)]
height: Option<u32>,
#[serde(default)]
frame_rate: Option<FrameRateM>,
/// In ticks.
#[serde(default)]
duration: Option<i64>,
/// A composition's layers, the top one first.
#[serde(default, deserialize_with = "counted_option")]
layers: Option<Vec<LayerM>>,
}
#[derive(Deserialize)]
struct FrameRateM {
num: i64,
den: i64,
}
fn yes() -> bool {
true
}
#[derive(Deserialize)]
struct LayerM {
#[serde(default)]
name: String,
#[serde(default)]
source: Option<SourceM>,
#[serde(default)]
switches: Option<SwitchesM>,
#[serde(default)]
blend_mode: Option<String>,
}
#[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<Outline, Error> {
let json = json(bytes, budget)?;
head(json, budget)?;
let p: Project = parse(json, budget.limits)?;
let mut comps: Vec<ItemM> = 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::<Result<_, Error>>()?;
Ok(Outline { canvas: None, pages, layers: vec![] })
}
+536
View File
@@ -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<String>,
/// `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<u8>,
}
/// 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<Canvas>,
/// VectorCraft's artboards (in points) or EffectCraft's compositions (in pixels), in file
/// order; empty for PhotoCraft.
pub pages: Vec<Page>,
/// VectorCraft's top-level layers or PhotoCraft's layer tree, bottom first; empty for
/// EffectCraft, whose layers are in each [`Page`].
pub layers: Vec<Layer>,
}
/// 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<Timing>,
/// A composition's layers, bottom first; empty for an artboard.
pub layers: Vec<Layer>,
}
/// 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<f32>,
/// The blend mode as the file names it (`Normal`, `Multiply`…).
pub blend: Option<String>,
/// A PhotoCraft group's layers, bottom first.
pub children: Vec<Layer>,
}
/// 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<Kind> {
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<Info, Error> {
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<Vec<Rendition>, 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<Outline, Error> {
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<T>(max: u64, f: impl FnOnce() -> Result<T, Error>) -> Result<T, Error> {
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<E: de::Error>() -> 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<T>(PhantomData<T>);
impl<'de, T: Deserialize<'de>> DeserializeSeed<'de> for Item<T> {
type Value = T;
fn deserialize<D: Deserializer<'de>>(self, d: D) -> Result<T, D::Error> {
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<Vec<T>, D::Error> {
struct List<T>(PhantomData<T>);
impl<'de, T: Deserialize<'de>> Visitor<'de> for List<T> {
type Value = Vec<T>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a list")
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Vec<T>, 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<Option<Vec<T>>, D::Error> {
struct Counted<T>(Vec<T>);
impl<'de, T: Deserialize<'de>> Deserialize<'de> for Counted<T> {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
counted(d).map(Counted)
}
}
Ok(Option::<Counted<T>>::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<BTreeMap<String, T>, D::Error> {
struct Map<T>(PhantomData<T>);
impl<'de, T: Deserialize<'de>> Visitor<'de> for Map<T> {
type Value = BTreeMap<String, T>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a map")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<BTreeMap<String, T>, A::Error> {
let mut out = BTreeMap::new();
while let Some(k) = map.next_key::<String>()? {
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<T: DeserializeOwned>(json: &[u8], limits: &Limits) -> Result<T, Error> {
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<T: DeserializeOwned>(json: &[u8]) -> Result<T, serde_json::Error> {
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<T: DeserializeOwned>(json: &[u8], limits: &Limits, kind: Kind) -> Result<T, Error> {
check_depth(json, limits.max_depth)?;
counting(limits.max_items, || {
parse_checked(json).map_err(|e| match parse_checked::<Version>(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());
}
}
+158
View File
@@ -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<u8>), 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<String>,
}
fn checked(version: u32) -> Result<u32, Error> {
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<D> {
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<String>,
#[serde(default)]
composite: Option<String>,
}
/// 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<D: DeserializeOwned>(json: &[u8], budget: &Budget) -> Result<Envelope<D>, 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<D> = 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<String>), 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<Vec<Rendition>, Error> {
let (zip, json) = manifest(bytes, budget)?;
let m: Envelope<IgnoredAny> = 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<LayerM>,
}
#[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<LayerM>,
}
impl From<LayerM> 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<Outline, Error> {
let (_, json) = manifest(bytes, budget)?;
let d = read::<DocM>(&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(),
})
}
+302
View File
@@ -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": "<base64>" },
//! "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<GzDecoder<&'a [u8]>>, read: Vec<u8>, 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<Unpacked<'a>, 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<usize> = 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<String>,
}
/// 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<Stamp, Error> {
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<String>), 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<Option<u64>, 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<Vec<u8>, 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<Vec<Rendition>, 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<ArtboardM>,
/// Bottom first.
#[serde(default, deserialize_with = "counted")]
layers: Vec<NodeM>,
}
#[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<String>,
#[serde(default = "yes")]
visible: bool,
#[serde(default = "one")]
opacity: f32,
#[serde(default)]
blend: Option<String>,
kind: KindM,
}
#[derive(Deserialize)]
struct KindM {
#[serde(rename = "type")]
kind: String,
}
pub(crate) fn outline(bytes: &[u8], budget: &mut Budget) -> Result<Outline, Error> {
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 })
}
+183
View File
@@ -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<u16> {
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<u32> {
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<usize>,
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<Entry>,
}
impl<'a> Zip<'a> {
pub(crate) fn open(bytes: &'a [u8]) -> Result<Self, Error> {
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<Vec<u8>, 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
}
+222
View File
@@ -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<u8> {
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<u8> {
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<u8> {
fn chunk(out: &mut Vec<u8>, 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<u8> {
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<u8>| {
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(&central);
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<u8> {
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
}}"#
)
}
+138
View File
@@ -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<u8> = 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));
}
+264
View File
@@ -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"<w/>"), 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::<Vec<_>>(), [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)]
);
}
+353
View File
@@ -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<T: std::fmt::Debug>(r: Result<T, Error>) -> 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<Vec<u8>> {
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::<u8>(), 0..512)) {
exercise(&bytes, &small());
}
#[test]
fn arbitrary_bytes_behind_each_magic_never_panic(prefix in 0usize..4, rest in proptest::collection::vec(any::<u8>(), 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());
}
}