Files
photocraft/crates/codecs/tests/it/limits.rs
T
c65b7d93d6 Integration: file-size bound fits DWA EXR, RLE BMP and GIF screens (#8704)
The new plausibility check refused valid files that main opens:
- EXR DWAA/DWAB parts: they go past 2^16:1 (a flat 8192x4320 DWAB frame
  with 12 alpha-scheme mattes is 29,948 bytes, 75,629:1; run-length
  channels zlib-packed on top reach about 132,000:1). DWA parts now get
  2^18:1, so a header of a few hundred bytes can ask for about 100 MB,
  not gigabytes.
- BMP RLE8/RLE4: end-of-line, end-of-bitmap and delta escapes leave pixels
  unwritten, so no ratio bounds a valid file (an EOB-only 4000x4000 RLE8 is
  1,080 bytes). Skipped like HDR.
- GIF: the decoder's size is the logical screen, which needs no data (a
  4000x4000 screen with a 1x1 frame is 35 bytes). The first frame must be
  backed, and the screen only past 256 MiB, so a 35-byte GIF still can't
  declare a 30000x30000 screen.
The declared-size products saturate: a Radiance header of 2^31 x 2^31
read without limits overflowed u64 in debug builds.

Co-authored-by: FinnNk <1424666+FinnNk@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:46:11 +01:00

