132 lines
5.8 KiB
Rust
132 lines
5.8 KiB
Rust
//! 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:?}");
|
|
}
|
|
|
|
#[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]);
|
|
}
|
|
}
|