mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-11 13:02:25 +00:00
craft-doc: read a bounded PhotoCraft ICC payload
This commit is contained in:
1 parent
6865897f14
commit
ee2d912f24
5 files changed
+453
-9
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user