Files
craft-libs/crates/pcraft-format/src/migrate.rs
T
.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

42 lines
1.9 KiB
Rust

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())
}