Merge pull request #581 from dotCooCoo/feat/doc-pcraft-document-head

craft-doc: read a caller-defined PhotoCraft document head
This commit is contained in:
FredApps authored and GitHub committed 2026-11-07 15:37:30 +01:00
commit 75647cb84a
4 files changed
+201

No files matched your search

+10
View File
@@ -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<Outline, Error>`: `canvas`, `pages` and `layers`.
- `pcraft_document::<D>(bytes, &Limits) -> Result<D, Error>`: 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`.
+32
View File
@@ -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<Outline, Error> {
}
}
/// 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<D: DeserializeOwned>(bytes: &[u8], limits: &Limits) -> Result<D, Error> {
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())
}
+13
View File
@@ -104,9 +104,15 @@ struct Envelope<D> {
/// 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<D: DeserializeOwned>(json: &[u8], budget: &Budget) -> Result<Envelope<D>, 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<D: DeserializeOwned>(json: &[u8], budget: &Budget, max_items: usize) -> Result<Envelope<D>, 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::<D>(&json, budget)?.document;
Ok((zip, document))
}
/// The caller's document head, with its structural item limit checked before deserialization.
pub(crate) fn head<D: DeserializeOwned>(bytes: &[u8], budget: &mut Budget) -> Result<D, Error> {
let (_, json) = manifest(bytes, budget)?;
let max_items = usize::try_from(budget.limits.max_items).unwrap_or(usize::MAX);
Ok(read_with_items::<D>(&json, budget, max_items)?.document)
}
+146
View File
@@ -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<Layer>,
}
#[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<u8> {
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<Head> =
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::<Head>(&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::<Head>(&bundle(&broken, false), &Limits::default()), Err(Error::Malformed(_))), "{broken}");
}
let newer = json.replacen("\"format_version\":1", "\"format_version\":2", 1);
assert!(matches!(pcraft_document::<Head>(&bundle(&newer, false), &Limits::default()), Err(Error::TooNew { kind: Kind::PhotoCraft, .. })));
assert_eq!(pcraft_document::<Head>(&bundle("{}", false), &Limits::default()), Err(Error::Unrecognized));
assert_eq!(pcraft_document::<Head>(&zip(&[store("readme", b"none")]), &Limits::default()), Err(Error::Unrecognized));
assert!(matches!(pcraft_document::<Head>(vectorcraft_json(4, "").as_bytes(), &Limits::default()), Err(Error::Unsupported(_))));
assert_eq!(pcraft_document::<Head>(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: serde::Deserializer<'de>>(_: D) -> Result<Self, D::Error> {
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::<Never>(&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<u32>,
}
#[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::<Small>(&bytes, &limits).map(|_| ()),
_ => pcraft_document::<Empty>(&bytes, &limits).map(|_| ()),
};
assert!(matches!(result, Err(Error::LimitExceeded(_))), "{field}: {result:?}");
assert!(pcraft_document::<Empty>(&bytes, &Limits::default()).is_ok());
}
}