craft-doc: read a bounded PhotoCraft ICC payload

This commit is contained in:
dotCooCoo committed 2026-11-03 10:08:44 -08:00
1 parent 6865897f14
commit ee2d912f24
5 files changed
+453 -9

No files matched your search

+21
View File
@@ -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:
+4 -1
View File
@@ -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,
}
+14
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),
@@ -208,3 +214,11 @@ pub(crate) fn document<'a, D: DeserializeOwned>(bytes: &'a [u8], budget: &mut Bu
let document = read::<D>(&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::<D>(&json, budget, max_items)?.document))
}
+168 -8
View File
@@ -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<Option<Vec<u8>>, 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::<ProfileHead>(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<ProfileHash>,
}
impl<'de> Deserialize<'de> for ProfileHead {
/// Read an object while skipping all metadata unrelated to the profile reference.
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
#[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<A: MapAccess<'de>>(self, mut map: A) -> Result<ProfileHead, A::Error> {
let mut seen = false;
let mut profile = None;
while let Some(field) = map.next_key::<Field>()? {
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::<IgnoredAny>()?;
}
}
}
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: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
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<E: de::Error>(self, value: &str) -> Result<ProfileHash, E> {
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<S: Sample>(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<Vec<u8>, 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<Vec<u8>, 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")));
}
+246
View File
@@ -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<u8> {
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<u8> {
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());
}
}