Files
photocraft/crates/codecs/tests/limits.rs
T
kayrockscreenprintingandClaude Opus 5.5 9af9df8275 PNM: the file, not the header, bounds what a decode reserves (#1110) (#1365)
ASCII PGM/PPM reserved a u32 (four bytes) per declared sample before
reading any, against a Limits budget of one or two bytes per sample, so a
19-byte header could ask for 12 GiB. With Limits::none() a 2³¹ × 2³¹
header panics with "capacity overflow"; PBM reserved 2⁶² bytes before
finding the raster missing and aborted. ASCII reservations are now capped
by what the remaining bytes can hold (a PBM digit is at least a byte, a
PGM/PPM sample at least a digit and a separator), and binary PBM checks
the raster length before reserving.

Limits::none() documents that it is still bounded by usize overflow
checks, but the decoder multiplied w × h × channels, n × 2 (16-bit
samples) and the PFM raster length unchecked. A new sample_count checks
the product, and the length checks use checked_mul.

Tests: a header alone reserves nothing (P1-P4), u32::MAX sizes are errors
(P3, P5, P6, PF, P7), and ASCII files with no slack (PBM digits without
separators, no trailing whitespace) still decode. The first two failed
before the fix: an abort on the 2⁶² reservation, "capacity overflow" for
the P2/P3 headers, and "attempt to multiply with overflow".

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 07:39:45 +07:00

173 lines
6.1 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.
//! Decompression-bomb guards.
mod common;
use common::*;
use photocraft_codecs::*;
fn opts(l: Limits) -> DecodeOptions {
DecodeOptions { limits: l, ..Default::default() }
}
fn is_limit(r: Result<Image, CodecError>) -> bool {
matches!(r, Err(CodecError::LimitExceeded(_)))
}
fn encoded(f: Format) -> Vec<u8> {
let img = synth(64, 48, ChannelLayout::Rgba, SampleType::U8, 1, 0.1);
encode(&img, f, &EncodeOptions::default()).unwrap()
}
#[test]
fn max_pixels_enforced_for_every_format() {
for f in rw_formats() {
let b = encoded(f);
let l = Limits { max_pixels: 1000, ..Limits::default() };
assert!(is_limit(decode_with(&b, &opts(l))), "{f:?}");
let l = Limits { max_pixels: 64 * 48, ..Limits::default() };
assert!(decode_with(&b, &opts(l)).is_ok(), "{f:?} exact limit should pass");
}
}
#[test]
fn max_width_enforced_for_every_format() {
for f in rw_formats() {
let l = Limits { max_width: 63, ..Limits::default() };
assert!(is_limit(decode_with(&encoded(f), &opts(l))), "{f:?}");
}
}
#[test]
fn max_height_enforced_for_every_format() {
for f in rw_formats() {
let l = Limits { max_height: 47, ..Limits::default() };
assert!(is_limit(decode_with(&encoded(f), &opts(l))), "{f:?}");
}
}
#[test]
fn max_alloc_enforced_for_every_format() {
for f in rw_formats() {
let l = Limits { max_alloc: 64 * 48, ..Limits::default() };
assert!(decode_with(&encoded(f), &opts(l)).is_err(), "{f:?}");
}
}
#[test]
fn limits_none_accepts() {
for f in rw_formats() {
assert!(decode_with(&encoded(f), &opts(Limits::none())).is_ok(), "{f:?}");
}
}
fn png_chunk(ty: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut v = (data.len() as u32).to_be_bytes().to_vec();
v.extend_from_slice(ty);
v.extend_from_slice(data);
let mut crc_in = ty.to_vec();
crc_in.extend_from_slice(data);
v.extend_from_slice(&crc32(&crc_in).to_be_bytes());
v
}
fn crc32(d: &[u8]) -> u32 {
let mut c = 0xFFFF_FFFFu32;
for &b in d {
c ^= b as u32;
for _ in 0..8 {
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
}
}
!c
}
#[test]
fn png_bomb_header_rejected_before_alloc() {
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&100_000u32.to_be_bytes());
ihdr.extend_from_slice(&100_000u32.to_be_bytes());
ihdr.extend_from_slice(&[16, 6, 0, 0, 0]);
let mut b = b"\x89PNG\r\n\x1a\n".to_vec();
b.extend(png_chunk(b"IHDR", &ihdr));
b.extend(png_chunk(b"IDAT", &[0x78, 0x9C, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]));
b.extend(png_chunk(b"IEND", &[]));
assert!(is_limit(decode(&b)));
}
#[test]
fn pnm_bomb_header_rejected() {
assert!(is_limit(decode(b"P6\n200000 200000\n255\n\0\0\0")));
assert!(is_limit(decode(b"P7\nWIDTH 99999\nHEIGHT 99999\nDEPTH 4\nMAXVAL 65535\nTUPLTYPE RGB_ALPHA\nENDHDR\n")));
assert!(is_limit(decode(b"PF\n100000 100000\n-1.0\n")));
}
#[test]
fn pnm_header_alone_reserves_nothing_without_limits() {
// #1110: ASCII PGM/PPM reserved four bytes per declared sample before reading any. A
// 2³¹ × 2³¹ header asked for more than isize::MAX ("capacity overflow"), and PBM reserved
// 2⁶² bytes before finding the raster missing (an abort).
let none = opts(Limits::none());
let side = 1u32 << 31;
for f in [format!("P1\n{side} {side}\n"), format!("P2\n{side} {side}\n255\n"), format!("P3\n{side} {side}\n255\n"), format!("P4\n{side} {side}\n")] {
assert!(decode_with(f.as_bytes(), &none).is_err(), "{f:?}");
}
}
#[test]
fn pnm_header_sizes_that_overflow_usize_are_errors_without_limits() {
let m = u32::MAX;
let none = opts(Limits::none());
for f in [
format!("P3\n{m} {m}\n255\n"),
format!("P6\n{m} {m}\n255\n"),
format!("P5\n{m} {m}\n65535\n"),
format!("PF\n{m} {m}\n-1.0\n"),
format!("P7\nWIDTH {m}\nHEIGHT {m}\nDEPTH 4\nMAXVAL 255\nTUPLTYPE RGB_ALPHA\nENDHDR\n"),
] {
assert!(decode_with(f.as_bytes(), &none).is_err(), "{f:?}");
}
}
#[test]
fn ascii_pnm_with_the_tightest_layout_still_decodes() {
// The capacity bound for #1110 must not cut off files with no slack: PBM digits need no
// separator, and the last ASCII sample needs no trailing whitespace.
assert_eq!(decode(b"P1\n3 1\n010").unwrap().data(), &[255, 0, 255]);
assert_eq!(decode(b"P2\n3 1\n255\n1 2 3").unwrap().data(), &[1, 2, 3]);
assert_eq!(decode(b"P3\n1 1\n255\n9 8 7").unwrap().data(), &[9, 8, 7]);
}
#[test]
fn jpeg_bomb_header_rejected() {
// SOI + SOF0 claiming 65535x65535x3 with no scan data.
let b = [0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x11, 8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 1, 0x11, 0, 2, 0x11, 0, 3, 0x11, 0, 0xFF, 0xD9];
let l = Limits { max_pixels: 1 << 24, ..Limits::default() };
assert!(is_limit(decode_with(&b, &opts(l))));
}
#[test]
fn tiff_bomb_header_rejected() {
// Minimal little-endian TIFF claiming 1_000_000 x 1_000_000 8-bit gray.
let mut b = b"II*\0\x08\0\0\0".to_vec();
let entries: [(u16, u16, u32, u32); 6] = [(256, 4, 1, 1_000_000), (257, 4, 1, 1_000_000), (258, 3, 1, 8), (262, 3, 1, 1), (273, 4, 1, 0), (279, 4, 1, 0)];
b.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for (tag, ty, count, val) in entries {
b.extend_from_slice(&tag.to_le_bytes());
b.extend_from_slice(&ty.to_le_bytes());
b.extend_from_slice(&count.to_le_bytes());
b.extend_from_slice(&val.to_le_bytes());
}
b.extend_from_slice(&0u32.to_le_bytes());
assert!(is_limit(decode(&b)));
}
#[test]
fn limits_check_api() {
let l = Limits::default();
assert!(l.check(100, 100, ChannelLayout::Rgba, SampleType::F32).is_ok());
assert!(matches!(l.check(0, 1, ChannelLayout::Gray, SampleType::U8), Err(CodecError::InvalidImage(_))));
assert!(matches!(l.check(u32::MAX, 1, ChannelLayout::Gray, SampleType::U8), Err(CodecError::LimitExceeded(_))));
let l = Limits { max_alloc: 399, ..Limits::default() };
assert!(l.check(10, 10, ChannelLayout::Rgba, SampleType::U8).is_err());
assert!(l.check(10, 10, ChannelLayout::Rgb, SampleType::U8).is_ok());
}