Files
lightcraft/crates/codecs/tests/fuzz.rs
T
Claude c2cdb112b7 LR-IMP-FORMATS: GIF and BMP sizes from the header; a canvas the file can't fill is refused (#2553)
read_header had no reader for GIF or BMP and fell back to a full decode, so a few-dozen-byte file
declaring a canvas just under the 1-gigapixel limit made the import probe allocate ~4.3 GB; a
failed allocation aborts the process. A decode had the same allocation.

- other::header reads the size from the header. A canvas up to 4 MP is still decoded there, so
  truncated small files are refused at import as before.
- check_fillable refuses a canvas larger than the file's bytes can describe (BMP: 128 pixels per
  byte, covering 1-bit and RLE; GIF: 11,000, an LZW code of at least 3 bits expanding to at most
  4096 pixels), in both the probe and the decode, before the decoder allocates.
- The GIF and BMP decoders get an allocation limit (the RGBA buffer of max_pixels).

The approach follows the abandoned #2182 (PierrunoYT). JPEG XL still decodes in the probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K93HTyCiwYvbRGaAkjPvXn
2026-10-22 10:55:54 -07:00

193 lines
8.5 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.
//! Decoders must never panic on malformed input: random bytes behind every magic number, and
//! mutated/truncated valid files. Uses `decode_unguarded` so panics in our own code are not masked
//! by the public entry point's panic guard.
use lightcraft_codecs::exif::minimal_exif;
use lightcraft_codecs::icc::write_named;
use lightcraft_codecs::*;
use lightcraft_raster::Rgba8;
use proptest::prelude::*;
use std::sync::OnceLock;
const MAGICS: &[&[u8]] = &[
b"\xFF\xD8\xFF",
b"\x89PNG\r\n\x1a\n",
b"II*\0",
b"MM\0*",
b"RIFF\0\0\0\0WEBP",
b"GIF89a",
b"BM\0\0\0\0\0\0\0\0\0\0\0\0\x28\0\0\0",
b"8BPS\0\x01",
b"\xFF\x0A",
b"\0\0\0\x0CJXL \x0D\x0A\x87\x0A",
];
fn small_opts() -> DecodeOptions {
DecodeOptions { max_size: None, max_pixels: 1 << 22 }
}
fn try_all(bytes: &[u8]) {
if let Some(f) = sniff(bytes) {
let _ = decode_unguarded(bytes, f, &small_opts());
let _ = decode_unguarded(bytes, f, &DecodeOptions { max_size: Some((8, 8)), max_pixels: 1 << 22 });
let _ = read_header_unguarded(bytes, f);
}
let _ = decode(bytes, small_opts());
let _ = read_header(bytes);
let _ = decode_thumbnail_with(bytes, &ThumbnailOptions { max_pixels: small_opts().max_pixels, ..ThumbnailOptions::new(16) });
let _ = lightcraft_codecs::icc::parse(bytes);
let _ = lightcraft_codecs::gainmap::read_jpeg(bytes);
let _ = lightcraft_codecs::gainmap::is_gain_map_jpeg(bytes);
}
fn gain_map_jpeg(meta: &EncodeMeta) -> Vec<u8> {
use lightcraft_codecs::gainmap::{GainMapOptions, compute, encode_jpeg};
let hdr: Vec<[f32; 3]> = (0..24 * 16).map(|i| [i as f32 * 0.02; 3]).collect();
let sdr: Vec<[f32; 3]> = hdr.iter().map(|p| p.map(|v| v / (1.0 + v))).collect();
let (g, m) = compute(&sdr, &hdr, 24, 16, [0.2126, 0.7152, 0.0722], &GainMapOptions::default()).unwrap();
let base: Vec<u8> = sdr.iter().flat_map(|p| p.map(|v| (v * 255.0) as u8)).collect();
encode_jpeg(&EncodeImage::new(24, 16, 3, Samples::U8(&base)), &g, &m, 80, ChromaSubsampling::S420, meta).unwrap()
}
#[test]
fn webp_truncated_metadata_cannot_allocate_declared_chunk_size() {
let mut bytes = seeds()[5].clone();
let tag = bytes.windows(4).rposition(|w| w == b"XMP ").unwrap();
bytes[tag + 4..tag + 8].copy_from_slice(&1_946_157_060u32.to_le_bytes());
let decoded = decode(&bytes, small_opts()).unwrap();
assert_eq!((decoded.image.width, decoded.image.height), (24, 16));
assert!(decoded.xmp.is_none(), "invalid optional metadata is ignored");
}
#[test]
fn thumbnail_rejects_huge_gif_before_allocating_source_pixels() {
// A logical screen header without a global palette; previously this reached a
// multi-gigabyte output allocation before discovering that no frame follows.
let bytes = [
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x8f, 0xa4, 0xb0, 0x3f, 0x57, 0x14, 0xe8, 0x8a, 0xf8, 0xe0, 0xc7, 0x41, 0x9d, 0x0c, 0x10, 0xde, 0x54,
0x24, 0xcb, 0xed, 0xff, 0x4a, 0xe7, 0xa0, 0x82, 0x10, 0xa0, 0xbc, 0xac, 0xe5, 0x33, 0x3f, 0xc9, 0x8d, 0x69, 0xf4, 0x24, 0xce, 0x99, 0x4f,
0x0d, 0x47, 0xa0, 0xb0, 0xf4, 0xa8, 0x59, 0xad, 0x29, 0xbd, 0x09, 0x48, 0x63, 0xea, 0x59, 0xb7, 0xb7, 0xf2, 0xa1, 0x90, 0xeb, 0x92, 0xc5,
0x9c, 0xde, 0xcc, 0x96, 0x67, 0xc4, 0xe5, 0x8c, 0x03, 0x50, 0xb2, 0x30, 0xf8, 0xf4, 0x69, 0x1d, 0xeb, 0xb9, 0xbd, 0x4e, 0xfb, 0xbd, 0xb6,
0x02,
];
let result = decode_thumbnail(&bytes, 16);
assert!(matches!(result, Err(Error::TooLarge(42_127, 16_304))), "{result:?}");
}
/// A GIF of a few dozen bytes: a `w` × `h` logical screen and one empty frame of that size.
fn tiny_gif(w: u16, h: u16) -> Vec<u8> {
let mut b = b"GIF89a".to_vec();
b.extend(w.to_le_bytes());
b.extend(h.to_le_bytes());
b.extend([0, 0, 0, b',', 0, 0, 0, 0]);
b.extend(w.to_le_bytes());
b.extend(h.to_le_bytes());
b.extend([0, 2, 0, b';']);
b
}
/// A 54-byte uncompressed 24-bit BMP header declaring `w` × `h` and no pixel data.
fn tiny_bmp(w: i32, h: i32) -> Vec<u8> {
let mut b = b"BM".to_vec();
b.extend(54u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend(54u32.to_le_bytes());
b.extend(40u32.to_le_bytes());
b.extend(w.to_le_bytes());
b.extend(h.to_le_bytes());
b.extend(1u16.to_le_bytes());
b.extend(24u16.to_le_bytes());
b.extend([0u8; 24]);
b
}
/// Issue #2553: the import probe had no header reader for GIF or BMP and decoded them in full, so
/// a tiny file declaring a canvas just under the 1-gigapixel limit allocated ~4.3 GB (an abort, not
/// an error, when it failed). Both the probe and a decode now refuse a canvas the file's bytes
/// can't fill, before the decoder allocates it.
#[test]
fn tiny_gif_and_bmp_declaring_a_gigapixel_canvas_are_refused_before_allocating() {
for (name, bytes) in [("gif", tiny_gif(32_767, 32_767)), ("bmp", tiny_bmp(32_000, 32_000))] {
assert!(bytes.len() < 64, "{name}: {} bytes", bytes.len());
let header = read_header(&bytes);
assert!(matches!(&header, Err(Error::Malformed(_, m)) if m.contains("can hold")), "{name}: {header:?}");
let decoded = decode(&bytes, DecodeOptions::default());
assert!(matches!(&decoded, Err(Error::Malformed(_, m)) if m.contains("can hold")), "{name}: {:?}", decoded.err());
}
}
/// Above 4 MP the probe reads GIF and BMP sizes from the header without decoding; the size agrees
/// with a full decode.
#[test]
fn large_gif_and_bmp_sizes_come_from_the_header() {
let (w, h) = (2_100u32, 2_000u32);
let rgb = vec![90u8; (w * h * 3) as usize];
let mut bmp = Vec::new();
image::codecs::bmp::BmpEncoder::new(&mut bmp).encode(&rgb, w, h, image::ExtendedColorType::Rgb8).unwrap();
let mut gif = Vec::new();
let frame = image::RgbaImage::from_pixel(w, h, image::Rgba([90, 90, 90, 255]));
image::codecs::gif::GifEncoder::new(&mut gif).encode_frame(image::Frame::new(frame)).unwrap();
for (name, bytes) in [("bmp", bmp), ("gif", gif)] {
let header = read_header(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((header.width, header.height, header.orientation), (w, h, 1), "{name}");
let d = decode(&bytes, DecodeOptions::fit(64, 64)).unwrap();
assert_eq!((d.source_width, d.source_height), (w, h), "{name}");
}
}
#[test]
fn thumbnail_respects_configured_source_pixel_limit() {
let image = Rgba8::from_fn(3, 2, |_, _| [40, 80, 120, 255]);
let bytes = encode_png(&EncodeImage::rgba8(&image), &EncodeMeta::default()).unwrap();
let mut opts = ThumbnailOptions::new(1);
opts.max_pixels = 5;
assert!(matches!(decode_thumbnail_with(&bytes, &opts), Err(Error::TooLarge(3, 2))));
opts.max_pixels = 6;
assert_eq!(decode_thumbnail_with(&bytes, &opts).unwrap().image.width, 1);
}
fn seeds() -> &'static Vec<Vec<u8>> {
static S: OnceLock<Vec<Vec<u8>>> = OnceLock::new();
S.get_or_init(|| {
let img = Rgba8::from_fn(24, 16, |x, y| [(x * 10) as u8, (y * 15) as u8, ((x ^ y) * 8) as u8, (x * y) as u8]);
let icc = write_named(NamedSpace::DisplayP3);
let exif = minimal_exif(6);
let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some("<x/>"), ..Default::default() };
let e = EncodeImage::rgba8(&img);
let u16s: Vec<u16> = (0..24 * 16 * 3).map(|i| (i * 331) as u16).collect();
let f32s: Vec<f32> = (0..24 * 16 * 3).map(|i| i as f32 * 0.01).collect();
vec![
encode_jpeg(&e, 80, ChromaSubsampling::S420, &meta).unwrap(),
encode_png(&e, &meta).unwrap(),
encode_png(&EncodeImage::new(24, 16, 3, Samples::U16(&u16s)), &meta).unwrap(),
encode_tiff(&e, TiffCompression::Lzw, &meta).unwrap(),
encode_tiff(&EncodeImage::new(24, 16, 3, Samples::F32(&f32s)), TiffCompression::Deflate, &meta).unwrap(),
encode_webp_lossless(&e, &meta).unwrap(),
gain_map_jpeg(&meta),
]
})
}
proptest! {
#![proptest_config(ProptestConfig { cases: 400, ..ProptestConfig::default() })]
#[test]
fn random_after_magic(m in 0usize..MAGICS.len(), tail in proptest::collection::vec(any::<u8>(), 0..600)) {
let mut b = MAGICS[m].to_vec();
b.extend_from_slice(&tail);
try_all(&b);
}
#[test]
fn mutated_valid_files(which in 0usize..7, flips in proptest::collection::vec((any::<prop::sample::Index>(), any::<u8>()), 1..12), cut in any::<prop::sample::Index>()) {
let mut b = seeds()[which].clone();
for (i, v) in flips {
let i = i.index(b.len());
b[i] = v;
}
try_all(&b);
let n = cut.index(b.len());
try_all(&b[..n]);
}
}