mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-12 10:03:51 +00:00
* 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`.
115 lines
3.8 KiB
Rust
115 lines
3.8 KiB
Rust
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, .. }))));
|
|
}
|