mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-08 19:42:12 +00:00
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:
1 parent
431b34ad13
commit
fa1a657476
15 files changed
+2567
No files matched your search
@@ -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
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
@@ -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.
|
||||
@@ -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![] })
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¢ral);
|
||||
out.extend(0x0605_4b50u32.to_le_bytes());
|
||||
out.extend([0u8; 4]);
|
||||
out.extend((entries.len() as u16).to_le_bytes());
|
||||
out.extend((entries.len() as u16).to_le_bytes());
|
||||
out.extend((central.len() as u32).to_le_bytes());
|
||||
out.extend(dir_offset.to_le_bytes());
|
||||
out.extend(0u16.to_le_bytes());
|
||||
out
|
||||
}
|
||||
|
||||
/// A VectorCraft document member holding `bytes` as base64.
|
||||
pub fn inline(mime: &str, bytes: &[u8]) -> String {
|
||||
format!(r#"{{"mime":"{mime}","data":"{}"}}"#, base64(bytes))
|
||||
}
|
||||
|
||||
/// A VectorCraft document member pointing at the data after the JSON.
|
||||
pub fn blob(mime: &str, offset: usize, len: usize) -> String {
|
||||
format!(r#"{{"mime":"{mime}","blob":[{offset},{len}]}}"#)
|
||||
}
|
||||
|
||||
/// The JSON of a VectorCraft file: `members` go between `generator` and `document`.
|
||||
pub fn vectorcraft_json(version: u32, members: &str) -> String {
|
||||
let document = r#"{"version":1,"title":"","units":"Points","color_mode":"Rgb",
|
||||
"artboards":[{"id":1,"name":"Front","rect":{"x0":0.0,"y0":0.0,"x1":612.0,"y1":792.0},"show_center_mark":false,"show_cross_hairs":false},
|
||||
{"id":2,"name":"Back","rect":{"x0":700.0,"y0":800.0,"x1":650.0,"y1":700.0},"show_center_mark":false,"show_cross_hairs":false}],
|
||||
"layers":[{"id":1,"name":"Art","kind":{"type":"layer","color":{"Preset":0},"template":false,"printable":true,
|
||||
"children":[{"id":3,"kind":{"type":"group","children":[{"id":4,"kind":{"type":"path","path":{"subpaths":[]}}}]}}]}},
|
||||
{"id":2,"name":"Notes","visible":false,"opacity":0.5,"blend":"Multiply","kind":{"type":"layer","color":{"Preset":1},"template":false,"printable":false,"children":[]}}],
|
||||
"next_id":5}"#;
|
||||
let sep = if members.is_empty() { "" } else { "," };
|
||||
format!(r#"{{"format":"vectorcraft","version":{version},"generator":"VectorCraft 0.1.0"{sep}{members},"document":{document},"images":{{}}}}"#)
|
||||
}
|
||||
|
||||
/// A PhotoCraft layer as the manifest writes it.
|
||||
pub fn pcraft_layer(name: &str, visible: bool, blend: &str, opacity: f32, content: &str) -> String {
|
||||
format!(
|
||||
r#"{{"id":1,"name":"{name}","visible":{visible},"locks":{{}},"blend":"{blend}","opacity":{opacity},"fill_opacity":1.0,"clipped":false,"mask":null,
|
||||
"effects":{{"enabled":true,"items":[],"psd_raw":null}},"label":"None","content":{content},"psd_blocks":[],"psd_id":null,"fill_cache":null}}"#
|
||||
)
|
||||
}
|
||||
|
||||
pub fn raster() -> &'static str {
|
||||
r#"{"kind":"raster","surface":{"format":{"mode":"Rgb","sample":"U8","alpha":true},"default":"00000000","tiles":[]}}"#
|
||||
}
|
||||
|
||||
/// A PhotoCraft manifest with `layers` (a JSON array) and the preview paths given.
|
||||
pub fn pcraft_manifest(version: u32, layers: &str, previews: &str) -> String {
|
||||
format!(
|
||||
r#"{{"format_version":{version},"generator":"photocraft-format 0.1.0",
|
||||
"document":{{"id":1,"name":"Untitled","size":{{"width":640,"height":480}},"resolution_dpi":300.0,"mode":"Rgb","depth":"U16",
|
||||
"icc_profile":null,"layers":{layers},"channels":[],"guides":{{}},"selection":null,"metadata":{{}},"global_light":{{}}}}{previews}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
/// A `.pcraft` bundle: a tile, the previews, and the manifest last.
|
||||
pub fn pcraft(thumb: &[u8], composite: &[u8]) -> Vec<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
|
||||
}}"#
|
||||
)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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)]
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user