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

196 lines
7.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![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),
}