diff --git a/.github/workflows/pcraft-format.yml b/.github/workflows/pcraft-format.yml new file mode 100644 index 00000000..e79cebc2 --- /dev/null +++ b/.github/workflows/pcraft-format.yml @@ -0,0 +1,35 @@ +name: PhotoCraft format + +on: + workflow_dispatch: + push: + branches: [main] + paths: ['crates/pcraft-format/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/pcraft-format.yml'] + pull_request: + paths: ['crates/pcraft-format/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/pcraft-format.yml'] + +permissions: + contents: read + +jobs: + pcraft-format: + 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable + with: + toolchain: "1.95" + components: rustfmt, clippy + targets: wasm32-unknown-unknown + - run: cargo fmt --check + - run: cargo clippy -p craft-pcraft-format --all-targets --locked -- -D warnings + - run: cargo test -p craft-pcraft-format --locked -- --nocapture + - run: cargo check -p craft-pcraft-format --locked --target wasm32-unknown-unknown diff --git a/Cargo.lock b/Cargo.lock index ab3e3a7a..4920c11a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -940,6 +940,15 @@ dependencies = [ "objc2-app-kit 0.3.2", ] +[[package]] +name = "craft-pcraft-format" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "craft-pdf" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 00993d79..37d6c0ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ craft-jpeg = { path = "crates/jpeg" } craft-keymap = { path = "crates/keymap" } craft-media = { path = "crates/media" } craft-menu = { path = "crates/menu" } +craft-pcraft-format = { path = "crates/pcraft-format" } craft-prepress = { path = "crates/prepress" } craft-print = { path = "crates/print" } craft-quit = { path = "crates/quit" } diff --git a/README.md b/README.md index e4ddadd8..82854e96 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,9 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` with the document as a type parameter, `TileRef`, the entry paths, the + manifest decode limits and the migration runner. The document model stays in PhotoCraft. - [`craft-print`](crates/print/README.md): PDF submission to CUPS and Windows, printer discovery, duplex/copies/colour and driver preferences. PDF generation stays in each app. diff --git a/crates/pcraft-format/Cargo.toml b/crates/pcraft-format/Cargo.toml new file mode 100644 index 00000000..ce1d6873 --- /dev/null +++ b/crates/pcraft-format/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "craft-pcraft-format" +description = "The envelope of PhotoCraft's .pcraft bundle: the manifest with the document as a type parameter, tile references, entry paths, decode limits and the migration runner." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true } +# `unbounded_depth`: manifests nest deeper than serde_json's 128 levels; `parse_manifest` checks +# the nesting against `Limits::max_depth` before parsing. +serde_json = { workspace = true, features = ["unbounded_depth"] } +thiserror = { workspace = true } diff --git a/crates/pcraft-format/README.md b/crates/pcraft-format/README.md new file mode 100644 index 00000000..29321df8 --- /dev/null +++ b/crates/pcraft-format/README.md @@ -0,0 +1,116 @@ +# craft-pcraft-format + +The envelope of PhotoCraft's `.pcraft` bundle: the manifest around the document, tile +references, the entry paths, the limits for reading a manifest, and the runner for manifest +migrations. The document model stays in PhotoCraft and is a type parameter here. The crate +does not read ZIP archives, open files or decode tiles. No `unsafe`, no panics on malformed +input, and it builds for `wasm32-unknown-unknown`. The dependencies are `serde`, `serde_json` +and `thiserror`. + +```toml +craft-pcraft-format = { git = "https://github.com/storytold/craft-libs", rev = "" } +``` + +## Bundle layout + +A bundle is a ZIP archive or a directory with these entries: + +| Entry | Constant or function | Contents | +|---|---|---| +| `manifest.json` | `MANIFEST` | the versioned manifest | +| `tiles/.zst` | `tile_path(hash)` | a 256×256 tile, zstd, little-endian samples | +| `blobs/.zst` | `blob_path(hash)` | binary data, zstd | +| `thumb.png` | `THUMB` | optional thumbnail | +| `composite/preview.png` | `COMPOSITE` | optional flattened preview | +| `save.lock` | `SAVE_LOCK` | directory bundles only: held while a save runs (empty) | + +`` is the BLAKE3 hash of the uncompressed bytes as 64 lowercase hex digits. +`is_valid_hash` checks that form. `tile_path` and `blob_path` return `None` for any other +string, so a hash read from a file never becomes an entry name such as `tiles/../x.zst`. +`TILES_DIR` (`tiles`), `BLOBS_DIR` (`blobs`) and `COMPOSITE_DIR` (`composite`) name the +directories, and `OBJECT_SUFFIX` (`.zst`) ends every tile and blob entry. `EXTENSION` is +`pcraft` and `FORMAT_VERSION` is 1. + +## Use + +```rust +use craft_pcraft_format::{FORMAT_VERSION, Limits, Manifest, Step}; + +const STEPS: &[Step] = &[]; + +let manifest: Manifest = craft_pcraft_format::read_manifest(&manifest_json, &Limits::default(), STEPS, FORMAT_VERSION)?; +``` + +- `read_manifest(json, &Limits, steps, target)` is the reader of a manifest. It runs + `parse_manifest`, then `migrate`, which refuses a version newer than `target`, then reads the + members, the document as `D`. `read_manifest_with` takes a function that reads the + document from its `serde_json::Value` instead of `D`'s `Deserialize`. +- `Manifest` has `format_version`, `generator`, `document: D`, `thumbnail` and `composite`, + and serializes them in that order. Absent previews serialize as `null`. On read, missing + previews are `None`, members the type does not name are skipped, and a `thumbnail` other + than `thumb.png` or a `composite` other than `composite/preview.png` gives `Corrupt`. + `Manifest` has no `Deserialize` and is `#[non_exhaustive]`: `Manifest::new(generator, + document)` builds one at `FORMAT_VERSION` without previews. +- `TileRef` has `tx`, `ty` and `hash`, and is `#[non_exhaustive]`: `TileRef::new(tx, ty, hash)` + builds one. `TileRef::path` gives its entry, or `None` when the hash is not valid. +- `check_manifest(json, &Limits)` checks the size, the nesting and the item count in one pass + without recursion. PhotoCraft's own check of a manifest it saves tests the nesting and the + item count only. `parse_manifest(json, &Limits)` runs that check and then parses into a + `serde_json::Value` with serde_json's 128-level recursion limit turned off. +- `migrate(&mut value, steps, target)` upgrades the JSON in place, where `steps[i]` upgrades + version `i + 1` to `i + 2`, and returns the version the file had. `Step` is + `fn(&mut Value) -> Result<(), E>`; with `E: From`, the steps return the caller's own + error type. +- `Error` is `Corrupt`, `Unsupported`, `TooNew`, `LimitExceeded` or `Json`. A manifest whose + `format_version` is newer than `target` gives `TooNew`, and one with no step to the next + version gives `Unsupported`. A missing or invalid `format_version` gives `Corrupt`, and any + other missing or mistyped member gives `Json`. `TooNew` holds `found`, `supported` and the manifest's + `generator` (`None` when it is missing or not a string), and is matched with `..`. + +## Limits + +`Limits` (fields `max_manifest_bytes`, `max_blob_bytes`, `max_total_bytes`, `max_depth`, +`max_items`) holds the limits PhotoCraft loads with: + +- `max_manifest_bytes` (256 MiB): the size of `manifest.json`. +- `max_blob_bytes` (1 GiB): one decompressed blob. +- `max_total_bytes` (16 GiB): every decompressed tile and blob in one bundle. +- `max_depth` (367, `manifest_depth(MAX_GROUP_DEPTH)`): the nesting of JSON arrays and objects. + PhotoCraft nests up to `MAX_GROUP_DEPTH` (100, a copy of PhotoCraft's own constant) layer + groups, three levels each, and allows 64 more levels for the document and leaf layers. A + value above `MAX_DEPTH` (400) is treated as 400, so parsing stays within the stack. +- `max_items` (`MAX_MANIFEST_ITEMS`, 4,194,304): arrays and objects opened, plus elements and + members after a comma. + +`check_manifest`, `parse_manifest` and `read_manifest` apply the manifest limits. The crate +does not read tiles or blobs, so it does not apply `max_blob_bytes` or `max_total_bytes`: the +caller that decompresses the entries enforces them. The struct is `#[non_exhaustive]`: start from the +defaults and set the fields you change. + +```rust +let mut limits = craft_pcraft_format::Limits::default(); +limits.max_manifest_bytes = 16 << 20; +``` + +## Testing + +`cargo test -p craft-pcraft-format` runs: + +- `tests/manifest.rs`: a manifest with and without previews serializes with + `serde_json::to_vec_pretty` to JSON written by hand in the test, byte for byte, and reads + back equal; missing previews and unknown members; entry names, hash checks, and no entry name + for an invalid hash. +- `tests/read.rs`: `read_manifest` and `read_manifest_with` read the envelope and the document, + refuse a newer version with the writer's `generator`, refuse a manifest past the limits, run + the steps first, and report missing or mistyped members as a struct with a derived + `Deserialize` reports them, in the same order; a missing or invalid `format_version` is + `Corrupt`, and so is a preview that names another entry, such as `../x` or an absolute path. +- Doc tests: `Manifest` and `TileRef` are not built by struct literal, `Manifest` has no + `Deserialize`, and `Error::TooNew` is matched with `..`. +- `tests/limits.rs`: the default limits, nesting at and past `max_depth`, items at and past + `max_items`, brackets inside strings, the manifest size, JSON errors and the messages. +- `tests/migrate.rs`: steps in order, partial chains, the current version, newer, missing, + zero and out-of-range versions, a missing step, and steps that return the caller's error type. + +`.github/workflows/pcraft-format.yml` runs fmt, clippy and the tests on Linux and Windows, and +a `wasm32-unknown-unknown` check. diff --git a/crates/pcraft-format/src/lib.rs b/crates/pcraft-format/src/lib.rs new file mode 100644 index 00000000..eb6482aa --- /dev/null +++ b/crates/pcraft-format/src/lib.rs @@ -0,0 +1,195 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! The envelope of PhotoCraft's `.pcraft` bundle, shared by PhotoCraft and by readers in other +//! apps. +//! +//! ```text +//! manifest.json versioned manifest (see [`Manifest`]) +//! tiles/.zst content-addressed 256×256 tiles (zstd, little-endian samples) +//! blobs/.zst content-addressed binary data +//! thumb.png optional thumbnail +//! composite/preview.png optional flattened preview +//! save.lock directory bundles only: held while a save runs (empty) +//! ``` +//! +//! A bundle is a ZIP archive or a directory with this layout. [`Manifest`] holds the format +//! version, the writer and the preview paths, with the document as the type parameter `D`, so +//! the document model stays in the app. [`TileRef`] is a tile's place in a surface. +//! [`read_manifest`] reads manifest JSON: it checks the JSON against [`Limits`], refuses a version +//! newer than the one the caller reads, upgrades older versions with the caller's steps, and reads +//! the envelope and the document. +//! +//! The crate does not read ZIP archives, open files or decode tiles. It builds for +//! `wasm32-unknown-unknown`. +//! +//! ``` +//! use craft_pcraft_format::{FORMAT_VERSION, Limits, Manifest, Step}; +//! +//! let raw = br#"{"format_version":1,"generator":"photocraft-format 0.1.0","document":{"name":"Untitled"}}"#; +//! let steps: &[Step] = &[]; +//! let manifest: Manifest = craft_pcraft_format::read_manifest(raw, &Limits::default(), steps, FORMAT_VERSION).unwrap(); +//! assert_eq!(manifest.document["name"], "Untitled"); +//! assert_eq!(manifest.thumbnail, None); +//! ``` + +mod limits; +mod migrate; +mod read; + +use serde::{Deserialize, Serialize}; + +pub use limits::{Limits, MAX_DEPTH, MAX_GROUP_DEPTH, MAX_MANIFEST_ITEMS, check_manifest, manifest_depth, parse_manifest}; +pub use migrate::{Step, migrate}; +pub use read::{read_manifest, read_manifest_with}; + +/// The manifest version PhotoCraft writes. +pub const FORMAT_VERSION: u32 = 1; + +/// File extension of the bundle, without the dot. +pub const EXTENSION: &str = "pcraft"; + +/// The directory of the tiles. +pub const TILES_DIR: &str = "tiles"; +/// The directory of the blobs. +pub const BLOBS_DIR: &str = "blobs"; +/// The directory of the flattened preview, [`COMPOSITE`]. +pub const COMPOSITE_DIR: &str = "composite"; +/// The suffix of every tile and blob entry, after the hash. +pub const OBJECT_SUFFIX: &str = ".zst"; + +/// The manifest entry. +pub const MANIFEST: &str = "manifest.json"; +/// The thumbnail entry, a PNG. +pub const THUMB: &str = "thumb.png"; +/// The flattened preview entry, a PNG, in [`COMPOSITE_DIR`]. +pub const COMPOSITE: &str = "composite/preview.png"; +/// The file a directory save holds an exclusive lock on while it runs. ZIP bundles do not have it. +pub const SAVE_LOCK: &str = "save.lock"; + +/// Hex-encoded BLAKE3 hash (64 lowercase hex digits) of an object's uncompressed bytes. +pub type Hash = String; + +/// The entry of the tile with hash `hash`: `tiles/.zst`. `None` when [`is_valid_hash`] +/// refuses `hash`. +pub fn tile_path(hash: &str) -> Option { + is_valid_hash(hash).then(|| format!("{TILES_DIR}/{hash}{OBJECT_SUFFIX}")) +} + +/// The entry of the blob with hash `hash`: `blobs/.zst`. `None` when [`is_valid_hash`] +/// refuses `hash`. +pub fn blob_path(hash: &str) -> Option { + is_valid_hash(hash).then(|| format!("{BLOBS_DIR}/{hash}{OBJECT_SUFFIX}")) +} + +/// `true` when `hash` is 64 lowercase hex digits, the only form a valid manifest gives. +/// [`tile_path`] and [`blob_path`] build no entry name for any other string. +pub fn is_valid_hash(hash: &str) -> bool { + hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// `manifest.json`: the envelope around the app's document `D`. +/// +/// The fields serialize in this order, and `thumbnail` and `composite` serialize as `null` when +/// absent. [`read_manifest`] reads one: a missing `thumbnail` or `composite` reads as `None`, +/// members the type does not name are skipped, and a preview that names any entry other than +/// [`THUMB`] or [`COMPOSITE`] is refused. +/// +/// The struct is `#[non_exhaustive]`: build one with [`Manifest::new`] and set the previews. +/// +/// ```compile_fail +/// let _ = craft_pcraft_format::Manifest { format_version: 1, generator: String::new(), document: (), thumbnail: None, composite: None }; +/// ``` +/// +/// It has no `Deserialize`, so a manifest is read only through [`read_manifest`]: +/// +/// ```compile_fail +/// let _: craft_pcraft_format::Manifest = serde_json::from_str("{}").unwrap(); +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize)] +#[non_exhaustive] +pub struct Manifest { + pub format_version: u32, + /// Free-form writer identification, for example `photocraft-format 0.1.0`. + pub generator: String, + pub document: D, + /// The thumbnail entry, [`THUMB`]. [`read_manifest`] refuses any other name. + pub thumbnail: Option, + /// The flattened preview entry, [`COMPOSITE`]. [`read_manifest`] refuses any other name. + pub composite: Option, +} + +impl Manifest { + /// A manifest at [`FORMAT_VERSION`] written by `generator`, holding `document`, with no + /// previews. + pub fn new(generator: impl Into, document: D) -> Self { + Manifest { format_version: FORMAT_VERSION, generator: generator.into(), document, thumbnail: None, composite: None } + } +} + +/// One tile of a surface: its column `tx`, its row `ty` and the hash of its samples, stored at +/// [`tile_path`]. `hash` is read as written; [`TileRef::path`] checks it. +/// +/// The struct is `#[non_exhaustive]`: build one with [`TileRef::new`]. +/// +/// ```compile_fail +/// let _ = craft_pcraft_format::TileRef { tx: 0, ty: 0, hash: String::new() }; +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[non_exhaustive] +pub struct TileRef { + pub tx: i32, + pub ty: i32, + pub hash: Hash, +} + +impl TileRef { + /// The tile at column `tx` and row `ty` whose samples hash to `hash`. + pub fn new(tx: i32, ty: i32, hash: Hash) -> Self { + TileRef { tx, ty, hash } + } + + /// The tile's entry, [`tile_path`] of its `hash`: `None` when the hash is not valid. + pub fn path(&self) -> Option { + tile_path(&self.hash) + } +} + +/// Why a manifest could not be read. +/// +/// [`Error::TooNew`] is `#[non_exhaustive]`: match it with `..`. +/// +/// ```compile_fail +/// fn found(e: craft_pcraft_format::Error) -> u32 { +/// match e { +/// craft_pcraft_format::Error::TooNew { found, supported: _, generator: _ } => found, +/// _ => 0, +/// } +/// } +/// ``` +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum Error { + /// The manifest breaks the format. + #[error("corrupt .pcraft bundle: {0}")] + Corrupt(String), + /// The manifest needs a migration step the caller did not pass. + #[error("unsupported .pcraft feature: {0}")] + Unsupported(String), + /// The manifest's `format_version` is newer than the version the caller reads. + #[error("bundle written by a newer version (format {found}, this build reads up to {supported})")] + #[non_exhaustive] + TooNew { + /// The manifest's `format_version`. + found: u32, + /// The version the caller reads. + supported: u32, + /// The manifest's `generator`, so a message can name the writer. `None` when it is + /// missing or not a string. + generator: Option, + }, + /// The manifest passes one of the caller's [`Limits`]. + #[error("limit exceeded: {0}")] + LimitExceeded(String), + /// The manifest is not valid JSON, or a member does not have the type the reader expects. + #[error("manifest JSON: {0}")] + Json(#[from] serde_json::Error), +} diff --git a/crates/pcraft-format/src/limits.rs b/crates/pcraft-format/src/limits.rs new file mode 100644 index 00000000..62c4ad10 --- /dev/null +++ b/crates/pcraft-format/src/limits.rs @@ -0,0 +1,114 @@ +use crate::Error; + +/// Deepest nesting of layer groups PhotoCraft saves. The value is a copy of PhotoCraft's +/// `photocraft_doc::MAX_GROUP_DEPTH`; change both together. +pub const MAX_GROUP_DEPTH: usize = 100; + +/// Default [`Limits::max_items`]: the most JSON items (arrays and objects opened, plus elements +/// and members after a comma) a manifest may hold. +pub const MAX_MANIFEST_ITEMS: usize = 4 << 20; + +/// The largest [`Limits::max_depth`] the checks apply. A larger value is treated as this one, +/// so parsing stays within the stack. +pub const MAX_DEPTH: usize = 400; + +/// The deepest JSON nesting of a manifest whose layer groups nest `groups` deep: three levels per +/// group (the layer object, its content object and the `children` array) and 64 for the document +/// and the leaf layers' fields. +pub const fn manifest_depth(groups: usize) -> usize { + 3usize.saturating_mul(groups.saturating_add(1)).saturating_add(64) +} + +/// Limits applied while reading a bundle from an untrusted source. The defaults are the ones +/// PhotoCraft loads with. [`check_manifest`], [`parse_manifest`] and [`crate::read_manifest`] +/// apply `max_manifest_bytes`, `max_depth` and `max_items`; the caller applies `max_blob_bytes` +/// and `max_total_bytes`. +/// +/// The struct is `#[non_exhaustive]`: start from [`Limits::default`] and set the fields you +/// change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct Limits { + /// Largest `manifest.json`, in bytes (256 MiB). + pub max_manifest_bytes: usize, + /// Largest decompressed blob, in bytes (1 GiB). The crate does not read blobs or apply this + /// limit: the caller that decompresses them enforces it. + pub max_blob_bytes: usize, + /// Most decompressed tile and blob bytes in one bundle (16 GiB). The crate does not read + /// tiles or blobs or apply this limit: the caller that decompresses them enforces it. + pub max_total_bytes: u64, + /// Deepest JSON nesting of the manifest (`manifest_depth(MAX_GROUP_DEPTH)`, 367). Values + /// above [`MAX_DEPTH`] are treated as [`MAX_DEPTH`]. + pub max_depth: usize, + /// Most JSON items in the manifest ([`MAX_MANIFEST_ITEMS`]). + pub max_items: usize, +} + +impl Default for Limits { + fn default() -> Self { + Limits { + max_manifest_bytes: 256 << 20, + max_blob_bytes: 1 << 30, + max_total_bytes: 16 << 30, + max_depth: manifest_depth(MAX_GROUP_DEPTH), + max_items: MAX_MANIFEST_ITEMS, + } + } +} + +/// Checks manifest JSON against [`Limits::max_manifest_bytes`], [`Limits::max_depth`] (at most +/// [`MAX_DEPTH`]) and +/// [`Limits::max_items`] in one pass without recursion. Brackets and commas inside strings are +/// not counted. Returns [`Error::LimitExceeded`] for the first limit passed. +/// +/// PhotoCraft's own check of a manifest it is about to save tests the depth and the items only. +/// This check also refuses JSON larger than [`Limits::max_manifest_bytes`], so a writer that runs +/// it before saving refuses a manifest of more than that size. +pub fn check_manifest(json: &[u8], limits: &Limits) -> Result<(), Error> { + if json.len() > limits.max_manifest_bytes { + return Err(Error::LimitExceeded(format!("`{}` exceeds {} bytes", crate::MANIFEST, limits.max_manifest_bytes))); + } + let max_depth = limits.max_depth.min(MAX_DEPTH); + let (mut depth, mut items, mut in_str, mut escaped) = (0usize, 0usize, 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_depth { + return Err(Error::LimitExceeded(format!("manifest JSON nested deeper than {max_depth} levels"))); + } + } + b']' | b'}' => depth = depth.saturating_sub(1), + _ => {} + } + if matches!(b, b'[' | b'{' | b',') { + items += 1; + if items > limits.max_items { + return Err(Error::LimitExceeded(format!("manifest JSON holds more than {} values", limits.max_items))); + } + } + } + Ok(()) +} + +/// Checks manifest JSON with [`check_manifest`], then parses it into a [`serde_json::Value`] +/// with serde_json's recursion limit turned off, so a manifest nested deeper than 128 levels +/// and within [`Limits::max_depth`] parses. +pub fn parse_manifest(json: &[u8], limits: &Limits) -> Result { + check_manifest(json, limits)?; + let mut de = serde_json::Deserializer::from_slice(json); + de.disable_recursion_limit(); + let value = ::deserialize(&mut de)?; + de.end()?; + Ok(value) +} diff --git a/crates/pcraft-format/src/migrate.rs b/crates/pcraft-format/src/migrate.rs new file mode 100644 index 00000000..2751c38f --- /dev/null +++ b/crates/pcraft-format/src/migrate.rs @@ -0,0 +1,41 @@ +use serde_json::Value; + +use crate::Error; + +/// One migration step: upgrades manifest JSON from version `n` to `n + 1` in place. The runner +/// sets `format_version` after each step. +pub type Step = fn(&mut Value) -> Result<(), E>; + +/// Upgrades the manifest JSON `v` in place to version `target`, where `steps[i]` upgrades +/// version `i + 1` to `i + 2`. Returns the version the manifest had. +/// +/// - A missing or non-integer `format_version`, one past `u32`, or `0` is [`Error::Corrupt`]. +/// - A version newer than `target` is [`Error::TooNew`], holding the manifest's `generator` when +/// it is a string. +/// - A version with no step to the next one is [`Error::Unsupported`]. +/// +/// A step's error is returned as it is, and the steps before it have already changed `v`. A step +/// that leaves `v` something other than a JSON object is [`Error::Corrupt`]. +pub fn migrate>(v: &mut Value, steps: &[Step], target: u32) -> Result { + let found = v.get("format_version").and_then(Value::as_u64).ok_or_else(|| corrupt("manifest has no format_version"))?; + let found = u32::try_from(found).map_err(|_| corrupt("format_version out of range"))?; + if found == 0 { + return Err(corrupt("format_version 0 is invalid").into()); + } + if found > target { + let generator = v.get("generator").and_then(Value::as_str).map(str::to_owned); + return Err(Error::TooNew { found, supported: target, generator }.into()); + } + for ver in found..target { + let step = + usize::try_from(ver - 1).ok().and_then(|i| steps.get(i)).ok_or_else(|| Error::Unsupported(format!("no migration from format {ver}")))?; + step(v)?; + let object = v.as_object_mut().ok_or_else(|| corrupt("a migration step left the manifest without an object"))?; + object.insert("format_version".to_owned(), Value::from(ver + 1)); + } + Ok(found) +} + +fn corrupt(msg: &str) -> Error { + Error::Corrupt(msg.to_owned()) +} diff --git a/crates/pcraft-format/src/read.rs b/crates/pcraft-format/src/read.rs new file mode 100644 index 00000000..ab9b5167 --- /dev/null +++ b/crates/pcraft-format/src/read.rs @@ -0,0 +1,73 @@ +use serde::Deserialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::{COMPOSITE, Error, Limits, Manifest, Step, THUMB, migrate, parse_manifest}; + +/// Reads manifest JSON into a [`Manifest`] whose document is `D`. +/// +/// 1. [`parse_manifest`] checks `json` against `limits` and parses it. +/// 2. [`migrate`] refuses a `format_version` newer than `target` with [`Error::TooNew`], which +/// holds the manifest's `generator`, and upgrades an older one with `steps`. +/// 3. The members are read in the order the parsed JSON object holds them, the document as `D`. +/// +/// A missing `thumbnail` or `composite` reads as `None`, and members the envelope does not name +/// are skipped. A missing or invalid `format_version` is [`Error::Corrupt`], from [`migrate`]. +/// Any other member of the wrong type is [`Error::Json`], and so is a missing `generator` or +/// `document`, which is reported after the members that are present, in the order serde's derived +/// `Deserialize` reports them. A `thumbnail` other than [`THUMB`] or a `composite` other than +/// [`COMPOSITE`] is [`Error::Corrupt`], checked after the members are read. +pub fn read_manifest>(json: &[u8], limits: &Limits, steps: &[Step], target: u32) -> Result, E> { + read_manifest_with(json, limits, steps, target, |document| serde_json::from_value(document).map_err(|e| E::from(Error::Json(e)))) +} + +/// [`read_manifest`], with the document read by `document` from its JSON value instead of by +/// `D`'s `Deserialize`, for example to decode a deep tree a level at a time. +pub fn read_manifest_with>( + json: &[u8], + limits: &Limits, + steps: &[Step], + target: u32, + document: impl FnOnce(Value) -> Result, +) -> Result, E> { + let mut read_document = Some(document); + let mut value = parse_manifest(json, limits)?; + migrate(&mut value, steps, target)?; + let Value::Object(members) = value else { + return Err(Error::Corrupt("the manifest is not a JSON object".to_owned()).into()); + }; + let (mut format_version, mut generator, mut doc, mut thumbnail, mut composite) = (None, None, None, None, None); + for (name, member) in members { + match name.as_str() { + "format_version" => format_version = Some(u32::deserialize(member).map_err(Error::Json)?), + "generator" => generator = Some(String::deserialize(member).map_err(Error::Json)?), + "document" => { + if let Some(read) = read_document.take() { + doc = Some(read(member)?); + } + } + "thumbnail" => thumbnail = Option::::deserialize(member).map_err(Error::Json)?, + "composite" => composite = Option::::deserialize(member).map_err(Error::Json)?, + _ => {} + } + } + let missing = |name| Error::Json(serde::de::Error::missing_field(name)); + let manifest = Manifest { + format_version: format_version.ok_or_else(|| missing("format_version"))?, + generator: generator.ok_or_else(|| missing("generator"))?, + document: doc.ok_or_else(|| missing("document"))?, + thumbnail, + composite, + }; + check_preview("thumbnail", manifest.thumbnail.as_deref(), THUMB)?; + check_preview("composite", manifest.composite.as_deref(), COMPOSITE)?; + Ok(manifest) +} + +/// Refuses a preview member that names an entry other than `expected`. +fn check_preview(member: &str, entry: Option<&str>, expected: &str) -> Result<(), Error> { + match entry { + Some(entry) if entry != expected => Err(Error::Corrupt(format!("`{member}` names {entry:?}, not {expected:?}"))), + _ => Ok(()), + } +} diff --git a/crates/pcraft-format/tests/limits.rs b/crates/pcraft-format/tests/limits.rs new file mode 100644 index 00000000..a7caf6f1 --- /dev/null +++ b/crates/pcraft-format/tests/limits.rs @@ -0,0 +1,101 @@ +use craft_pcraft_format::{Error, Limits, MAX_DEPTH, MAX_GROUP_DEPTH, MAX_MANIFEST_ITEMS, check_manifest, manifest_depth, parse_manifest}; + +fn nested(depth: usize) -> Vec { + let mut json = "[".repeat(depth).into_bytes(); + json.extend(std::iter::repeat_n(b']', depth)); + json +} + +fn small(max_depth: usize, max_items: usize) -> Limits { + let mut limits = Limits::default(); + limits.max_depth = max_depth; + limits.max_items = max_items; + limits +} + +#[test] +fn defaults_are_photocraft_s_load_limits() { + let limits = Limits::default(); + assert_eq!(limits.max_manifest_bytes, 256 << 20); + assert_eq!(limits.max_blob_bytes, 1 << 30); + assert_eq!(limits.max_total_bytes, 16 << 30); + assert_eq!(MAX_GROUP_DEPTH, 100); + assert_eq!(limits.max_depth, 367); + assert_eq!(limits.max_depth, manifest_depth(MAX_GROUP_DEPTH)); + assert_eq!(limits.max_items, MAX_MANIFEST_ITEMS); + assert_eq!(MAX_MANIFEST_ITEMS, 4 << 20); +} + +#[test] +fn manifest_depth_saturates() { + assert_eq!(manifest_depth(0), 67); + assert_eq!(manifest_depth(usize::MAX), usize::MAX); +} + +#[test] +fn nesting_up_to_max_depth_passes_and_one_more_level_does_not() { + let limits = Limits::default(); + let at = limits.max_depth; + assert!(check_manifest(&nested(at), &limits).is_ok()); + assert!(matches!(check_manifest(&nested(at + 1), &limits), Err(Error::LimitExceeded(_)))); +} + +#[test] +fn a_manifest_deeper_than_serde_json_s_128_levels_parses() { + let value = parse_manifest(&nested(300), &Limits::default()).unwrap(); + assert!(value.is_array()); +} + +#[test] +fn a_raised_max_depth_stops_at_max_depth_cap() { + let limits = small(100_000, MAX_MANIFEST_ITEMS); + assert!(parse_manifest(&nested(MAX_DEPTH), &limits).unwrap().is_array()); + let deeper = parse_manifest(&nested(MAX_DEPTH + 1), &limits).err().map(|e| e.to_string()); + assert_eq!(deeper, Some(format!("limit exceeded: manifest JSON nested deeper than {MAX_DEPTH} levels"))); + assert!(matches!(parse_manifest(&nested(100_000), &limits), Err(Error::LimitExceeded(_)))); +} + +#[test] +fn items_up_to_max_items_pass_and_one_more_does_not() { + let limits = small(8, 4); + assert!(check_manifest(b"[1,2,3,4]", &limits).is_ok()); + assert!(matches!(check_manifest(b"[1,2,3,4,5]", &limits), Err(Error::LimitExceeded(_)))); + assert!(matches!(check_manifest(b"[[[[[]]]]]", &limits), Err(Error::LimitExceeded(_)))); +} + +#[test] +fn brackets_and_commas_inside_strings_are_not_counted() { + let limits = small(2, 3); + assert!(check_manifest(br#"{"a":"[[[{{{,,,,"}"#, &limits).is_ok()); + assert!(check_manifest(br#"{"a":"\"[[[,,,\\"}"#, &limits).is_ok()); + assert!(matches!(check_manifest(br#"{"a":"\\",[[[]]]}"#, &limits), Err(Error::LimitExceeded(_)))); +} + +#[test] +fn a_manifest_larger_than_max_manifest_bytes_is_refused() { + let mut limits = Limits::default(); + limits.max_manifest_bytes = 8; + assert!(check_manifest(b"{\"a\":12}", &limits).is_ok()); + assert!(matches!(check_manifest(b"{\"a\":123}", &limits), Err(Error::LimitExceeded(_)))); + assert!(matches!(parse_manifest(b"{\"a\":123}", &limits), Err(Error::LimitExceeded(_)))); +} + +#[test] +fn invalid_or_trailing_json_is_a_json_error() { + let limits = Limits::default(); + assert!(matches!(parse_manifest(b"{\"a\":", &limits), Err(Error::Json(_)))); + assert!(matches!(parse_manifest(b"{} {}", &limits), Err(Error::Json(_)))); + assert!(matches!(parse_manifest(b"", &limits), Err(Error::Json(_)))); +} + +#[test] +fn messages_read_as_photocraft_s() { + let none: &[craft_pcraft_format::Step] = &[]; + let too_new = craft_pcraft_format::migrate(&mut serde_json::json!({"format_version": 2, "generator": "g"}), none, 1).err().map(|e| e.to_string()); + assert_eq!(too_new.as_deref(), Some("bundle written by a newer version (format 2, this build reads up to 1)")); + assert_eq!(Error::Corrupt("x".into()).to_string(), "corrupt .pcraft bundle: x"); + assert_eq!(Error::Unsupported("x".into()).to_string(), "unsupported .pcraft feature: x"); + assert_eq!(Error::LimitExceeded("x".into()).to_string(), "limit exceeded: x"); + let deep = check_manifest(&nested(368), &Limits::default()).err().map(|e| e.to_string()); + assert_eq!(deep.as_deref(), Some("limit exceeded: manifest JSON nested deeper than 367 levels")); +} diff --git a/crates/pcraft-format/tests/manifest.rs b/crates/pcraft-format/tests/manifest.rs new file mode 100644 index 00000000..550c829b --- /dev/null +++ b/crates/pcraft-format/tests/manifest.rs @@ -0,0 +1,152 @@ +use craft_pcraft_format::{ + BLOBS_DIR, COMPOSITE, COMPOSITE_DIR, EXTENSION, FORMAT_VERSION, Limits, MANIFEST, Manifest, OBJECT_SUFFIX, SAVE_LOCK, Step, THUMB, TILES_DIR, + TileRef, blob_path, is_valid_hash, tile_path, +}; +use serde::{Deserialize, Serialize}; + +fn read(text: &str) -> Result, craft_pcraft_format::Error> { + let none: &[Step] = &[]; + craft_pcraft_format::read_manifest(text.as_bytes(), &Limits::default(), none, FORMAT_VERSION) +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +struct Doc { + id: u64, + name: String, + tiles: Vec, +} + +const HASH: &str = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"; + +fn doc() -> Doc { + Doc { id: 7, name: "Untitled-1".into(), tiles: vec![TileRef::new(0, -1, HASH.into())] } +} + +fn manifest(previews: bool) -> Manifest { + let mut manifest = Manifest::new("photocraft-format 0.1.0", doc()); + manifest.thumbnail = previews.then(|| THUMB.to_owned()); + manifest.composite = previews.then(|| COMPOSITE.to_owned()); + manifest +} + +const WITH_PREVIEWS: &str = r#"{ + "format_version": 1, + "generator": "photocraft-format 0.1.0", + "document": { + "id": 7, + "name": "Untitled-1", + "tiles": [ + { + "tx": 0, + "ty": -1, + "hash": "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" + } + ] + }, + "thumbnail": "thumb.png", + "composite": "composite/preview.png" +}"#; + +const WITHOUT_PREVIEWS: &str = r#"{ + "format_version": 1, + "generator": "photocraft-format 0.1.0", + "document": { + "id": 7, + "name": "Untitled-1", + "tiles": [ + { + "tx": 0, + "ty": -1, + "hash": "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" + } + ] + }, + "thumbnail": null, + "composite": null +}"#; + +#[test] +fn a_manifest_with_previews_writes_photocraft_s_pretty_json_byte_for_byte() { + let bytes = serde_json::to_vec_pretty(&manifest(true)).unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), WITH_PREVIEWS); +} + +#[test] +fn a_manifest_without_previews_writes_null_members() { + let bytes = serde_json::to_vec_pretty(&manifest(false)).unwrap(); + assert_eq!(String::from_utf8(bytes).unwrap(), WITHOUT_PREVIEWS); +} + +#[test] +fn written_manifests_read_back_equal() { + for (text, previews) in [(WITH_PREVIEWS, true), (WITHOUT_PREVIEWS, false)] { + let read: Manifest = read(text).unwrap(); + assert_eq!(read, manifest(previews)); + } +} + +#[test] +fn a_new_manifest_is_at_the_current_version_without_previews() { + let m = Manifest::new(String::from("g"), 5); + assert_eq!((m.format_version, m.generator.as_str(), m.document, m.thumbnail, m.composite), (FORMAT_VERSION, "g", 5, None, None)); +} + +#[test] +fn missing_preview_members_read_as_none_and_unknown_members_are_skipped() { + let text = + r#"{"format_version":1,"generator":"g","document":{"id":1,"name":"a","tiles":[]},"full_composite":"composite/full.png","later":{"x":[1,2]}}"#; + let read: Manifest = read(text).unwrap(); + assert_eq!(read.thumbnail, None); + assert_eq!(read.composite, None); + assert_eq!(read.document.name, "a"); +} + +#[test] +fn a_manifest_without_a_document_or_generator_does_not_read() { + assert!(read::(r#"{"format_version":1,"generator":"g"}"#).is_err()); + assert!(read::(r#"{"format_version":1,"document":{"id":1,"name":"a","tiles":[]}}"#).is_err()); +} + +#[test] +fn the_document_can_be_any_json_value() { + let read: Manifest = read(WITH_PREVIEWS).unwrap(); + assert_eq!(read.document["tiles"][0]["ty"], -1); + assert_eq!(read.composite.as_deref(), Some(COMPOSITE)); +} + +#[test] +fn entry_names_match_the_bundle_layout() { + assert_eq!(EXTENSION, "pcraft"); + assert_eq!(MANIFEST, "manifest.json"); + assert_eq!(THUMB, "thumb.png"); + assert_eq!(COMPOSITE, "composite/preview.png"); + assert_eq!(SAVE_LOCK, "save.lock"); + assert_eq!((TILES_DIR, BLOBS_DIR, COMPOSITE_DIR, OBJECT_SUFFIX), ("tiles", "blobs", "composite", ".zst")); + assert!(COMPOSITE.starts_with(&format!("{COMPOSITE_DIR}/"))); + assert_eq!(tile_path(HASH), Some(format!("tiles/{HASH}.zst"))); + assert_eq!(blob_path(HASH), Some(format!("blobs/{HASH}.zst"))); +} + +#[test] +fn no_entry_name_is_built_from_an_invalid_hash() { + for hash in ["../../etc/passwd", "../x", "", &HASH.to_uppercase(), &HASH[1..]] { + assert_eq!(tile_path(hash), None, "{hash}"); + assert_eq!(blob_path(hash), None, "{hash}"); + } + let tile: TileRef = serde_json::from_str(r#"{"tx":0,"ty":0,"hash":"../x"}"#).unwrap(); + assert_eq!(tile.path(), None); + let tile = TileRef::new(1, -1, HASH.into()); + assert_eq!(tile.path(), Some(format!("tiles/{HASH}.zst"))); +} + +#[test] +fn only_64_lowercase_hex_digits_are_a_valid_hash() { + assert!(is_valid_hash(HASH)); + assert!(is_valid_hash(&"0".repeat(64))); + assert!(!is_valid_hash(&HASH.to_uppercase())); + assert!(!is_valid_hash(&HASH[1..])); + assert!(!is_valid_hash(&format!("{HASH}0"))); + assert!(!is_valid_hash(&format!("../{}", &HASH[3..]))); + assert!(!is_valid_hash(&format!("{}g", &HASH[1..]))); + assert!(!is_valid_hash("")); +} diff --git a/crates/pcraft-format/tests/migrate.rs b/crates/pcraft-format/tests/migrate.rs new file mode 100644 index 00000000..8aae1430 --- /dev/null +++ b/crates/pcraft-format/tests/migrate.rs @@ -0,0 +1,114 @@ +use craft_pcraft_format::{Error, FORMAT_VERSION, Step, migrate}; +use serde_json::{Value, json}; + +fn rename_title(v: &mut Value) -> Result<(), Error> { + let document = v.get_mut("document").and_then(Value::as_object_mut).ok_or(Error::Corrupt("no document".into()))?; + let title = document.remove("title").unwrap_or_default(); + document.insert("name".into(), title); + Ok(()) +} + +fn add_flag(v: &mut Value) -> Result<(), Error> { + v["document"]["flag"] = json!(true); + Ok(()) +} + +fn replace_with_array(v: &mut Value) -> Result<(), Error> { + *v = json!([]); + Ok(()) +} + +const STEPS: &[Step] = &[rename_title, add_flag]; + +#[test] +fn steps_run_in_order_from_the_file_s_version() { + let mut v = json!({"format_version": 1, "document": {"title": "old"}}); + assert_eq!(migrate(&mut v, STEPS, 3).unwrap(), 1); + assert_eq!(v, json!({"format_version": 3, "document": {"name": "old", "flag": true}})); +} + +#[test] +fn a_file_at_a_later_version_runs_only_the_later_steps() { + let mut v = json!({"format_version": 2, "document": {"title": "kept"}}); + assert_eq!(migrate(&mut v, STEPS, 3).unwrap(), 2); + assert_eq!(v, json!({"format_version": 3, "document": {"title": "kept", "flag": true}})); +} + +#[test] +fn the_current_version_is_left_unchanged() { + let mut v = json!({"format_version": FORMAT_VERSION, "document": {}}); + let before = v.clone(); + let none: &[Step] = &[]; + assert_eq!(migrate(&mut v, none, FORMAT_VERSION).unwrap(), FORMAT_VERSION); + assert_eq!(v, before); +} + +#[test] +fn a_newer_version_is_too_new() { + let mut v = json!({"format_version": 4}); + assert!(matches!(migrate(&mut v, STEPS, 3), Err(Error::TooNew { found: 4, supported: 3, generator: None, .. }))); + let mut v = json!({"format_version": 4, "generator": "photocraft-format 2.0.0"}); + assert!(matches!(migrate(&mut v, STEPS, 3), Err(Error::TooNew { generator: Some(g), .. }) if g == "photocraft-format 2.0.0")); +} + +#[test] +fn a_missing_zero_or_out_of_range_version_is_corrupt() { + for v in [ + json!({}), + json!({"format_version": 0}), + json!({"format_version": "1"}), + json!({"format_version": -1}), + json!({"format_version": 1.5}), + json!({"format_version": 4_294_967_296_u64}), + json!([1]), + json!(null), + ] { + let mut v = v; + assert!(matches!(migrate(&mut v, STEPS, 3), Err(Error::Corrupt(_))), "{v}"); + } +} + +#[test] +fn a_version_with_no_step_is_unsupported() { + let mut v = json!({"format_version": 1, "document": {"title": "t"}}); + let one: &[Step] = &[rename_title]; + assert!(matches!(migrate(&mut v, one, 3), Err(Error::Unsupported(m)) if m == "no migration from format 2")); + assert_eq!(v["format_version"], 2); +} + +#[test] +fn a_step_that_leaves_no_object_is_corrupt() { + let mut v = json!({"format_version": 1}); + let steps: &[Step] = &[replace_with_array]; + assert!(matches!(migrate(&mut v, steps, 2), Err(Error::Corrupt(_)))); +} + +#[derive(Debug)] +enum AppError { + Format(Error), + Step(&'static str), +} + +impl From for AppError { + fn from(e: Error) -> Self { + AppError::Format(e) + } +} + +fn failing(_: &mut Value) -> Result<(), AppError> { + Err(AppError::Step("refused")) +} + +fn app_add_flag(v: &mut Value) -> Result<(), AppError> { + add_flag(v).map_err(AppError::from) +} + +#[test] +fn steps_return_the_caller_s_error_type() { + let steps: &[Step] = &[app_add_flag, failing]; + let mut v = json!({"format_version": 1, "document": {}}); + assert!(matches!(migrate(&mut v, steps, 3), Err(AppError::Step("refused")))); + assert_eq!(v, json!({"format_version": 2, "document": {"flag": true}})); + let mut newer = json!({"format_version": 9}); + assert!(matches!(migrate(&mut newer, steps, 3), Err(AppError::Format(Error::TooNew { found: 9, supported: 3, .. })))); +} diff --git a/crates/pcraft-format/tests/read.rs b/crates/pcraft-format/tests/read.rs new file mode 100644 index 00000000..ab3a218e --- /dev/null +++ b/crates/pcraft-format/tests/read.rs @@ -0,0 +1,191 @@ +use craft_pcraft_format::{COMPOSITE, Error, FORMAT_VERSION, Limits, Manifest, Step, THUMB, TileRef, read_manifest, read_manifest_with}; +use serde::Deserialize; +use serde_json::{Value, json}; + +#[derive(Debug, Clone, PartialEq, Deserialize)] +struct Doc { + name: String, + #[serde(default)] + tiles: Vec, +} + +const NONE: &[Step] = &[]; + +fn read(json: &[u8]) -> Result, Error> { + read_manifest(json, &Limits::default(), NONE, FORMAT_VERSION) +} + +fn nested_document(depth: usize) -> Vec { + let mut json = br#"{"format_version":1,"generator":"g","document":{"name":"a","deep":"#.to_vec(); + json.extend("[".repeat(depth).bytes()); + json.extend("]".repeat(depth).bytes()); + json.extend(b"}}"); + json +} + +#[test] +fn a_manifest_reads_with_its_previews_and_document() { + let json = br#"{"format_version":1,"generator":"photocraft-format 0.1.0","document":{"name":"a","tiles":[{"tx":0,"ty":-1,"hash":"x"}]},"thumbnail":"thumb.png","composite":null,"later":[1]}"#; + let m = read(json).unwrap(); + assert_eq!(m.format_version, 1); + assert_eq!(m.generator, "photocraft-format 0.1.0"); + assert_eq!(m.document.name, "a"); + assert_eq!(m.document.tiles, vec![TileRef::new(0, -1, "x".into())]); + assert_eq!(m.thumbnail.as_deref(), Some("thumb.png")); + assert_eq!(m.composite, None); +} + +#[test] +fn a_newer_manifest_is_too_new_and_names_its_writer() { + let json = br#"{"format_version":2,"generator":"photocraft-format 9.0.0","document":{"name":"a"}}"#; + let Err(Error::TooNew { found, supported, generator, .. }) = read(json) else { panic!("not TooNew") }; + assert_eq!((found, supported), (2, 1)); + assert_eq!(generator.as_deref(), Some("photocraft-format 9.0.0")); + let message = read(json).err().map(|e| e.to_string()); + assert_eq!(message.as_deref(), Some("bundle written by a newer version (format 2, this build reads up to 1)")); +} + +#[test] +fn a_newer_manifest_without_a_string_generator_is_too_new_without_one() { + for json in [&br#"{"format_version":3,"document":{}}"#[..], br#"{"format_version":3,"generator":7}"#] { + assert!(matches!(read(json), Err(Error::TooNew { found: 3, generator: None, .. }))); + } +} + +#[test] +fn a_manifest_past_the_limits_is_refused_before_it_is_read() { + let limits = Limits::default(); + let deep = nested_document(limits.max_depth); + assert!(matches!(read(&deep), Err(Error::LimitExceeded(m)) if m == "manifest JSON nested deeper than 367 levels")); + let within = nested_document(limits.max_depth - 2); + let m: Manifest = read_manifest(&within, &limits, NONE, FORMAT_VERSION).unwrap(); + assert_eq!(m.document["name"], "a"); + + let mut small = Limits::default(); + small.max_manifest_bytes = 10; + let json = br#"{"format_version":1,"generator":"g","document":{"name":"a"}}"#; + let large: Result, Error> = read_manifest(json, &small, NONE, FORMAT_VERSION); + assert!(matches!(large, Err(Error::LimitExceeded(m)) if m == "`manifest.json` exceeds 10 bytes")); + + let mut few = Limits::default(); + few.max_items = 2; + let many: Result, Error> = read_manifest(br#"{"format_version":1,"generator":"g","document":{"name":"a"}}"#, &few, NONE, 1); + assert!(matches!(many, Err(Error::LimitExceeded(_)))); +} + +#[test] +fn the_steps_run_before_the_document_is_read() { + fn rename(v: &mut Value) -> Result<(), Error> { + v["document"]["name"] = v["document"]["title"].take(); + Ok(()) + } + let steps: &[Step] = &[rename]; + let json = br#"{"format_version":1,"generator":"g","document":{"title":"old"}}"#; + let m: Manifest = read_manifest(json, &Limits::default(), steps, 2).unwrap(); + assert_eq!(m.format_version, 2); + assert_eq!(m.document.name, "old"); +} + +#[test] +fn a_missing_or_invalid_envelope_member_is_a_json_error() { + for (json, message) in [ + (&br#"{"format_version":1,"generator":"g"}"#[..], "missing field `document`"), + (br#"{"format_version":1,"document":{"name":"a"}}"#, "missing field `generator`"), + (br#"{"format_version":1,"generator":"g","document":{"name":"a"},"thumbnail":5}"#, "invalid type: integer `5`, expected a string"), + (br#"{"format_version":1,"generator":"g","document":{"title":"a"}}"#, "missing field `name`"), + ] { + let e = read(json).err(); + assert!(matches!(&e, Some(Error::Json(_))), "{e:?}"); + assert_eq!(e.map(|e| e.to_string()).as_deref(), Some(format!("manifest JSON: {message}").as_str())); + } +} + +/// The manifest as a struct with a derived `Deserialize`, for comparing errors. +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct Derived { + format_version: u32, + generator: String, + document: Doc, + #[serde(default)] + thumbnail: Option, + #[serde(default)] + composite: Option, +} + +#[test] +fn a_manifest_with_several_faults_reports_the_one_a_derived_struct_reports() { + for json in [ + json!({"format_version": 1, "composite": 1, "document": {"title": "a"}}), + json!({"format_version": 1, "generator": 2, "document": {"title": "a"}}), + json!({"format_version": 1, "document": {"title": "a"}, "thumbnail": 3}), + json!({"format_version": 1, "document": {"name": "a"}, "thumbnail": 3}), + json!({"format_version": 1, "thumbnail": 3}), + json!({"format_version": 1}), + json!({"format_version": 1, "generator": "g"}), + json!({"format_version": 1, "composite": [], "generator": {}}), + ] { + let want = serde_json::from_value::(json.clone()).err().map(|e| format!("manifest JSON: {e}")); + let got = read(json.to_string().as_bytes()).err().map(|e| e.to_string()); + assert!(want.is_some()); + assert_eq!(got, want, "{json}"); + } +} + +#[test] +fn a_missing_or_invalid_version_is_corrupt() { + assert!(matches!(read(br#"{"generator":"g","document":{"name":"a"}}"#), Err(Error::Corrupt(_)))); + assert!(matches!(read(br#"{"format_version":"1","generator":"g","document":{"name":"a"}}"#), Err(Error::Corrupt(_)))); + assert!(matches!(read(br#"{"format_version":1.5,"generator":"g","document":{"name":"a"}}"#), Err(Error::Corrupt(_)))); + assert!(matches!(read(b"[1]"), Err(Error::Corrupt(_)))); +} + +#[test] +fn a_preview_other_than_its_entry_is_corrupt() { + for (member, entry) in [ + ("thumbnail", "../x"), + ("thumbnail", "/etc/passwd"), + ("composite", "../x"), + ("composite", "C:\\x.png"), + ("thumbnail", "composite/preview.png"), + ("composite", "thumb.png"), + ] { + let json = json!({"format_version": 1, "generator": "g", "document": {"name": "a"}, member: entry}); + let e = read(json.to_string().as_bytes()).err(); + assert!(matches!(&e, Some(Error::Corrupt(_))), "{json}: {e:?}"); + } + let json = + json!({"format_version": 1, "generator": "g", "document": {"name": "a"}, "thumbnail": "thumb.png", "composite": "composite/preview.png"}); + let m = read(json.to_string().as_bytes()).unwrap(); + assert_eq!((m.thumbnail.as_deref(), m.composite.as_deref()), (Some(THUMB), Some(COMPOSITE))); +} + +#[derive(Debug)] +enum AppError { + Format(Error), + Document(String), +} + +impl From for AppError { + fn from(e: Error) -> Self { + AppError::Format(e) + } +} + +#[test] +fn read_manifest_with_reads_the_document_with_the_caller_s_function() { + let json = br#"{"format_version":1,"generator":"g","document":{"name":"a","layers":[1,2,3]}}"#; + let none: &[Step] = &[]; + let m = read_manifest_with(json, &Limits::default(), none, 1, |mut document: Value| { + let layers = document.get_mut("layers").map(Value::take).unwrap_or_default(); + Ok::<_, AppError>((document, layers)) + }) + .unwrap(); + assert_eq!(m.document.0, json!({"name": "a", "layers": null})); + assert_eq!(m.document.1, json!([1, 2, 3])); + + let refused = read_manifest_with(json, &Limits::default(), none, 1, |_| Err::<(), _>(AppError::Document("no".into()))); + assert!(matches!(refused, Err(AppError::Document(m)) if m == "no")); + let newer = read_manifest_with(br#"{"format_version":2}"#, &Limits::default(), none, 1, |_| Ok::<(), AppError>(())); + assert!(matches!(newer, Err(AppError::Format(Error::TooNew { found: 2, .. })))); +}