mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-11 04:52:13 +00:00
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:
4 files changed
+201
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user