mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 22:30:20 -06:00
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>
641 lines
26 KiB
Rust
641 lines
26 KiB
Rust
//! 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:?}");
|
||
}
|
||
}
|