mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-02 18:07:45 +00:00
Add craft-pcraft-format: the envelope of PhotoCraft's .pcraft bundle (#407)
* Add craft-pcraft-format: the envelope of PhotoCraft's .pcraft bundle The manifest around the document, with the document as a type parameter (Manifest<D>), TileRef, the bundle's entry paths and hash check, FORMAT_VERSION, the manifest decode limits with the nesting and item scan, and the migration runner as a generic step list. Messages and serialized field order are PhotoCraft's, so its saved manifests stay byte-identical when it switches to these types. Part of #59. * ci: do not keep the checkout token in the pcraft-format workflow The checkout step sets persist-credentials: false, as jpeg.yml and media.yml do. No later step in the job uses the token. * pcraft-format: build no entry name from an invalid hash tile_path and blob_path return None unless the hash is 64 lowercase hex digits, so a hash read from a file such as "../x" never becomes an entry name. TileRef::path gives a tile's entry the same way. The docs note that MAX_GROUP_DEPTH is a copy of PhotoCraft's constant, and that check_manifest also refuses a manifest larger than max_manifest_bytes, which PhotoCraft's check before a save does not. * ci: pin actions/checkout by commit and Rust to 1.95 in the pcraft-format workflow * pcraft-format: read_manifest is the one reader of a manifest read_manifest(json, limits, steps, target) checks the JSON against the limits, refuses a format_version newer than target, runs the migration steps, and reads the document as D and the other envelope members. read_manifest_with reads the document with the caller's function instead. Manifest no longer implements Deserialize. Error::TooNew holds the manifest's generator, so a message can name the writer; its message is unchanged. TooNew, Manifest and TileRef are #[non_exhaustive], and Manifest::new and TileRef::new build them. TILES_DIR, BLOBS_DIR, COMPOSITE_DIR and OBJECT_SUFFIX name the bundle's directories and the object suffix. The docs of max_blob_bytes and max_total_bytes say the caller enforces them. * pcraft-format: read_manifest reports errors in serde's struct order read_manifest reads the envelope members and the document in the order the parsed object holds them, and reports a missing member after the members that are present. A manifest with several faults gives the same error as a derived Deserialize of the whole manifest, as PhotoCraft reads it today. * pcraft-format: document that a missing or invalid format_version is Corrupt read_manifest's docs and the README say that a missing or invalid format_version gives Error::Corrupt from migrate, and that a missing generator or document, or any other mistyped member, gives Error::Json. A test covers a string and a fractional format_version. * pcraft-format: read_manifest refuses preview names other than its own A manifest's `thumbnail` and `composite` named any entry the reader then opened, while tile entries go through `is_valid_hash`. `read_manifest` now gives `Error::Corrupt` for a `thumbnail` other than `thumb.png` or a `composite` other than `composite/preview.png`, the only names PhotoCraft writes. The check runs after the members are read, so JSON errors keep their order. The reader's doc now says a missing or invalid `format_version` is `Error::Corrupt`, from `migrate`, not `Error::Json`.
This commit is contained in:
@@ -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
|
||||
Generated
+9
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -82,6 +82,9 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
|
||||
(app menu, Window, Help, system-key clashes), a platform backend trait, and key equivalents
|
||||
fed back to egui as the input egui-winit would have made. Commands and their enabled state
|
||||
stay in each app.
|
||||
- [`craft-pcraft-format`](crates/pcraft-format/README.md): the envelope of PhotoCraft's `.pcraft`
|
||||
bundle: `Manifest<D>` 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.
|
||||
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 = "<commit>" }
|
||||
```
|
||||
|
||||
## 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/<blake3>.zst` | `tile_path(hash)` | a 256×256 tile, zstd, little-endian samples |
|
||||
| `blobs/<blake3>.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) |
|
||||
|
||||
`<blake3>` 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<MyDocument> = 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<D>` 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<E>` is
|
||||
`fn(&mut Value) -> Result<(), E>`; with `E: From<Error>`, 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.
|
||||
@@ -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/<blake3>.zst content-addressed 256×256 tiles (zstd, little-endian samples)
|
||||
//! blobs/<blake3>.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<serde_json::Value> = 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/<hash>.zst`. `None` when [`is_valid_hash`]
|
||||
/// refuses `hash`.
|
||||
pub fn tile_path(hash: &str) -> Option<String> {
|
||||
is_valid_hash(hash).then(|| format!("{TILES_DIR}/{hash}{OBJECT_SUFFIX}"))
|
||||
}
|
||||
|
||||
/// The entry of the blob with hash `hash`: `blobs/<hash>.zst`. `None` when [`is_valid_hash`]
|
||||
/// refuses `hash`.
|
||||
pub fn blob_path(hash: &str) -> Option<String> {
|
||||
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::Value> = serde_json::from_str("{}").unwrap();
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct Manifest<D> {
|
||||
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<String>,
|
||||
/// The flattened preview entry, [`COMPOSITE`]. [`read_manifest`] refuses any other name.
|
||||
pub composite: Option<String>,
|
||||
}
|
||||
|
||||
impl<D> Manifest<D> {
|
||||
/// A manifest at [`FORMAT_VERSION`] written by `generator`, holding `document`, with no
|
||||
/// previews.
|
||||
pub fn new(generator: impl Into<String>, 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<String> {
|
||||
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<String>,
|
||||
},
|
||||
/// 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),
|
||||
}
|
||||
@@ -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<serde_json::Value, Error> {
|
||||
check_manifest(json, limits)?;
|
||||
let mut de = serde_json::Deserializer::from_slice(json);
|
||||
de.disable_recursion_limit();
|
||||
let value = <serde_json::Value as serde::Deserialize>::deserialize(&mut de)?;
|
||||
de.end()?;
|
||||
Ok(value)
|
||||
}
|
||||
@@ -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<E = Error> = 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<E: From<Error>>(v: &mut Value, steps: &[Step<E>], target: u32) -> Result<u32, E> {
|
||||
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())
|
||||
}
|
||||
@@ -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<D: DeserializeOwned, E: From<Error>>(json: &[u8], limits: &Limits, steps: &[Step<E>], target: u32) -> Result<Manifest<D>, 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<D, E: From<Error>>(
|
||||
json: &[u8],
|
||||
limits: &Limits,
|
||||
steps: &[Step<E>],
|
||||
target: u32,
|
||||
document: impl FnOnce(Value) -> Result<D, E>,
|
||||
) -> Result<Manifest<D>, 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::<String>::deserialize(member).map_err(Error::Json)?,
|
||||
"composite" => composite = Option::<String>::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(()),
|
||||
}
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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"));
|
||||
}
|
||||
@@ -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<D: serde::de::DeserializeOwned>(text: &str) -> Result<Manifest<D>, 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<TileRef>,
|
||||
}
|
||||
|
||||
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<Doc> {
|
||||
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<Doc> = 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<Doc> = 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::<Doc>(r#"{"format_version":1,"generator":"g"}"#).is_err());
|
||||
assert!(read::<Doc>(r#"{"format_version":1,"document":{"id":1,"name":"a","tiles":[]}}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_document_can_be_any_json_value() {
|
||||
let read: Manifest<serde_json::Value> = 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(""));
|
||||
}
|
||||
@@ -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<Error> 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<AppError>] = &[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, .. }))));
|
||||
}
|
||||
@@ -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<TileRef>,
|
||||
}
|
||||
|
||||
const NONE: &[Step] = &[];
|
||||
|
||||
fn read(json: &[u8]) -> Result<Manifest<Doc>, Error> {
|
||||
read_manifest(json, &Limits::default(), NONE, FORMAT_VERSION)
|
||||
}
|
||||
|
||||
fn nested_document(depth: usize) -> Vec<u8> {
|
||||
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<Value> = 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<Manifest<Doc>, 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<Manifest<Doc>, 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<Doc> = 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<String>,
|
||||
#[serde(default)]
|
||||
composite: Option<String>,
|
||||
}
|
||||
|
||||
#[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::<Derived>(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<Error> 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<AppError>] = &[];
|
||||
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, .. }))));
|
||||
}
|
||||
Reference in New Issue
Block a user