* 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`.
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 runsparse_manifest, thenmigrate, which refuses a version newer thantarget, then reads the members, the document asD.read_manifest_withtakes a function that reads the document from itsserde_json::Valueinstead ofD'sDeserialize.Manifest<D>hasformat_version,generator,document: D,thumbnailandcomposite, and serializes them in that order. Absent previews serialize asnull. On read, missing previews areNone, members the type does not name are skipped, and athumbnailother thanthumb.pngor acompositeother thancomposite/preview.pnggivesCorrupt.Manifesthas noDeserializeand is#[non_exhaustive]:Manifest::new(generator, document)builds one atFORMAT_VERSIONwithout previews.TileRefhastx,tyandhash, and is#[non_exhaustive]:TileRef::new(tx, ty, hash)builds one.TileRef::pathgives its entry, orNonewhen 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 aserde_json::Valuewith serde_json's 128-level recursion limit turned off.migrate(&mut value, steps, target)upgrades the JSON in place, wheresteps[i]upgrades versioni + 1toi + 2, and returns the version the file had.Step<E>isfn(&mut Value) -> Result<(), E>; withE: From<Error>, the steps return the caller's own error type.ErrorisCorrupt,Unsupported,TooNew,LimitExceededorJson. A manifest whoseformat_versionis newer thantargetgivesTooNew, and one with no step to the next version givesUnsupported. A missing or invalidformat_versiongivesCorrupt, and any other missing or mistyped member givesJson.TooNewholdsfound,supportedand the manifest'sgenerator(Nonewhen 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 ofmanifest.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 toMAX_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 aboveMAX_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 withserde_json::to_vec_prettyto 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_manifestandread_manifest_withread the envelope and the document, refuse a newer version with the writer'sgenerator, refuse a manifest past the limits, run the steps first, and report missing or mistyped members as a struct with a derivedDeserializereports them, in the same order; a missing or invalidformat_versionisCorrupt, and so is a preview that names another entry, such as../xor an absolute path.- Doc tests:
ManifestandTileRefare not built by struct literal,Manifesthas noDeserialize, andError::TooNewis matched with... tests/limits.rs: the default limits, nesting at and pastmax_depth, items at and pastmax_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.