diff --git a/crates/doc/README.md b/crates/doc/README.md index 03ee07e1..06717cbd 100644 --- a/crates/doc/README.md +++ b/crates/doc/README.md @@ -111,6 +111,27 @@ tile coordinates, a mask, layers in groups and layers without pixels, text, shap object copies, bitmap and indexed documents, a CMYK document, a zstd bomb, a tile shared by many references, and the limits. +## PhotoCraft ICC payload + +With `pcraft-pixels`, `pcraft_icc_profile(bytes, &limits)` returns the raw blob named by +`document.icc_profile`. Missing or null gives `Ok(None)`; a declared missing or damaged blob +is an error. The payload's BLAKE3 hash is checked, but its ICC semantics are not validated. +An empty verified payload is returned as `Some`, and no pixels or colors are converted. +Previews, tiles and unrelated blobs are not read. + +The manifest, the selected ZIP entry holding one zstd frame, and the decoded profile count +against `max_total`. Each is limited by `max_entry`, and the manifest also by `max_json` and +the normal PhotoCraft envelope/depth checks. Decoding admits only the remaining output budget, +at most an 8 MiB window, and no bytes after the frame. Structural manifest items (objects and +arrays opened, plus commas outside strings) count against `max_items` before metadata is read, +including skipped fields, with PhotoCraft's structural ceiling of 4,194,304. This is different +from the layer-pixel reader's counting of parsed layer and tile-reference lists. An absent +profile does not bypass manifest limits. Limits apply per call; a consumer reading +several layers and a profile must also bound its combined work and retained memory. + +This call takes no cancellation callback. It returns payload bytes rather than a parsed or +validated color profile and does not promise that a renderer honors those bytes. + ## Limits `Limits` (fields `max_json`, `max_entry`, `max_total`, `max_depth`) bounds each call: diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ed5daa36..02fd8757 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -8,6 +8,7 @@ //! - [`outline`] returns the canvas, the pages (artboards or compositions) and the layers. //! - `pcraft_layer_rgba`, with the feature `pcraft-pixels`, decodes a PhotoCraft layer's pixels //! and its layer mask. +//! - `pcraft_icc_profile`, with the same feature, returns a document's raw declared ICC payload. //! //! Bytes go in and plain data comes out: no file system access, no rendering and no app crate. //! Each format's envelope is read through its craft-libs crate: `craft-pcraft-format`, @@ -34,7 +35,7 @@ mod vectorcraft; mod zip; #[cfg(feature = "pcraft-pixels")] -pub use pcraft_pixels::{LayerMask, LayerPixels, Samples, pcraft_layer_rgba}; +pub use pcraft_pixels::{LayerMask, LayerPixels, Samples, pcraft_icc_profile, pcraft_layer_rgba}; use std::cell::Cell; use std::collections::BTreeMap; @@ -146,6 +147,8 @@ pub struct Limits { /// Most list elements one call reads, together: EffectCraft project items and composition /// layers, VectorCraft artboards and layers, and PhotoCraft layers at every level. Each is /// counted before it is read. Default 1,048,576. + /// `pcraft_icc_profile` instead caps structural JSON items throughout the manifest, + /// including skipped fields, before reading its profile reference. pub max_items: u64, } diff --git a/crates/doc/src/pcraft.rs b/crates/doc/src/pcraft.rs index bfbb53ff..c9788290 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), @@ -208,3 +214,11 @@ pub(crate) fn document<'a, D: DeserializeOwned>(bytes: &'a [u8], budget: &mut Bu let document = read::(&json, budget)?.document; Ok((zip, document)) } + +/// The archive and a document head, with structural items checked before deserialization. +#[cfg(feature = "pcraft-pixels")] +pub(crate) fn document_with_items<'a, D: DeserializeOwned>(bytes: &'a [u8], budget: &mut Budget) -> Result<(Zip<'a>, D), Error> { + let (zip, json) = manifest(bytes, budget)?; + let max_items = usize::try_from(budget.limits.max_items).unwrap_or(usize::MAX); + Ok((zip, read_with_items::(&json, budget, max_items)?.document)) +} diff --git a/crates/doc/src/pcraft_pixels.rs b/crates/doc/src/pcraft_pixels.rs index 85dca4ee..c2ebf182 100644 --- a/crates/doc/src/pcraft_pixels.rs +++ b/crates/doc/src/pcraft_pixels.rs @@ -7,12 +7,15 @@ //! samples, row by row, the color channels first and then the alpha channel when the format has //! one; the hash is the BLAKE3 hash of those samples. Pixels without a tile hold the default //! pixel. +//! +//! The same bounded blob decoder reads a document's declared ICC payload without interpreting it. use std::collections::{BTreeSet, HashMap}; use std::io::Read; use craft_pcraft_format::TileRef; -use serde::Deserialize; +use serde::de::{self, IgnoredAny, MapAccess, Visitor}; +use serde::{Deserialize, Deserializer}; use crate::zip::{Entry, Zip}; use crate::{Budget, Error, Kind, Limits, counted, malformed, pcraft, sniff}; @@ -20,7 +23,7 @@ use crate::{Budget, Error, Kind, Limits, counted, malformed, pcraft, sniff}; /// The side of a tile, in pixels. const TILE: usize = 256; -/// The largest zstd window a tile's frame may declare: 8 MiB, eight times the largest tile +/// The largest zstd window a tile or ICC frame may declare: 8 MiB, eight times the largest tile /// (256 × 256 pixels of four 32-bit samples). The decoder's buffer grows as it decodes, up to the /// window the frame declares; a frame that declares a larger window is refused before decoding /// starts. @@ -145,6 +148,110 @@ pub fn pcraft_layer_rgba(bytes: &[u8], layer: &[usize], limits: &Limits) -> Resu Ok(Some(LayerPixels { width: canvas.width, height: canvas.height, rgba, mask })) } +/// The raw ICC payload declared by a PhotoCraft document, verified against its BLAKE3 hash. +/// +/// A missing or null `document.icc_profile` gives `Ok(None)`. A declared profile whose hash, +/// entry or compressed payload is invalid gives an error. These bytes are not checked for ICC +/// semantics, interpreted, converted or rendered; even an empty verified blob is returned. +/// Previews, tiles and other blobs are not read. The normal manifest envelope is still checked. +/// +/// The manifest, its selected zstd frame and the decoded payload count against `max_total`; +/// each is bounded by `max_entry`, and the manifest also by `max_json`. The frame window is at +/// most 8 MiB. One frame is accepted, with no trailing bytes. Unlike the layer reader's counted +/// lists, this call checks `max_items` against structural JSON items throughout the manifest, +/// including skipped values: objects/arrays opened and commas outside strings, at most +/// [`craft_pcraft_format::MAX_MANIFEST_ITEMS`]. The check precedes metadata deserialization. +pub fn pcraft_icc_profile(bytes: &[u8], limits: &Limits) -> Result>, Error> { + match sniff(bytes).ok_or(Error::Unrecognized)? { + Kind::PhotoCraft => {} + kind => return Err(Error::Unsupported(format!("ICC payloads are read from .pcraft bundles, not .{}", kind.extension()))), + } + let mut budget = Budget::new(limits); + let (zip, head) = pcraft::document_with_items::(bytes, &mut budget)?; + let Some(ProfileHash(hash)) = head.profile else { return Ok(None) }; + let path = craft_pcraft_format::blob_path(&hash).ok_or_else(|| malformed("invalid ICC blob hash"))?; + let entry = zip.find(&path).ok_or_else(|| malformed(format!("the manifest names `{path}`, which the bundle does not have")))?; + let frame = zip.read(entry, limits.max_entry, &mut budget, &path)?; + let cap = limits.max_entry.min(budget.left()); + let cap = usize::try_from(cap).unwrap_or(usize::MAX); + let profile = unzstd_bounded(&frame, DecodedSize::AtMost(cap), &path)?; + budget.take_entry(profile.len() as u64, &path)?; + if blake3::hash(&profile).to_hex().as_str() != hash { + return Err(malformed(format!("{path}: the payload does not match its hash"))); + } + Ok(Some(profile)) +} + +/// A document object that retains only its declared profile reference. +struct ProfileHead { + profile: Option, +} + +impl<'de> Deserialize<'de> for ProfileHead { + /// Read an object while skipping all metadata unrelated to the profile reference. + fn deserialize>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(field_identifier, rename_all = "snake_case")] + enum Field { + IccProfile, + #[serde(other)] + Other, + } + struct Head; + impl<'de> Visitor<'de> for Head { + type Value = ProfileHead; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a PhotoCraft document object") + } + /// Track presence separately from value so null cannot conceal a duplicate field. + fn visit_map>(self, mut map: A) -> Result { + let mut seen = false; + let mut profile = None; + while let Some(field) = map.next_key::()? { + match field { + Field::IccProfile => { + if seen { + return Err(de::Error::duplicate_field("icc_profile")); + } + seen = true; + profile = map.next_value()?; + } + Field::Other => { + map.next_value::()?; + } + } + } + Ok(ProfileHead { profile }) + } + } + d.deserialize_map(Head) + } +} + +/// A canonical blob hash; invalid references are refused before building an entry name. +struct ProfileHash(String); + +impl<'de> Deserialize<'de> for ProfileHash { + /// Retain only a canonical reference, without accepting a caller-supplied archive path. + fn deserialize>(d: D) -> Result { + struct Hash; + impl Visitor<'_> for Hash { + type Value = ProfileHash; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a 64-digit lowercase BLAKE3 hash") + } + /// Check the decoded string before copying the 64 hash digits into owned metadata. + fn visit_str(self, value: &str) -> Result { + if !craft_pcraft_format::is_valid_hash(value) { + return Err(E::custom("invalid ICC blob hash")); + } + Ok(ProfileHash(value.to_owned())) + } + } + d.deserialize_str(Hash) + } +} + /// [`Error::Unsupported`] unless `surface` is stored as RGB or grayscale. fn check_mode(surface: &SurfaceP) -> Result<(), Error> { let mode = surface.format.mode.as_str(); @@ -435,16 +542,69 @@ fn decode_as(tiles: &mut Tiles, surface: &SurfaceP, canvas: Canvas, o /// One zstd frame that decodes to exactly `len` bytes. Decoding stops one byte past `len`, so a /// frame that expands further is refused without being decoded. fn unzstd(frame: &[u8], len: usize, what: &str) -> Result, Error> { + unzstd_bounded(frame, DecodedSize::Exact(len), what) +} + +/// An exact tile length or an admitted upper bound for a variable-length payload. +#[derive(Clone, Copy)] +enum DecodedSize { + Exact(usize), + AtMost(usize), +} + +/// Decode one bounded frame without allocating the destination from an untrusted header. +/// Exact lengths retain the tile reader's malformed-length errors; upper bounds admit only +/// remaining profile bytes. Destination growth stays within that bound, and the window is +/// limited before the decoder allocates it. Neither mode accepts a second frame or trailing data. +fn unzstd_bounded(frame: &[u8], size: DecodedSize, what: &str) -> Result, Error> { let zstd = |e: &dyn std::fmt::Display| malformed(format!("{what}: zstd: {e}")); - let mut dec = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(frame, MAX_WINDOW).map_err(|e| zstd(&e))?; - let mut out = Vec::with_capacity(len); - (&mut dec).take(len as u64 + 1).read_to_end(&mut out).map_err(|e| zstd(&e))?; - if out.len() > len { - return Err(malformed(format!("{what} decodes to more than {len} bytes"))); + let mut dec = ruzstd::decoding::StreamingDecoder::new_with_max_window_size(frame, MAX_WINDOW).map_err(|e| { + if matches!(size, DecodedSize::AtMost(_)) && matches!(e, ruzstd::decoding::errors::FrameDecoderError::WindowSizeTooBig { .. }) { + Error::LimitExceeded(format!("{what}: zstd window exceeds {MAX_WINDOW} bytes")) + } else { + zstd(&e) + } + })?; + let len = match size { + DecodedSize::Exact(n) | DecodedSize::AtMost(n) => n, + }; + let declared = dec.decoder.content_size(); + if matches!(size, DecodedSize::AtMost(_)) && declared > len as u64 { + return Err(Error::LimitExceeded(format!("{what}: declared payload exceeds {len} bytes"))); } - if out.len() < len { + let mut out = Vec::new(); + if let DecodedSize::Exact(n) = size { + out.try_reserve_exact(n).map_err(|_| Error::LimitExceeded(format!("{what} does not fit in memory")))?; + } + let mut chunk = [0u8; 8192]; + loop { + let n = len.saturating_sub(out.len()).saturating_add(1).min(chunk.len()); + let read = dec.read(&mut chunk[..n]).map_err(|e| zstd(&e))?; + if read == 0 { + break; + } + let next = out.len().checked_add(read).ok_or_else(|| Error::LimitExceeded(format!("{what} does not fit in memory")))?; + if next > len { + return Err(match size { + DecodedSize::Exact(_) => malformed(format!("{what} decodes to more than {len} bytes")), + DecodedSize::AtMost(_) => Error::LimitExceeded(format!("{what} decodes to more than {len} admitted bytes")), + }); + } + if next > out.capacity() { + let target = out.capacity().max(chunk.len()).saturating_mul(2).min(len).max(next); + out.try_reserve_exact(target - out.len()).map_err(|_| Error::LimitExceeded(format!("{what} does not fit in memory")))?; + } + out.extend_from_slice(&chunk[..read]); + } + if matches!(size, DecodedSize::Exact(_)) && out.len() < len { return Err(malformed(format!("{what} decodes to {} bytes, not {len}", out.len()))); } + if matches!(size, DecodedSize::AtMost(_)) && declared != 0 && declared != out.len() as u64 { + return Err(malformed(format!("{what}: declared payload size {declared} differs from {} decoded bytes", out.len()))); + } + if !dec.decoder.is_finished() { + return Err(malformed(format!("{what} has an incomplete zstd frame"))); + } if !dec.get_ref().is_empty() { return Err(malformed(format!("{what} has bytes after its zstd frame"))); } diff --git a/crates/doc/tests/icc.rs b/crates/doc/tests/icc.rs new file mode 100644 index 00000000..df83631b --- /dev/null +++ b/crates/doc/tests/icc.rs @@ -0,0 +1,246 @@ +//! Synthetic PhotoCraft ICC payloads: the declared bytes are opaque, content-addressed blobs. +#![cfg(feature = "pcraft-pixels")] + +mod common; + +use common::{deflated, store, zip}; +use craft_doc::{Error, Limits, pcraft_icc_profile}; + +/// Blob identity is over the decoded payload, not its compressed representation. +fn hash(bytes: &[u8]) -> String { + blake3::hash(bytes).to_hex().to_string() +} + +/// The producer's streaming encoder omits a declared content size. +fn frame(bytes: &[u8]) -> Vec { + ruzstd::encoding::compress_to_vec(bytes, ruzstd::encoding::CompressionLevel::Fastest) +} + +/// A minimal checked envelope around the document chosen by each regression. +fn manifest(document: &str) -> String { + format!(r#"{{"format_version":1,"generator":"synthetic","document":{document}}}"#) +} + +/// A profile read needs no canvas or layer metadata. +fn named_manifest(hash: &str) -> String { + manifest(&format!(r#"{{"icc_profile":"{hash}"}}"#)) +} + +/// Store a blob at its content address without assuming its bytes are a valid ICC profile. +fn bundle(payload: &[u8]) -> Vec { + let name = craft_pcraft_format::blob_path(&hash(payload)).unwrap(); + zip(&[store("manifest.json", named_manifest(&hash(payload)).as_bytes()), store(&name, &frame(payload))]) +} + +#[test] +/// Native absence is missing or null; declared blobs remain opaque, including empty ones. +fn absent_and_null_are_none_but_declared_payloads_are_returned_without_semantic_checks() { + for document in ["{}", r#"{"icc_profile":null}"#] { + assert_eq!(pcraft_icc_profile(&zip(&[store("manifest.json", manifest(document).as_bytes())]), &Limits::default()), Ok(None)); + } + for payload in [&b"not an ICC header"[..], &b""[..], &[0, 1, 2, 3][..]] { + assert_eq!(pcraft_icc_profile(&bundle(payload), &Limits::default()), Ok(Some(payload.to_vec()))); + } +} + +#[test] +/// Either selected ZIP entry may use the producer's supported outer compression method. +fn store_and_deflate_entries_use_the_same_bounded_blob_reader() { + let payload = b"synthetic profile bytes"; + let h = hash(payload); + let path = craft_pcraft_format::blob_path(&h).unwrap(); + let m = named_manifest(&h); + let z = frame(payload); + for archive in + [zip(&[store("manifest.json", m.as_bytes()), deflated(&path, &z)]), zip(&[deflated("manifest.json", m.as_bytes()), store(&path, &z)])] + { + assert_eq!(pcraft_icc_profile(&archive, &Limits::default()), Ok(Some(payload.to_vec()))); + } +} + +#[test] +/// Bad references and non-object documents are errors rather than an absent profile. +fn invalid_references_types_and_duplicate_profile_members_are_malformed() { + let h = hash(b"p"); + for document in [ + r#"{"icc_profile":""}"#.to_owned(), + format!(r#"{{"icc_profile":"{}"}}"#, h.to_uppercase()), + r#"{"icc_profile":"../manifest.json"}"#.to_owned(), + r#"{"icc_profile":3}"#.to_owned(), + r#"{"icc_profile":[]}"#.to_owned(), + r#"{"icc_profile":null,"icc_profile":null}"#.to_owned(), + format!(r#"{{"icc_profile":"{h}","icc_profile":null}}"#), + "[]".to_owned(), + "null".to_owned(), + ] { + let archive = zip(&[store("manifest.json", manifest(&document).as_bytes())]); + assert!(matches!(pcraft_icc_profile(&archive, &Limits::default()), Err(Error::Malformed(_))), "{document}"); + } +} + +#[test] +/// A declared payload must exist, match its content address and occupy exactly one frame. +fn missing_wrong_hash_or_damaged_declared_blobs_are_errors() { + let h = hash(b"wanted"); + let path = craft_pcraft_format::blob_path(&h).unwrap(); + let m = named_manifest(&h); + let valid = frame(b"wanted"); + let mut trailing = valid.clone(); + trailing.push(0); + let mut multiple = valid.clone(); + multiple.extend_from_slice(&frame(b"second")); + for archive in [ + zip(&[store("manifest.json", m.as_bytes())]), + zip(&[store("manifest.json", m.as_bytes()), store(&path, &frame(b"other"))]), + zip(&[store("manifest.json", m.as_bytes()), store(&path, b"not zstd")]), + zip(&[store("manifest.json", m.as_bytes()), store(&path, &valid[..valid.len() - 1])]), + zip(&[store("manifest.json", m.as_bytes()), store(&path, &trailing)]), + zip(&[store("manifest.json", m.as_bytes()), store(&path, &multiple)]), + ] { + assert!(matches!(pcraft_icc_profile(&archive, &Limits::default()), Err(Error::Malformed(_)))); + } +} + +#[test] +/// Unused previews and tile payloads cannot prevent reading an independently valid profile. +fn unrelated_payloads_are_never_read_and_document_metadata_is_skipped() { + let payload = b"profile"; + let h = hash(payload); + let path = craft_pcraft_format::blob_path(&h).unwrap(); + let m = format!( + r#"{{"format_version":1,"generator":"synthetic","thumbnail":"thumb.png","composite":"composite/preview.png", + "document":{{"icc_profile":"{h}","layers":[{{"content":{{"surface":{{"tiles":[{{"hash":"invalid"}}]}}}}}}],"future":[{{"ignored":true}}]}}}}"# + ); + let archive = zip(&[store("manifest.json", m.as_bytes()), store(&path, &frame(payload)), store("tiles/invalid.zst", b"broken tile")]); + assert_eq!(pcraft_icc_profile(&archive, &Limits::default()), Ok(Some(payload.to_vec()))); +} + +#[test] +/// Accepted work is exactly the manifest plus selected frame plus decoded payload. +fn manifest_frame_and_decoded_profile_have_exact_total_and_entry_boundaries() { + let payload = vec![7; 32 << 10]; + let h = hash(&payload); + let path = craft_pcraft_format::blob_path(&h).unwrap(); + let m = named_manifest(&h); + let z = frame(&payload); + let archive = zip(&[store("manifest.json", m.as_bytes()), store(&path, &z)]); + let mut limits = Limits::default(); + limits.max_json = m.len() as u64; + limits.max_entry = payload.len() as u64; + limits.max_total = (m.len() + z.len() + payload.len()) as u64; + assert_eq!(pcraft_icc_profile(&archive, &limits), Ok(Some(payload.clone()))); + limits.max_total -= 1; + assert!(matches!(pcraft_icc_profile(&archive, &limits), Err(Error::LimitExceeded(_)))); + limits.max_total += 1; + limits.max_entry -= 1; + assert!(matches!(pcraft_icc_profile(&archive, &limits), Err(Error::LimitExceeded(_)))); + limits.max_entry += 1; + limits.max_json -= 1; + assert!(matches!(pcraft_icc_profile(&archive, &limits), Err(Error::LimitExceeded(_)))); + let absent = manifest("{}"); + limits.max_json = absent.len() as u64; + limits.max_total = absent.len() as u64; + assert_eq!(pcraft_icc_profile(&zip(&[store("manifest.json", absent.as_bytes())]), &limits), Ok(None)); + limits.max_total -= 1; + assert!(matches!(pcraft_icc_profile(&zip(&[store("manifest.json", absent.as_bytes())]), &limits), Err(Error::LimitExceeded(_)))); +} + +#[test] +/// The frame's ZIP size must fail admission even if its bytes would also fail decoding. +fn an_oversized_frame_is_refused_before_the_zstd_decoder() { + let m = named_manifest(&hash(b"p")); + let path = craft_pcraft_format::blob_path(&hash(b"p")).unwrap(); + let mut limits = Limits::default(); + limits.max_entry = m.len() as u64; + let archive = zip(&[store("manifest.json", m.as_bytes()), store(&path, &vec![0; m.len() + 1])]); + assert!(matches!(pcraft_icc_profile(&archive, &limits), Err(Error::LimitExceeded(_)))); +} + +#[test] +/// Skipping document values must not let a small typed head evade the caller's item cap. +fn structural_item_limits_include_skipped_fields_before_the_profile_head() { + let archive = bundle(b"p"); + let mut limits = Limits::default(); + limits.max_items = 4; // root object, its two commas and document object + assert_eq!(pcraft_icc_profile(&archive, &limits), Ok(Some(b"p".to_vec()))); + limits.max_items = 3; + assert!(matches!(pcraft_icc_profile(&archive, &limits), Err(Error::LimitExceeded(_)))); + let m = manifest(r#"{"icc_profile":null,"ignored":[0,0,0,0,0]}"#); + let archive = zip(&[store("manifest.json", m.as_bytes())]); + limits.max_items = 4; + assert!(matches!(pcraft_icc_profile(&archive, &limits), Err(Error::LimitExceeded(_)))); + limits.max_items = 0; + assert!(matches!(pcraft_icc_profile(&zip(&[store("manifest.json", manifest("{}").as_bytes())]), &limits), Err(Error::LimitExceeded(_)))); +} + +#[test] +/// Absence of a profile cannot bypass kind, envelope, format-version or nesting checks. +fn versions_kinds_envelope_and_depth_are_checked_even_without_a_profile() { + assert_eq!(pcraft_icc_profile(b"not a bundle", &Limits::default()), Err(Error::Unrecognized)); + assert!(matches!(pcraft_icc_profile(common::vectorcraft_json(4, "").as_bytes(), &Limits::default()), Err(Error::Unsupported(_)))); + let m = manifest("{}").replace("\"format_version\":1", "\"format_version\":2"); + assert!(matches!(pcraft_icc_profile(&zip(&[store("manifest.json", m.as_bytes())]), &Limits::default()), Err(Error::TooNew { .. }))); + for m in [ + r#"{"format_version":1,"generator":"a","generator":"b","document":{}}"#, + r#"{"format_version":1,"generator":"a","document":{},"thumbnail":"elsewhere"}"#, + ] { + assert!(matches!(pcraft_icc_profile(&zip(&[store("manifest.json", m.as_bytes())]), &Limits::default()), Err(Error::Malformed(_)))); + } + let m = manifest(r#"{"ignored":[[[0]]]}"#); + let mut limits = Limits::default(); + limits.max_depth = 3; + assert!(matches!(pcraft_icc_profile(&zip(&[store("manifest.json", m.as_bytes())]), &limits), Err(Error::LimitExceeded(_)))); +} + +#[test] +/// Header-window limits and streamed output admission remain effective without a size hint. +fn excessive_window_and_size_less_expansion_are_limits() { + let payload = vec![0; 64 << 10]; + let h = hash(&payload); + let path = craft_pcraft_format::blob_path(&h).unwrap(); + let m = named_manifest(&h); + let z = frame(&payload); + // Producer frames omit their content size; output admission must work without that hint. + assert_eq!(z[4] & 0xc0, 0); + let mut limits = Limits::default(); + limits.max_entry = 1024; + assert!(matches!(pcraft_icc_profile(&zip(&[store("manifest.json", m.as_bytes()), store(&path, &z)]), &limits), Err(Error::LimitExceeded(_)))); + let mut large_window = z.clone(); + assert_eq!(large_window[4] & 0x20, 0); + large_window[5] = 14 << 3; // 16 MiB + assert!(matches!( + pcraft_icc_profile(&zip(&[store("manifest.json", m.as_bytes()), store(&path, &large_window)]), &Limits::default()), + Err(Error::LimitExceeded(_)) + )); +} + +#[test] +/// Nonzero size hints permit early refusal but never replace checking actual decoded bytes. +fn declared_nonzero_content_sizes_are_checked_before_and_after_decoding() { + let h = hash(b"p"); + let path = craft_pcraft_format::blob_path(&h).unwrap(); + let m = named_manifest(&h); + // A single-segment frame declaring 4096 bytes, but holding one raw byte. + let mut z = vec![0x28, 0xb5, 0x2f, 0xfd, 0x60]; + z.extend_from_slice(&(4096u16 - 256).to_le_bytes()); + z.extend_from_slice(&[9, 0, 0, b'p']); + let archive = zip(&[store("manifest.json", m.as_bytes()), store(&path, &z)]); + let mut limits = Limits::default(); + limits.max_entry = 1024; + assert!(matches!(pcraft_icc_profile(&archive, &limits), Err(Error::LimitExceeded(_)))); + assert!(matches!(pcraft_icc_profile(&archive, &Limits::default()), Err(Error::Malformed(_)))); + let z = [0x28, 0xb5, 0x2f, 0xfd, 0x20, 1, 9, 0, 0, b'p']; + assert_eq!(pcraft_icc_profile(&zip(&[store("manifest.json", m.as_bytes()), store(&path, &z)]), &limits), Ok(Some(b"p".to_vec()))); +} + +#[test] +/// Damaged envelopes and frames fail through the public accessor without escaping a panic. +fn truncations_and_mutations_do_not_panic() { + let archive = bundle(b"synthetic profile"); + for i in 0..archive.len() { + assert!(pcraft_icc_profile(&archive[..i], &Limits::default()).is_err()); + let mut changed = archive.clone(); + changed[i] ^= 0xff; + let _ = pcraft_icc_profile(&changed, &Limits::default()); + } +}