PSD integration test: deep groups + NaN text, damaged layer section, huge header under a small budget, on a 2 MiB stack

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-13 04:47:27 -04:00
committed by Brandon Thomas
co-authored by Claude Sonnet 5.5
parent f2d481ce20
commit 5f4b3aee86
3 changed files with 97 additions and 1 deletions
+2
View File
@@ -1494,6 +1494,8 @@ mod memory_tests;
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_integration;
#[cfg(test)]
mod tests_opt;
/// Test-only call counters (per thread), to prove what the optimised paths avoid doing.
+1 -1
View File
@@ -265,7 +265,7 @@ fn pixels_match_the_one_buffer_per_channel_reference() {
}
/// A copy of `bytes` with a different size, depth and merged-image section (`comp`, then `body`).
fn with_merged(bytes: &[u8], size: Option<(u32, u32)>, depth: Option<u16>, comp: u16, body: &[u8]) -> Vec<u8> {
pub(crate) fn with_merged(bytes: &[u8], size: Option<(u32, u32)>, depth: Option<u16>, comp: u16, body: &[u8]) -> Vec<u8> {
let offset = Psd::parse(bytes.to_vec()).unwrap().merged_offset;
let mut v = bytes[..offset].to_vec();
if let Some((w, h)) = size {
+94
View File
@@ -0,0 +1,94 @@
//! The hardening of the Photoshop reader working together: one hostile file (groups nested past
//! the limit, a NaN text transform), a damaged layer section, and a tiny file claiming a huge
//! image. Each ends in a clean result or a clean error, on a 2 MiB stack, never a panic.
use super::memory_tests::with_merged;
use super::write::*;
use super::*;
/// Run `f` on a 2 MiB thread; a panic or stack overflow in it fails the test.
fn on_small_stack<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
std::thread::Builder::new().stack_size(2 << 20).spawn(f).expect("spawn").join().expect("no panic")
}
fn depth_of(nodes: &[Node]) -> usize {
nodes.iter().map(|n| if let Node::Group { children, .. } = n { 1 + depth_of(children) } else { 0 }).max().unwrap_or(0)
}
#[test]
fn a_hostile_file_with_deep_groups_and_a_nan_text_value_loads_and_decodes() {
let n = 2 * MAX_GROUP_DEPTH + 36;
let mut d = WDoc::new(8, 8);
// File order is bottom first: the section ends open the nest, the group headers close it.
d.layers = (0..n).map(|_| WLayer::group_end()).collect();
for i in 0..n {
d.layers.push(WLayer::solid(&format!("L{i}"), Rect::new(0, 0, 2, 2), [1.0, 0.0, 0.0, 1.0]));
d.layers.push(WLayer::group(&format!("G{i}"), true, *b"norm"));
}
d.layers.push(WLayer::empty("Title", vec![text_block("x", [f64::NAN, f64::INFINITY], "Inter", 12.0, [0.0; 3], 0)]));
let bytes = write(&d);
let (depth, flattened, finite, ok) = on_small_stack(move || {
let p = Psd::parse(bytes).expect("parses");
let (tree, flattened) = p.tree_report();
let title = p.layers.iter().find(|l| l.name == "Title").and_then(|l| l.text.as_ref());
let finite = title.is_some_and(|t| t.transform.iter().all(|v| v.is_finite()));
// Every layer and the merged image decode to a result or an error, and results are finite.
let mut ok = p.composite().map_or(true, |px| px.data.iter().flatten().all(|v| v.is_finite()));
for i in 0..p.layers.len() {
ok &= p.layer_pixels(i, false).map_or(true, |px| px.data.iter().flatten().all(|v| v.is_finite()));
}
(depth_of(&tree), flattened, finite, ok)
});
assert!(depth <= MAX_GROUP_DEPTH, "{depth}");
assert!(flattened > 0);
assert!(finite, "the NaN transform fell back to the identity");
assert!(ok);
}
#[test]
fn a_damaged_layer_section_falls_back_to_the_merged_image() {
let mut d = WDoc::new(30, 20);
d.layers = vec![
WLayer::solid("Back", Rect::new(0, 0, 30, 20), [0.0, 0.0, 1.0, 1.0]),
WLayer::solid("Box", Rect::new(2, 2, 8, 8), [1.0, 0.0, 0.0, 1.0]).with_blend(b"mul "),
];
let mut bytes = write(&d);
let at = bytes.windows(8).position(|w| w == b"8BIMmul ").expect("blend signature");
bytes[at] = b'X';
let (layers, warnings, size, composite_ok) = on_small_stack(move || {
let p = Psd::parse(bytes).expect("lenient");
(p.layers.len(), p.warnings.clone(), (p.width, p.height), p.composite().is_ok())
});
assert_eq!(layers, 0);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("Layers could not be read"));
assert_eq!(size, (30, 20));
assert!(composite_ok);
}
#[test]
fn a_tiny_file_claiming_a_huge_image_is_refused_within_the_budget() {
let mut small = WDoc::new(2, 2);
small.rle = false;
small.layers = vec![WLayer::solid("L", Rect::new(0, 0, 2, 2), [1.0, 0.0, 0.0, 1.0])];
let base = write(&small);
let stream = miniz_oxide::deflate::compress_to_vec_zlib(&vec![0u8; 1 << 20], 9);
for (w, h, comp) in [(16384u32, 16384u32, 2u16), (16384, 16384, 3), (4096, 4096, 2), (4096, 4096, 3), (16384, 16384, 1), (16384, 16384, 0)] {
let bytes = with_merged(&base, Some((w, h)), None, comp, &stream);
assert!(bytes.len() < 8192);
let outcome = on_small_stack(move || {
let p = Psd::parse(bytes).expect("header and layers parse");
// A small test budget: one MiB, far under what the claimed image needs.
let merged = p.composite_with_budget(1 << 20).map(|_| ()).map_err(|e| e.to_string());
let layer = p.layer_pixels_with_budget(0, true, 1 << 20).map(|_| ()).map_err(|e| e.to_string());
let default_budget = p.composite().map(|_| ()).map_err(|e| e.to_string());
(merged, layer, default_budget)
});
let (merged, layer, default_budget) = outcome;
assert!(merged.as_ref().is_err_and(|e| e.contains("memory budget")), "{w}x{h} comp {comp}: {merged:?}");
assert!(layer.as_ref().is_err_and(|e| e.contains("memory budget")), "{w}x{h} comp {comp}: {layer:?}");
if w == 16384 {
assert!(default_budget.as_ref().is_err_and(|e| e.contains("memory budget")), "comp {comp}: {default_budget:?}");
}
}
}