Files
.CooCoo b8f7d93b00 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`.
2026-11-01 23:31:04 -05:00
..

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.

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

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.

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.