Files
effectcraft/crates/psd/src/tests.rs
T
Brandon ThomasandClaude Opus 5.5 dc573f77e6 Never crash: PSD reader bounds overflow and input-sized allocations
Layer bounds come straight from the file as four i32s. Rect::width/height subtracted them in
i32 (overflow: a panic in debug builds, garbage sizes in release), and layer_pixels allocated
width x height pixels before reading any data, so a 100-byte file claiming a 2^31-square layer
hit "capacity overflow" (or an out-of-memory abort). Widths are now computed in i64 and
clamped, every decoded image is capped at 2^28 pixels (Error::Unsupported beyond that), zlib
data is inflated with a limit of the expected size, RLE count tables and UTF-16 strings
reserve no more than the remaining bytes, and the per-pixel copy only visits the part of the
layer that lands in the output. Denies the panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:27:18 -04:00

255 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::write::*;
use super::*;
fn gradient(w: u32, h: u32) -> Vec<[f32; 4]> {
(0..w * h).map(|i| [(i % w) as f32 / (w - 1).max(1) as f32, (i / w) as f32 / (h - 1).max(1) as f32, 0.25, 1.0]).collect()
}
fn close(a: &[[f32; 4]], b: &[[f32; 4]], tol: f32) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(x, y)| (0..4).all(|c| (x[c] - y[c]).abs() <= tol))
}
fn doc_with_layers(depth: u16, mode: ColorMode, rle: bool) -> WDoc {
let mut d = WDoc::new(40, 30);
d.depth = depth;
d.mode = mode;
d.rle = rle;
let mut bg = WLayer::pixels("Background", Rect::new(0, 0, 40, 30), gradient(40, 30));
bg.opacity = 255;
let mut red = WLayer::solid("Red box", Rect::new(5, 6, 10, 8), [1.0, 0.0, 0.0, 1.0]).with_blend(b"mul ").with_opacity(128);
red.hidden = true;
let mut masked = WLayer::solid("Masked", Rect::new(20, 10, 12, 12), [0.0, 0.5, 1.0, 1.0]).with_blend(b"scrn");
// Mask: left half opaque, right half transparent; default (outside) black.
let mrect = Rect::new(20, 10, 12, 12);
masked.mask = Some(WMask { rect: mrect, data: (0..144).map(|i| if i % 12 < 6 { 1.0 } else { 0.0 }).collect(), default_color: 0, disabled: false });
d.layers = vec![bg, red, masked];
d
}
#[test]
fn layers_round_trip_8bit_rle() {
let bytes = write(&doc_with_layers(8, ColorMode::Rgb, true));
assert!(is_psd(&bytes));
let p = Psd::parse(bytes).unwrap();
assert_eq!((p.width, p.height, p.depth, p.mode), (40, 30, 8, ColorMode::Rgb));
assert_eq!(p.layers.len(), 3);
let names: Vec<&str> = p.layers.iter().map(|l| l.name.as_str()).collect();
assert_eq!(names, ["Background", "Red box", "Masked"]);
assert_eq!(p.layers[1].blend_key(), "mul ");
assert_eq!(p.layers[1].opacity, 128);
assert!(p.layers[1].hidden && !p.layers[0].hidden);
assert_eq!(p.layers[2].blend_key(), "scrn");
assert_eq!(p.layers[1].rect, Rect::new(5, 6, 10, 8));
// Pixels (8-bit quantisation).
let bg = p.layer_pixels(0, false).unwrap();
assert!(close(&bg.data, &gradient(40, 30), 1.0 / 255.0));
// Retain layer size vs canvas.
let red = p.layer_pixels(1, false).unwrap();
assert_eq!((red.width, red.height), (10, 8));
let red_c = p.layer_pixels(1, true).unwrap();
assert_eq!((red_c.width, red_c.height), (40, 30));
assert_eq!(red_c.data[6 * 40 + 5], [1.0, 0.0, 0.0, 1.0]);
assert_eq!(red_c.data[0][3], 0.0);
// Layer mask becomes alpha.
let m = p.layer_pixels(2, false).unwrap();
assert_eq!(m.data[0][3], 1.0);
assert_eq!(m.data[11][3], 0.0);
// Merged composite.
let c = p.composite().unwrap();
assert_eq!((c.width, c.height), (40, 30));
assert!(p.merged_alpha);
}
#[test]
fn layers_round_trip_16bit_raw_and_cmyk_gray() {
for (depth, mode, rle) in
[(16, ColorMode::Rgb, false), (16, ColorMode::Rgb, true), (8, ColorMode::Cmyk, true), (16, ColorMode::Cmyk, false), (8, ColorMode::Grayscale, false)]
{
let bytes = write(&doc_with_layers(depth, mode, rle));
let p = Psd::parse(bytes).unwrap_or_else(|e| panic!("{depth} {mode:?} {rle}: {e}"));
assert_eq!(p.layers.len(), 3, "{depth} {mode:?}");
assert_eq!(p.depth, depth);
let bg = p.layer_pixels(0, true).unwrap();
let tol = if depth == 16 { 1.0 / 30000.0 } else { 1.5 / 255.0 };
let want: Vec<[f32; 4]> =
if mode == ColorMode::Grayscale { gradient(40, 30).iter().map(|p| [p[0], p[0], p[0], 1.0]).collect() } else { gradient(40, 30) };
assert!(close(&bg.data, &want, tol), "{depth} {mode:?} {rle}: {:?} vs {:?}", bg.data[41], want[41]);
let c = p.composite().unwrap();
assert_eq!(c.data.len(), 1200);
}
}
#[test]
fn groups_build_a_tree() {
let mut d = WDoc::new(16, 16);
d.layers = vec![
WLayer::solid("Bottom", Rect::new(0, 0, 16, 16), [1.0; 4]),
WLayer::group_end(),
WLayer::solid("Inner A", Rect::new(0, 0, 4, 4), [1.0, 0.0, 0.0, 1.0]),
WLayer::group_end(),
WLayer::solid("Deep", Rect::new(2, 2, 4, 4), [0.0, 1.0, 0.0, 1.0]),
WLayer::group("Nested", false, *b"norm"),
WLayer::group("Group 1", true, *b"pass"),
WLayer::solid("Top", Rect::new(8, 8, 4, 4), [0.0, 0.0, 1.0, 1.0]),
];
let p = Psd::parse(write(&d)).unwrap();
assert_eq!(p.layers[6].section, Section::OpenFolder);
assert_eq!(p.layers[6].blend_key(), "pass");
assert_eq!(p.layers[5].section, Section::ClosedFolder);
let t = p.tree();
assert_eq!(
t,
vec![
Node::Layer(7),
Node::Group { layer: 6, children: vec![Node::Group { layer: 5, children: vec![Node::Layer(4)] }, Node::Layer(2)] },
Node::Layer(0)
]
);
}
#[test]
fn text_effects_fill_and_adjustments() {
let mut d = WDoc::new(64, 32);
let fx = Descriptor::new("null").with("masterFXSwitch", DValue::Bool(true)).with(
"DrSh",
DValue::Descriptor(
Descriptor::new("DrSh")
.with("enab", DValue::Bool(true))
.with("Md ", DValue::Enum("BlnM".into(), "Mltp".into()))
.with("Clr ", DValue::Descriptor(Descriptor::rgb([0.0, 0.0, 1.0])))
.with("Opct", DValue::UnitFloat("#Prc".into(), 60.0))
.with("Dstn", DValue::UnitFloat("#Pxl".into(), 7.0)),
),
);
d.layers = vec![
WLayer::empty("Fill", vec![solid_color_block([0.0, 1.0, 0.0])]),
WLayer::empty("Title", vec![text_block("Hello\nWorld", [10.0, 20.0], "Inter-Bold", 18.0, [1.0, 0.0, 0.0], 2)]).with_block(effects_block(&fx)),
WLayer::empty("Hue", vec![hue_sat_block(30, -20, 10)]),
WLayer::empty("Lev", vec![levels_block(10, 240, 5, 250, 1.2)]),
WLayer::empty("Inv", vec![invert_block()]),
WLayer::empty("BC", vec![brightness_block(20, -10)]),
WLayer::empty("Exp", vec![exposure_block(1.5, 0.0, 1.0)]),
WLayer::solid("Vec", Rect::new(0, 0, 64, 32), [1.0; 4]).with_block(vector_mask_block(
&[vec![[[8.0, 4.0]; 3], [[40.0, 4.0]; 3], [[40.0, 20.0]; 3]]],
64,
32,
false,
)),
];
let p = Psd::parse(write(&d)).unwrap();
assert_eq!(p.layers[0].fill, Some([0.0, 1.0, 0.0]));
let fill = p.layer_pixels(0, false).unwrap();
assert_eq!((fill.width, fill.height), (64, 32));
assert_eq!(fill.data[5], [0.0, 1.0, 0.0, 1.0]);
let t = p.layers[1].text.as_ref().unwrap();
assert_eq!(t.text, "Hello\nWorld");
assert_eq!(t.transform[4..], [10.0, 20.0]);
assert_eq!(t.style.font.as_deref(), Some("Inter-Bold"));
assert_eq!(t.style.size, Some(18.0));
assert_eq!(t.style.color, Some([1.0, 0.0, 0.0]));
assert_eq!(t.style.justification, Some(2));
let e = p.layers[1].effects.as_ref().unwrap();
let ds = e.obj("DrSh").unwrap();
assert_eq!(ds.enum_value("Md "), Some("Mltp"));
assert_eq!(ds.num("Dstn"), Some(7.0));
assert_eq!(p.layers[2].adjustment, Some(Adjustment::HueSaturation { hue: 30.0, saturation: -20.0, lightness: 10.0, colorize: false }));
assert_eq!(p.layers[3].adjustment, Some(Adjustment::Levels { in_black: 10.0, in_white: 240.0, gamma: 1.2, out_black: 5.0, out_white: 250.0 }));
assert_eq!(p.layers[4].adjustment, Some(Adjustment::Invert));
assert_eq!(p.layers[5].adjustment, Some(Adjustment::BrightnessContrast { brightness: 20.0, contrast: -10.0, legacy: true }));
assert_eq!(p.layers[6].adjustment, Some(Adjustment::Exposure { exposure: 1.5, offset: 0.0, gamma: 1.0 }));
let vm = p.layers[7].vector_mask.as_ref().unwrap();
assert_eq!(vm.subpaths.len(), 1);
assert!(vm.subpaths[0].closed);
let k = &vm.subpaths[0].knots;
assert_eq!(k.len(), 3);
assert!((k[1][1][0] - 40.0).abs() < 1e-4 && (k[2][1][1] - 20.0).abs() < 1e-4);
}
#[test]
fn packbits_round_trip() {
for src in [vec![], vec![1u8], vec![5; 300], (0..=255).collect::<Vec<u8>>(), vec![1, 1, 2, 3, 3, 3, 4, 5, 5]] {
let enc = pack_bits(&src);
let mut out = vec![];
unpack_bits(&enc, src.len(), &mut out).unwrap();
assert_eq!(out, src);
}
}
#[test]
fn truncated_and_garbage_input_errors() {
let bytes = write(&doc_with_layers(8, ColorMode::Rgb, true));
for n in [0, 3, 10, 26, 40, 100, bytes.len() / 2] {
let r = Psd::parse(bytes[..n].to_vec());
if let Ok(p) = r {
let _ = p.composite();
}
}
assert!(Psd::parse(b"8BPS\0\x09garbage".to_vec()).is_err());
assert!(Psd::parse(b"PNG".to_vec()).is_err());
}
#[test]
fn smart_objects_and_embedded_files() {
let mut inner = WDoc::new(20, 10);
inner.layers = vec![WLayer::solid("Red", Rect::new(0, 0, 20, 10), [1.0, 0.0, 0.0, 1.0])];
let inner_bytes = write(&inner);
let mut d = WDoc::new(64, 48);
// The content scaled 2× and rotated 90° clockwise: top-left at (40, 4).
let quad = [[40.0, 4.0], [40.0, 44.0], [20.0, 44.0], [20.0, 4.0]];
d.layers = vec![
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
WLayer::solid("Placed", Rect::new(20, 4, 20, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block("uuid-1", quad, [20.0, 10.0])),
];
d.linked = vec![("uuid-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes.clone())];
let p = Psd::parse(write(&d)).unwrap();
assert_eq!(p.linked.len(), 1);
assert_eq!(p.linked[0].name, "inner.psd");
assert_eq!(p.linked[0].file_type, "8BPS");
assert_eq!(p.linked_data("uuid-1").unwrap(), &inner_bytes[..]);
let so = p.layers[1].smart_object.clone().unwrap();
assert_eq!(so.uuid, "uuid-1");
assert_eq!(so.quad, quad);
assert_eq!(so.size, Some([20.0, 10.0]));
let (c, sx, sy, rot) = so.placement(20.0, 10.0);
assert_eq!(c, [30.0, 24.0]);
assert!((sx - 2.0).abs() < 1e-9 && (sy - 2.0).abs() < 1e-9 && (rot - 90.0).abs() < 1e-9, "{sx} {sy} {rot}");
// The embedded document parses on its own.
let e = Psd::parse(p.linked_data("uuid-1").unwrap().to_vec()).unwrap();
assert_eq!((e.width, e.height), (20, 10));
assert!(p.layers[0].smart_object.is_none());
}
/// Replace the first layer record's bounds (`top, left, bottom, right`) in a written document.
fn patch_layer_rect(bytes: &mut [u8], from: Rect, to: Rect) {
let enc = |r: Rect| [r.top, r.left, r.bottom, r.right].iter().flat_map(|v| v.to_be_bytes()).collect::<Vec<u8>>();
let (old, new) = (enc(from), enc(to));
let at = bytes.windows(16).position(|w| w == old.as_slice()).expect("layer rect in the written file");
bytes[at..at + 16].copy_from_slice(&new);
}
#[test]
fn extreme_layer_bounds_error_instead_of_overflowing() {
// A layer spanning the whole i32 range: its width overflowed i32 subtraction.
let r = Rect::new(2, 3, 4, 5);
let mut d = WDoc::new(8, 8);
d.layers = vec![WLayer::solid("L", r, [1.0, 0.0, 0.0, 1.0])];
let mut bytes = write(&d);
patch_layer_rect(&mut bytes, r, Rect { top: i32::MIN, left: i32::MIN, bottom: i32::MAX, right: i32::MAX });
let p = Psd::parse(bytes).unwrap();
assert_eq!(p.layers[0].rect.width(), u32::MAX);
assert!(p.layer_pixels(0, false).is_err());
assert!(p.layer_pixels(0, true).is_err());
}
#[test]
fn huge_layer_bounds_error_instead_of_allocating() {
// A few bytes claiming a 2-billion-pixel-square layer must not try to allocate it.
let r = Rect::new(2, 3, 4, 5);
let mut d = WDoc::new(8, 8);
d.layers = vec![WLayer::solid("L", r, [1.0, 0.0, 0.0, 1.0])];
let mut bytes = write(&d);
patch_layer_rect(&mut bytes, r, Rect { top: 0, left: 0, bottom: i32::MAX, right: i32::MAX });
let p = Psd::parse(bytes).unwrap();
assert!(matches!(p.layer_pixels(0, false), Err(Error::Unsupported(_))));
}