diff --git a/crates/doc/README.md b/crates/doc/README.md index d21dadec..289c7ef4 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -54,6 +54,16 @@ let outline = craft_doc::outline(&bytes, &limits)?; already-counted prefix bytes are replayed without retaining or charging another copy. JSON and selected-payload limits still apply, and requesting the damaged member still fails. - `outline(bytes, &Limits) -> Result`: `canvas`, `pages` and `layers`. +- `pcraft_document::(bytes, &Limits) -> Result`: a PhotoCraft manifest's `document` + deserialized directly into the caller's metadata type. It reads no previews, tiles or blobs + and does not check that referenced payload entries exist. Envelope, version, JSON byte and + nesting checks still apply. Before the caller's deserializer runs, `max_items` bounds the + whole manifest's structural JSON items, including skipped fields, as the shared format's + `check_manifest` counts them (opening array/object brackets and commas outside strings), + capped at that format's own ceiling. The caller owns its type's + allocations and behavior. No pixel feature is required. A cancellation variant can follow + after the cooperative document-read APIs in [#571](https://github.com/storytold/craft-libs/pull/571) + merge. - `Error` is `Unrecognized`, `Malformed`, `TooNew`, `Unsupported` or `LimitExceeded`. A file whose version is newer than the crate reads gives `TooNew` from `renditions` and `outline`. A ZIP archive whose `manifest.json` has no `format_version` gives `Unrecognized`. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 11a03978..0da42b1d 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -7,6 +7,7 @@ //! - [`renditions`] returns the previews and the PDF a file carries. //! - [`rendition`] reads just one kind of preview or PDF. //! - [`outline`] returns the canvas, the pages (artboards or compositions) and the layers. +//! - [`pcraft_document`] reads a caller-defined PhotoCraft document head without its payloads. //! - `pcraft_layer_rgba`, with the feature `pcraft-pixels`, decodes a PhotoCraft layer's pixels //! and its layer mask. //! @@ -323,6 +324,37 @@ pub fn outline(bytes: &[u8], limits: &Limits) -> Result { } } +/// A PhotoCraft manifest's `document` read as the caller's `D`, without reading its previews, +/// tiles or blobs. `D` may describe only the metadata the caller needs; other members are +/// skipped by serde. This does not validate the document against PhotoCraft's complete model +/// or check that its referenced payload entries exist. +/// +/// The manifest and envelope checks are the same as [`outline`]. Before `D` is deserialized, +/// [`Limits::max_items`] additionally bounds structural JSON items throughout the manifest, +/// including members `D` skips, as [`craft_pcraft_format::check_manifest`] counts them; the +/// count is opening array/object brackets and commas outside strings. The shared format's +/// own item ceiling still applies. Byte and nesting limits apply as usual. +/// `D`'s deserializer is caller code: its own allocations and behavior remain the caller's +/// responsibility. No pixel feature is required. +/// +/// A recognized document of another kind gives [`Error::Unsupported`]. +/// +/// ``` +/// #[derive(serde::Deserialize)] +/// struct Head { mode: String } +/// # fn read(bytes: &[u8]) -> Result<(), craft_doc::Error> { +/// let head: Head = craft_doc::pcraft_document(bytes, &craft_doc::Limits::default())?; +/// assert_eq!(head.mode, "Rgb"); +/// # Ok(()) +/// # } +/// ``` +pub fn pcraft_document(bytes: &[u8], limits: &Limits) -> Result { + match sniff(bytes).ok_or(Error::Unrecognized)? { + Kind::PhotoCraft => pcraft::head(bytes, &mut Budget::new(limits)), + kind => Err(Error::Unsupported(format!("document metadata is read from .pcraft bundles, not .{}", kind.extension()))), + } +} + fn malformed(m: impl std::fmt::Display) -> Error { Error::Malformed(m.to_string()) } diff --git a/crates/doc/src/pcraft.rs b/crates/doc/src/pcraft.rs index 43d848f2..a1f6002b 100644 --- a/crates/doc/src/pcraft.rs +++ b/crates/doc/src/pcraft.rs @@ -104,9 +104,15 @@ struct Envelope { /// newer than [`FORMAT_VERSION`] refused), and a preview that names an entry other than [`THUMB`] /// or [`COMPOSITE`] refused. A member that appears twice is refused. fn read(json: &[u8], budget: &Budget) -> Result, Error> { + read_with_items(json, budget, format::MAX_MANIFEST_ITEMS) +} + +/// Check the envelope with a caller-supplied structural item cap before deserializing `D`. +fn read_with_items(json: &[u8], budget: &Budget, max_items: usize) -> Result, Error> { let mut limits = format::Limits::default(); limits.max_manifest_bytes = json.len(); limits.max_depth = (budget.limits.max_depth as usize).min(manifest_depth(MAX_GROUP_DEPTH)); + limits.max_items = max_items.min(format::MAX_MANIFEST_ITEMS); format::check_manifest(json, &limits).map_err(|e| match e { format::Error::LimitExceeded(m) => Error::LimitExceeded(m), e => malformed(e), @@ -211,3 +217,10 @@ pub(crate) fn document<'a, D: DeserializeOwned>(bytes: &'a [u8], budget: &mut Bu let document = read::(&json, budget)?.document; Ok((zip, document)) } + +/// The caller's document head, with its structural item limit checked before deserialization. +pub(crate) fn head(bytes: &[u8], budget: &mut Budget) -> Result { + let (_, json) = manifest(bytes, budget)?; + let max_items = usize::try_from(budget.limits.max_items).unwrap_or(usize::MAX); + Ok(read_with_items::(&json, budget, max_items)?.document) +} diff --git a/crates/doc/tests/pcraft_document.rs b/crates/doc/tests/pcraft_document.rs new file mode 100644 index 00000000..369fa885 --- /dev/null +++ b/crates/doc/tests/pcraft_document.rs @@ -0,0 +1,146 @@ +//! A caller-defined PhotoCraft document head, without reading image payloads. + +mod common; + +use common::*; +use craft_doc::{Error, Kind, Limits, outline, pcraft_document}; +use serde::Deserialize; + +#[derive(Debug, Deserialize, PartialEq)] +struct Head { + mode: String, + layers: Vec, +} + +#[derive(Debug, Deserialize, PartialEq)] +struct Layer { + name: String, + clipped: bool, + fill_opacity: f32, +} + +/// A document head whose optional previews and raster payload deliberately do not exist. +fn manifest() -> String { + pcraft_manifest( + 1, + r#"[{"name":"Paint","visible":true,"blend":"Normal","opacity":0.75,"clipped":true,"fill_opacity":0.5,"content":{"kind":"raster","surface":{"tiles":[{"hash":"missing"}]}}}]"#, + r#", "thumbnail":"thumb.png", "composite":"composite/preview.png""#, + ) +} + +/// Exercise both supported ZIP storage methods with the same manifest bytes. +fn bundle(json: &str, compressed: bool) -> Vec { + zip(&[if compressed { deflated("manifest.json", json.as_bytes()) } else { store("manifest.json", json.as_bytes()) }]) +} + +#[test] +/// Caller-selected metadata must agree with the shared format reader without requiring pixels. +fn reads_the_callers_document_head_without_requiring_declared_payloads() { + let json = manifest(); + for compressed in [false, true] { + let bytes = bundle(&json, compressed); + let head: Head = pcraft_document(&bytes, &Limits::default()).unwrap(); + assert_eq!(head, Head { mode: "Rgb".into(), layers: vec![Layer { name: "Paint".into(), clipped: true, fill_opacity: 0.5 }] }); + let existing = outline(&bytes, &Limits::default()).unwrap(); + assert_eq!(head.layers[0].name, existing.layers[0].name); + assert_eq!(head.mode, existing.canvas.unwrap().mode); + // The shared JSON reader uses the same caller-owned document schema. + let shared: craft_pcraft_format::Manifest = + craft_pcraft_format::read_manifest::<_, craft_pcraft_format::Error>(json.as_bytes(), &Default::default(), &[], 1).unwrap(); + assert_eq!(head, shared.document); + } +} + +#[test] +/// Unrequested image entries must not consume the manifest-only reader's byte budget. +fn skips_oversized_and_unreadable_unrequested_entries() { + let json = manifest(); + let mut bytes = + zip(&[store("tiles/missing.zst", b"not zstd"), store("composite/preview.png", b"not png"), store("manifest.json", json.as_bytes())]); + let entries: Vec<_> = bytes.windows(4).enumerate().filter_map(|(i, w)| (w == b"PK\x01\x02").then_some(i)).take(2).collect(); + for at in entries { + bytes[at + 10..at + 12].copy_from_slice(&99u16.to_le_bytes()); + bytes[at + 24..at + 28].copy_from_slice(&(u32::MAX - 1).to_le_bytes()); + } + let mut limits = Limits::default(); + limits.max_entry = json.len() as u64; + limits.max_total = json.len() as u64; + assert_eq!(pcraft_document::(&bytes, &limits).unwrap().layers.len(), 1); + assert!(matches!(craft_doc::renditions(&bytes, &limits), Err(Error::Malformed(_))), "the declared thumbnail is absent"); +} + +#[test] +/// Envelope validity, supported versions and the caller's schema are separate requirements. +fn checks_the_manifest_and_the_callers_json_types() { + let json = manifest(); + for broken in [ + "[]".into(), + "{".into(), + json.replacen("\"format_version\":1", "\"format_version\":0", 1), + json.replacen("\"generator\":", "\"generator\":null,\"generator\":", 1), + json.replace("thumb.png", "elsewhere.png"), + json.replace("\"clipped\":true", "\"clipped\":42"), + json.replace("\"fill_opacity\":0.5,", ""), + ] { + assert!(matches!(pcraft_document::(&bundle(&broken, false), &Limits::default()), Err(Error::Malformed(_))), "{broken}"); + } + let newer = json.replacen("\"format_version\":1", "\"format_version\":2", 1); + assert!(matches!(pcraft_document::(&bundle(&newer, false), &Limits::default()), Err(Error::TooNew { kind: Kind::PhotoCraft, .. }))); + assert_eq!(pcraft_document::(&bundle("{}", false), &Limits::default()), Err(Error::Unrecognized)); + assert_eq!(pcraft_document::(&zip(&[store("readme", b"none")]), &Limits::default()), Err(Error::Unrecognized)); + assert!(matches!(pcraft_document::(vectorcraft_json(4, "").as_bytes(), &Limits::default()), Err(Error::Unsupported(_)))); + assert_eq!(pcraft_document::(b"not a document", &Limits::default()), Err(Error::Unrecognized)); +} + +#[test] +/// Limits must reject the manifest before invoking potentially allocating caller code. +fn checks_byte_depth_and_item_limits_before_the_document_is_deserialized() { + #[derive(Debug)] + struct Never; + impl<'de> Deserialize<'de> for Never { + /// Fail if a limit check lets control reach the caller's deserializer. + fn deserialize>(_: D) -> Result { + panic!("document deserialization must follow limit checks") + } + } + let json = manifest(); + for compressed in [false, true] { + let bytes = bundle(&json, compressed); + for cap in ["JSON", "entry", "total", "depth", "items"] { + let mut limits = Limits::default(); + match cap { + "JSON" => limits.max_json = json.len() as u64 - 1, + "entry" => limits.max_entry = json.len() as u64 - 1, + "total" => limits.max_total = json.len() as u64 - 1, + "depth" => limits.max_depth = 2, + "items" => limits.max_items = 1, + _ => unreachable!(), + } + assert!(matches!(pcraft_document::(&bytes, &limits), Err(Error::LimitExceeded(_))), "{limits:?}"); + } + } +} + +#[test] +/// Ignoring a field must not let an oversized manifest bypass the structural item cap. +fn the_item_budget_also_bounds_a_callers_vec_and_ignored_fields() { + #[derive(Debug, Deserialize)] + struct Small { + #[serde(rename = "values")] + _values: Vec, + } + #[derive(Debug, Deserialize)] + struct Empty {} + for field in ["values", "ignored"] { + let json = format!(r#"{{"format_version":1,"generator":"test","document":{{"{field}":[0,1,2,3,4,5,6,7,8,9]}}}}"#); + let bytes = bundle(&json, true); + let mut limits = Limits::default(); + limits.max_items = 8; + let result = match field { + "values" => pcraft_document::(&bytes, &limits).map(|_| ()), + _ => pcraft_document::(&bytes, &limits).map(|_| ()), + }; + assert!(matches!(result, Err(Error::LimitExceeded(_))), "{field}: {result:?}"); + assert!(pcraft_document::(&bytes, &Limits::default()).is_ok()); + } +}