641 lines
26 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.
use crate::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 png_header_at_the_default_limit_needs_a_plausible_file_size() {
// 32768 x 32768 x RGBA16 is exactly the default max_alloc: it passes the dimension limits,
// but a ~100 byte file cannot hold it (zlib tops out near 1032:1), so it is refused before
// the 8 GiB output buffer is allocated.
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&32768u32.to_be_bytes());
ihdr.extend_from_slice(&32768u32.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 png_highly_compressible_image_still_decodes() {
let img = Image::from_u8(3000, 3000, ChannelLayout::Rgba, vec![255u8; 3000 * 3000 * 4]).unwrap();
let b = encode(&img, Format::Png, &EncodeOptions::default()).unwrap();
assert!(b.len() < 100_000, "{}", b.len());
assert_eq!(decode(&b).unwrap().width(), 3000);
}
const PNG_TEXT_BUDGET: usize = 64 << 10;
fn png_text_decode(bytes: &[u8], max_alloc: usize) -> Result<Image, CodecError> {
decode_with(bytes, &opts(Limits { max_alloc: max_alloc as u64, ..Limits::default() }))
}
fn zlib(text: &[u8]) -> Vec<u8> {
use std::io::Write;
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::fast());
encoder.write_all(text).unwrap();
encoder.finish().unwrap()
}
// zTXt and iTXt payloads supplied here are already compressed.
fn png_text_stream(ty: &[u8; 4], keyword: &[u8], payload: &[u8]) -> Vec<u8> {
let mut data = keyword.to_vec();
data.push(0);
match ty {
b"tEXt" => {}
b"zTXt" => data.push(0),
b"iTXt" => data.extend_from_slice(&[1, 0, 0, 0]),
_ => panic!("unexpected text chunk"),
}
data.extend_from_slice(payload);
png_chunk(ty, &data)
}
fn png_text(ty: &[u8; 4], keyword: &[u8], text: &[u8]) -> Vec<u8> {
if ty == b"tEXt" { png_text_stream(ty, keyword, text) } else { png_text_stream(ty, keyword, &zlib(text)) }
}
fn png_with_text(before_idat: &[Vec<u8>], after_idat: &[Vec<u8>]) -> Vec<u8> {
let mut image = Vec::new();
{
let mut encoder = png::Encoder::new(&mut image, 1, 1);
encoder.set_color(png::ColorType::Grayscale);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&[127]).unwrap();
writer.finish().unwrap();
}
let mut at = 8;
let mut idat = None;
let iend = loop {
let len = u32::from_be_bytes(image[at..at + 4].try_into().unwrap()) as usize;
let ty = &image[at + 4..at + 8];
if ty == b"IDAT" && idat.is_none() {
idat = Some(at);
}
if ty == b"IEND" {
break at;
}
at += len + 12;
};
let idat = idat.unwrap();
let mut output = image[..idat].to_vec();
for chunk in before_idat {
output.extend_from_slice(chunk);
}
output.extend_from_slice(&image[idat..iend]);
for chunk in after_idat {
output.extend_from_slice(chunk);
}
output.extend_from_slice(&image[iend..]);
output
}
#[test]
fn png_compressed_text_bombs_obey_limits_before_and_after_idat() {
let bomb = vec![b'A'; PNG_TEXT_BUDGET * 8];
for ty in [b"zTXt", b"iTXt"] {
for after_idat in [false, true] {
let chunks = [png_text(ty, b"Comment", &bomb)];
let bytes = if after_idat { png_with_text(&[], &chunks) } else { png_with_text(&chunks, &[]) };
assert!(bytes.len() < PNG_TEXT_BUDGET / 2, "fixture must fit comfortably within the compressed-input budget");
assert!(is_limit(png_text_decode(&bytes, PNG_TEXT_BUDGET)), "{ty:?}, after IDAT={after_idat}");
}
}
}
#[test]
fn png_text_chunks_share_an_aggregate_budget() {
for text_len in [(PNG_TEXT_BUDGET - 3) / 3, PNG_TEXT_BUDGET / 3] {
let text = vec![b'A'; text_len];
let before = [png_text(b"tEXt", b"A", &text), png_text(b"zTXt", b"B", &text)];
let after = [png_text(b"iTXt", b"C", &text)];
let result = png_text_decode(&png_with_text(&before, &after), PNG_TEXT_BUDGET);
if 3 * (text_len + 1) <= PNG_TEXT_BUDGET {
let image = result.unwrap();
assert_eq!(image.meta.text.len(), 3);
assert!(image.meta.text.iter().all(|(_, value)| value.len() == text_len));
} else {
assert!(is_limit(result), "chunks fitting individually must still share a budget");
}
}
}
#[test]
fn png_text_accepts_the_exact_utf8_budget() {
for ty in [b"tEXt", b"zTXt", b"iTXt"] {
let text = vec![b'A'; PNG_TEXT_BUDGET - 1];
let bytes = png_with_text(&[png_text(ty, b"K", &text)], &[]);
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.text, vec![("K".into(), String::from_utf8(text).unwrap())]);
assert!(is_limit(png_text_decode(&bytes, PNG_TEXT_BUDGET - 1)), "{ty:?}: keyword and text both count");
}
}
#[test]
fn png_plain_itxt_obeys_the_exact_budget_and_validates_fields() {
let mut payload = b"K\0\0\0en\0Keyword\0".to_vec();
payload.extend_from_slice(&vec![b'A'; PNG_TEXT_BUDGET - 1]);
let bytes = png_with_text(&[], &[png_chunk(b"iTXt", &payload)]);
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.text, vec![("K".into(), "A".repeat(PNG_TEXT_BUDGET - 1))]);
assert!(is_limit(png_text_decode(&bytes, PNG_TEXT_BUDGET - 1)));
let invalid_fields: &[&[u8]] = &[
b"K\0\0\0\0\0\xFF", // Invalid text UTF-8.
b"K\0\0\0\xFF\0\0text", // Non-ASCII language tag.
b"K\0\0\0\0\xFF\0text", // Invalid translated-keyword UTF-8.
b"K\0\x02\0\0\0text", // Unknown compression flag.
b"K\0\x01\x01\0\0text", // Unknown compression method for compressed text.
b"K\0\0", // Missing compression method.
b"K\0\0\0\0text", // Missing translated-keyword separator.
];
for fields in invalid_fields {
let bytes = png_with_text(&[png_chunk(b"iTXt", fields)], &[png_text(b"tEXt", b"Good", b"retained")]);
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.text, vec![("Good".into(), "retained".into())]);
}
}
#[test]
fn png_latin1_expansion_counts_keyword_and_text_utf8_bytes() {
let text_len = (PNG_TEXT_BUDGET - 2) / 2;
for ty in [b"tEXt", b"zTXt", b"iTXt"] {
let text = if ty == b"iTXt" { "é".repeat(text_len).into_bytes() } else { vec![0xE9; text_len] };
let bytes = png_with_text(&[], &[png_text(ty, &[0xE9], &text)]);
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.text, vec![("é".into(), "é".repeat(text_len))]);
assert!(is_limit(png_text_decode(&bytes, PNG_TEXT_BUDGET - 1)), "{ty:?}: Latin-1 expands when stored as UTF-8");
}
}
#[test]
fn png_duplicate_xmp_chunks_each_consume_budget() {
const KEYWORD: &[u8] = b"XML:com.adobe.xmp";
let text_len = (PNG_TEXT_BUDGET - 2 * KEYWORD.len()) / 2;
for extra in [0, 1] {
let first = png_text(b"iTXt", KEYWORD, &vec![b'A'; text_len + extra]);
let last_text = vec![b'B'; text_len];
let last = png_text(b"iTXt", KEYWORD, &last_text);
let result = png_text_decode(&png_with_text(&[first], &[last]), PNG_TEXT_BUDGET);
if extra == 0 {
let image = result.unwrap();
assert_eq!(image.meta.xmp, Some(String::from_utf8(last_text).unwrap()));
assert!(image.meta.text.is_empty());
} else {
assert!(is_limit(result), "replacing an XMP packet must not reset its budget charge");
}
}
}
#[test]
fn png_xmp_in_all_text_chunk_types_is_preserved() {
const XMP: &str = "<x:xmpmeta xmlns:x='adobe:ns:meta/'/>";
for ty in [b"tEXt", b"zTXt", b"iTXt"] {
for after_idat in [false, true] {
let chunks = [png_text(ty, b"XML:com.adobe.xmp", XMP.as_bytes())];
let bytes = if after_idat { png_with_text(&[], &chunks) } else { png_with_text(&chunks, &[]) };
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.xmp.as_deref(), Some(XMP), "{ty:?}, after IDAT: {after_idat}");
assert!(image.meta.text.is_empty(), "XMP must not also be retained as ordinary text");
let saved = encode(&image, Format::Png, &EncodeOptions::default()).unwrap();
assert_eq!(decode(&saved).unwrap().meta.xmp, image.meta.xmp);
}
}
}
#[test]
fn png_mixed_xmp_chunk_types_use_last_packet_and_share_budget() {
for first_ty in [b"tEXt", b"zTXt", b"iTXt"] {
for last_ty in [b"tEXt", b"zTXt", b"iTXt"] {
let first = png_text(first_ty, b"XML:com.adobe.xmp", b"first");
let last = png_text(last_ty, b"XML:com.adobe.xmp", b"last");
let bytes = png_with_text(&[first], &[last]);
let budget = 2 * b"XML:com.adobe.xmp".len() + b"first".len() + b"last".len();
// Pixel decoding needs a larger limit, so check the shared budget with larger packets.
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.xmp.as_deref(), Some("last"));
assert!(image.meta.text.is_empty());
let first = png_text(first_ty, b"XML:com.adobe.xmp", &vec![b'A'; PNG_TEXT_BUDGET - budget]);
let last = png_text(last_ty, b"XML:com.adobe.xmp", b"last");
let bytes = png_with_text(&[first], &[last]);
assert!(png_text_decode(&bytes, PNG_TEXT_BUDGET - b"first".len()).is_ok());
assert!(is_limit(png_text_decode(&bytes, PNG_TEXT_BUDGET - b"first".len() - 1)));
}
}
}
#[test]
fn png_corrupt_text_is_still_skipped() {
let valid = zlib(b"discard");
let mut bad_header = valid.clone();
bad_header[0] = 0;
let truncated = valid[..valid.len() - 4].to_vec();
for ty in [b"zTXt", b"iTXt"] {
for stream in [&bad_header, &truncated] {
let before = [png_text_stream(ty, b"Bad", stream)];
let after = [png_text(b"tEXt", b"Good", b"retained")];
let image = png_text_decode(&png_with_text(&before, &after), PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.text, vec![("Good".into(), "retained".into())]);
}
}
let invalid_utf8 = png_text(b"iTXt", b"Bad", &[0xFF]);
let image = png_text_decode(&png_with_text(&[], &[invalid_utf8]), PNG_TEXT_BUDGET).unwrap();
assert!(image.meta.text.is_empty());
}
#[test]
fn png_text_with_bad_crc_and_text_after_iend_are_ignored() {
let bomb = vec![b'A'; PNG_TEXT_BUDGET * 8];
for ty in [b"zTXt", b"iTXt"] {
let valid = png_text(ty, b"Comment", &bomb);
let mut bad_crc = valid.clone();
*bad_crc.last_mut().unwrap() ^= 1;
for after_idat in [false, true] {
let bytes = if after_idat { png_with_text(&[], &[bad_crc.clone()]) } else { png_with_text(&[bad_crc.clone()], &[]) };
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert!(image.meta.text.is_empty());
}
let mut bytes = png_with_text(&[], &[]);
bytes.extend_from_slice(&valid);
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert!(image.meta.text.is_empty());
}
}
#[test]
fn png_malformed_chunk_tails_remain_tolerated_after_pixels() {
let base = png_with_text(&[png_text(b"tEXt", b"Good", b"retained")], &[png_chunk(b"vpAg", &[])]);
let iend = base.len() - 12;
let tails: &[&[u8]] = &[&[], b"\x00\x00\x00", b"\x00\x00\x00\x10iTXtK\0\0", b"\xFF\xFF\xFF\xFFiTXt"];
for tail in tails {
let mut bytes = base[..iend].to_vec();
bytes.extend_from_slice(tail);
let image = png_text_decode(&bytes, PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.dimensions(), (1, 1));
assert_eq!(image.meta.text, vec![("Good".into(), "retained".into())]);
}
}
#[test]
fn png_invalid_optional_text_does_not_spend_an_exhausted_budget() {
let full = png_text(b"tEXt", b"K", &vec![b'A'; PNG_TEXT_BUDGET - 1]);
let invalid = [png_chunk(b"iTXt", b"Bad\0\0\0\0\0\xFF"), png_text_stream(b"zTXt", b"Bad", b"invalid")];
let image = png_text_decode(&png_with_text(&[full], &invalid), PNG_TEXT_BUDGET).unwrap();
assert_eq!(image.meta.text.len(), 1);
assert_eq!(image.meta.text[0].1.len(), PNG_TEXT_BUDGET - 1);
}
#[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 hdr_header_sizes_that_overflow_u64_are_errors_without_limits() {
let b = b"#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 2147483648 +X 2147483648\n";
assert!(decode_with(b, &opts(Limits::none())).is_err());
}
#[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());
}
/// #8597: a small file whose header declares about 3 GB, inside the default limits, is refused
/// by the size-against-file check before the decoder allocates its output.
fn refused_as_implausible(f: Format, b: &[u8]) {
match decode(b) {
Err(CodecError::LimitExceeded(m)) => assert!(m.contains("cannot hold"), "{f:?}: {m}"),
other => panic!("{f:?}: expected an implausible-size refusal, got {:?}", other.map(|i| (i.width(), i.height()))),
}
}
fn find(b: &[u8], needle: &[u8]) -> usize {
b.windows(needle.len()).position(|w| w == needle).unwrap()
}
#[test]
fn jpeg_header_declaring_gigabytes_needs_a_plausible_file_size() {
let mut b = encoded(Format::Jpeg);
// SOF0: marker, length, precision, then height and width (big-endian).
let sof = find(&b, &[0xFF, 0xC0]);
b[sof + 5..sof + 7].copy_from_slice(&16000u16.to_be_bytes());
b[sof + 7..sof + 9].copy_from_slice(&65000u16.to_be_bytes());
// The decoder's own scan-size guard refuses it (one refusal path for truncated and complete
// files alike), before the backend allocates the frame.
match decode(&b) {
Err(e) => assert!(e.to_string().contains("too short for the frame"), "{e}"),
Ok(i) => panic!("decoded a {}×{} frame from {} bytes", i.width(), i.height(), b.len()),
}
}
#[test]
fn image_crate_headers_declaring_gigabytes_need_a_plausible_file_size() {
let side = 30000u32;
let mut bmp = encoded(Format::Bmp);
bmp[18..22].copy_from_slice(&(side as i32).to_le_bytes());
bmp[22..26].copy_from_slice(&(side as i32).to_le_bytes());
refused_as_implausible(Format::Bmp, &bmp);
let mut qoi = encoded(Format::Qoi);
// The qoi crate refuses more than 400 M pixels itself, so 20000 x 20000 (1.6 GB).
qoi[4..8].copy_from_slice(&20000u32.to_be_bytes());
qoi[8..12].copy_from_slice(&20000u32.to_be_bytes());
refused_as_implausible(Format::Qoi, &qoi);
let mut gif = encoded(Format::Gif);
gif[6..8].copy_from_slice(&(side as u16).to_le_bytes());
gif[8..10].copy_from_slice(&(side as u16).to_le_bytes());
refused_as_implausible(Format::Gif, &gif);
// The frame too. Descriptor: separator, left, top, width, height.
let frame = find(&gif, &[0x2C, 0, 0, 0, 0, 64, 0, 48, 0]);
gif[frame + 5..frame + 7].copy_from_slice(&(side as u16).to_le_bytes());
gif[frame + 7..frame + 9].copy_from_slice(&(side as u16).to_le_bytes());
refused_as_implausible(Format::Gif, &gif);
}
/// GIF89a with a two-colour global table and one frame at the top left holding no pixels or
/// one pixel (LZW minimum code size 2: clear, index 0, end of information).
fn tiny_gif(screen: (u16, u16), frame: (u16, u16)) -> Vec<u8> {
let mut b = b"GIF89a".to_vec();
for v in [screen.0, screen.1] {
b.extend_from_slice(&v.to_le_bytes());
}
b.extend_from_slice(&[0x80, 0, 0, 0, 0, 0, 255, 255, 255, 0x2C, 0, 0, 0, 0]);
for v in [frame.0, frame.1] {
b.extend_from_slice(&v.to_le_bytes());
}
b.extend_from_slice(&[0, 2, 2, 0x44, 0x01, 0, 0x3B]);
b
}
#[test]
fn a_gif_frame_much_smaller_than_its_screen_still_decodes() {
let img = decode(&tiny_gif((4000, 4000), (1, 1))).unwrap();
assert_eq!((img.width(), img.height()), (4000, 4000));
}
#[test]
fn a_tiny_gif_frame_does_not_admit_a_gigabyte_screen() {
refused_as_implausible(Format::Gif, &tiny_gif((30000, 30000), (1, 1)));
// 324 MB of screen: past the 256 MiB that needs no data.
refused_as_implausible(Format::Gif, &tiny_gif((9000, 9000), (1, 1)));
refused_as_implausible(Format::Gif, &tiny_gif((30000, 30000), (0, 0)));
// A frame bigger than its screen is decoded at its own size.
refused_as_implausible(Format::Gif, &tiny_gif((100, 100), (40000, 40000)));
}
#[test]
fn an_rle_bmp_ending_early_still_decodes() {
// 4000x4000 RLE8 whose only data is the end-of-bitmap escape: every pixel is left at index 0.
let data = 14 + 40 + 1024;
let mut b = b"BM".to_vec();
for v in [data + 2, 0, data, 40, 4000, 4000] {
b.extend_from_slice(&(v as u32).to_le_bytes());
}
b.extend_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&8u16.to_le_bytes());
for v in [1, 2, 0, 0, 0, 0] {
b.extend_from_slice(&(v as u32).to_le_bytes());
}
b.resize(data, 0);
b.extend_from_slice(&[0, 1]);
let img = decode(&b).unwrap();
assert_eq!((img.width(), img.height()), (4000, 4000));
}
#[test]
fn pcx_header_declaring_gigabytes_needs_a_plausible_file_size() {
// 24-bit (three 8-bit planes) 65535 x 16384, with one row of data.
let mut b = vec![0u8; 128];
b[0] = 0x0a;
b[1] = 5;
b[2] = 1;
b[3] = 8;
b[8..10].copy_from_slice(&65534u16.to_le_bytes());
b[10..12].copy_from_slice(&16383u16.to_le_bytes());
b[65] = 3;
b[66..68].copy_from_slice(&65535u16.to_le_bytes());
b[68..70].copy_from_slice(&1u16.to_le_bytes());
b.extend_from_slice(&[0xff, 0]);
// PCX's own minimum-packet check refuses it before the output is allocated.
match decode(&b) {
Err(CodecError::Malformed { message, .. }) => assert!(message.contains("too short"), "{message}"),
other => panic!("expected a too-short refusal, got {:?}", other.map(|i| (i.width(), i.height()))),
}
}
#[test]
fn xpm_rows_shorter_than_the_header_declares_are_refused_before_allocating() {
let b = b"/* XPM */\nstatic char *x[] = {\n\"30000 30000 1 1\",\n\". c #000000\",\n\".\",\n};\n".to_vec();
match decode(&b) {
Err(CodecError::Malformed { .. }) => {}
other => panic!("expected a malformed-row error, got {:?}", other.map(|i| (i.width(), i.height()))),
}
}
#[test]
fn exr_header_declaring_gigabytes_needs_a_plausible_file_size() {
let mut b = encoded(Format::OpenExr);
// dataWindow: name, type, size, then xMin, yMin, xMax, yMax (little-endian i32).
let at = find(&b, b"dataWindow\0box2i\0") + b"dataWindow\0box2i\0".len() + 4;
b[at + 8..at + 12].copy_from_slice(&15999i32.to_le_bytes());
b[at + 12..at + 16].copy_from_slice(&15999i32.to_le_bytes());
refused_as_implausible(Format::OpenExr, &b);
}
#[test]
fn a_dwab_exr_gets_a_looser_file_size_bound() {
// DWA passes 2^16:1 (a flat 8K frame with 12 alpha-scheme mattes is about 30 KB), so a DWAB
// part may declare 4 times more than a ZIP part from the same file, and no more.
let img = synth(1, 1, ChannelLayout::Rgba, SampleType::U8, 1, 0.1);
let mut b = encode(&img, Format::OpenExr, &EncodeOptions::default()).unwrap();
let at = find(&b, b"dataWindow\0box2i\0") + b"dataWindow\0box2i\0".len() + 4;
let c = find(&b, b"compression\0compression\0") + b"compression\0compression\0".len() + 4;
// RGBA as f32: 16 bytes a pixel against a file of a few hundred bytes.
let size = |b: &mut Vec<u8>, side: i32| {
b[at + 8..at + 12].copy_from_slice(&(side - 1).to_le_bytes());
b[at + 12..at + 16].copy_from_slice(&(side - 1).to_le_bytes());
};
let refused = |b: &[u8]| matches!(exr_info(b, &Limits::default()), Err(CodecError::LimitExceeded(m)) if m.contains("cannot hold"));
size(&mut b, 2048);
b[c] = 3; // ZIP
assert!(refused(&b), "{} bytes", b.len());
for dwa in [8, 9] {
b[c] = dwa; // DWAA, DWAB
let parts = exr_info(&b, &Limits::default()).unwrap();
assert_eq!((parts[0].width, parts[0].height), (2048, 2048));
}
size(&mut b, 4096);
assert!(refused(&b), "{} bytes", b.len());
}
#[test]
fn highly_compressible_jpeg_and_gif_still_decode() {
let img = Image::from_u8(3000, 3000, ChannelLayout::Rgba, vec![255u8; 3000 * 3000 * 4]).unwrap();
for f in [Format::Jpeg, Format::Gif, Format::Qoi, Format::OpenExr] {
let b = encode(&img, f, &EncodeOptions::default()).unwrap();
assert_eq!(decode(&b).unwrap().width(), 3000, "{f:?}");
}
}