Files
lightcraft/crates/raw/tests/it/robust.rs
T
aiongg f3bb7a9f30 CI: one test binary per crate, line tables only, one WASM check (#2710)
* CI: dev profile keeps line tables for workspace crates and no debug info for dependencies

* Control server tests: persistent_requests_and_slow_replies_outlive_the_idle_read_timeout no longer races the idle timeout

Each request (and the closing half-shutdown) reaches the socket before the
worker reads, so on a slow machine only the reply waits outlast the 100 ms
idle timeout, as the test intends. The idle test sends its input the same
way, and the no-frames test allows 5 s instead of 2 s to give up.

* CI: one integration-test binary per crate

Each crate's tests/*.rs become modules of tests/it/main.rs (51 test files
into 13 binaries, plus 6 that stay their own). Inner #![cfg] attributes
move to the mod lines. Files that change state for the whole process keep
a binary of their own: codecs jxl_tail_heap and raw sr2_heap (dhat global
allocator), gpu shutdown, shutdown_first and memory, ui-egui shutdown.
Docs, comments and the GPU probe name the new paths and targets
(--test it <module>::); the CI report names an it binary's skips after
the crate.

* CI: tests under nextest, doctests apart, one wasm check

xtask ci runs the tests with cargo nextest run --lib --bins --tests when
cargo-nextest is installed, else cargo test with the same targets and a
one-line notice; doctests run after them with cargo test --workspace --doc.
The heif and hardware GPU steps use the same runner. Examples stay out of
the test build (clippy --all-targets still checks them). Concurrent tests
under nextest follow the RUST_TEST_THREADS cap (NEXTEST_TEST_THREADS).
The wasm check is one cargo check --keep-going over the same crates, and
clippy runs with --keep-going.

* CI: tests run under cargo test by default, nextest with LIGHTCRAFT_NEXTEST=1 (per-process scene cache)
2026-10-23 13:34:30 -04:00

315 lines
13 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.
//! Malformed-input robustness: decoding random or corrupted files must never panic.
use lightcraft_raw::{
BlackLevel, Cfa, ColorData, DngCompression, DngWriteOptions, Method, OpcodeLists, Orientation, RawData, RawFormat, RawImage, Rect,
};
use proptest::prelude::*;
fn sample(comp: DngCompression) -> Vec<u8> {
let (w, h) = (24usize, 16usize);
let data: Vec<u16> = (0..w * h).map(|i| (i * 97 % 4000) as u16).collect();
let raw = RawImage {
format: RawFormat::Dng,
width: w,
height: h,
cpp: 1,
data: RawData::U16(data),
cfa: Some(Cfa::bayer("RGGB").unwrap()),
bits: 12,
black: BlackLevel::uniform(64.0),
white: vec![4095.0],
active_area: Rect::new(0, 0, w, h),
crop: Rect::new(0, 0, w, h),
orientation: Orientation::Normal,
color: ColorData::default(),
wb_multipliers: None,
linearized: false,
pixel_aspect: 1.0,
opcodes: OpcodeLists::default(),
metadata: Default::default(),
lens_metadata: Default::default(),
};
lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression: comp, ..Default::default() }).unwrap()
}
fn exercise(bytes: &[u8]) {
let _ = lightcraft_raw::probe(bytes);
let _ = lightcraft_raw::embedded_preview(bytes);
if let Ok(img) = lightcraft_raw::decode(bytes)
&& img.width * img.height <= 1 << 16
{
let _ = img.develop(Method::Ahd);
let xy = lightcraft_raw::color::as_shot_white_xy(&img);
let _ = lightcraft_raw::color::camera_transform(&img, xy);
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 400, .. ProptestConfig::default() })]
#[test]
fn mutated_dngs_never_panic(flips in proptest::collection::vec((any::<usize>(), any::<u8>()), 1..24), cut in any::<usize>(), lj in any::<bool>()) {
let mut data = sample(if lj { DngCompression::Lj92 { tile: 16 } } else { DngCompression::Uncompressed });
for (i, v) in flips {
let n = data.len();
data[i % n] = v;
}
let keep = if cut % 3 == 0 { cut % data.len() } else { data.len() };
exercise(&data[..keep]);
}
#[test]
fn random_tiff_like_bytes_never_panic(mut data in proptest::collection::vec(any::<u8>(), 8..700), kind in 0usize..4) {
let heads: [&[u8]; 4] = [b"II*\0\x08\0\0\0", b"MM\0*\0\0\0\x08", b"II*\0\x10\0\0\0CR\x02\0", b"II+\0\x08\0\0\0"];
let h = heads[kind];
let n = h.len().min(data.len());
data[..n].copy_from_slice(&h[..n]);
exercise(&data);
}
#[test]
fn random_lj92_never_panics(data in proptest::collection::vec(any::<u8>(), 0..400)) {
let mut s = vec![0xff, 0xd8, 0xff, 0xc3, 0, 11, 12, 0, 8, 0, 8, 1, 1, 0x11, 0];
s.extend_from_slice(&data);
let _ = lightcraft_raw::ljpeg::decode(&s, 1 << 20);
}
}
#[test]
fn truncation_at_every_length() {
for comp in
[DngCompression::Uncompressed, DngCompression::Lj92 { tile: 16 }, DngCompression::LossyJpeg { tile: 16, quality: 80, long_edge: None }]
{
let d = sample(comp);
for n in (0..d.len()).step_by(7) {
exercise(&d[..n]);
}
}
}
#[test]
fn hostile_dimensions_are_rejected_quickly() {
// a DNG claiming a 60000 × 60000 16-bit image with 10 bytes of data
use lightcraft_tiff::tags as t;
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
let mut ifd = IfdBuilder::new();
ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![60000]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![60000]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803]));
ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
ifd.set(t::COMPRESSION, Value::Short(vec![7]));
ifd.set_image(ImageData::Tiles { tile_width: 60000, tile_height: 60000, tiles: vec![vec![0xff, 0xd8, 0xff, 0xd9]] });
let bytes = TiffWriter::default().write(&[ifd]).unwrap();
let t0 = std::time::Instant::now();
assert!(lightcraft_raw::decode(&bytes).is_err());
assert!(t0.elapsed().as_secs_f64() < 2.0);
}
/// A Bayer DNG whose `BlackLevel` is a FLOAT holding NaN (or infinity), and a float CFA DNG with
/// NaN samples: every normalised sample came out NaN, and AHD's green estimate clamped between two
/// NaN neighbours, which panics (the 1:1 view and every export of such a file). Non-finite levels
/// read as 0 and non-finite samples as black: the photo develops to finite numbers.
#[test]
fn non_finite_black_levels_and_samples_develop() {
use lightcraft_tiff::tags as t;
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
let (w, h) = (32u32, 16u32);
for black in [Value::Float(vec![f32::NAN]), Value::Double(vec![f64::INFINITY]), Value::Float(vec![64.0, f32::NAN, 64.0, 64.0])] {
let mut ifd = IfdBuilder::new();
ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
ifd.set(t::BLACK_LEVEL_REPEAT_DIM, Value::Short(vec![2, 2]));
ifd.set(t::BLACK_LEVEL, black.clone());
ifd.set(t::WHITE_LEVEL, Value::Long(vec![4095]));
let strip: Vec<u8> = (0..w * h).flat_map(|i| (100 + (i * 37 % 3000) as u16).to_le_bytes()).collect();
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![strip] });
let bytes = TiffWriter::default().write(&[ifd]).unwrap();
let img = lightcraft_raw::decode(&bytes).unwrap_or_else(|e| panic!("{black:?}: {e}"));
let rgb = img.develop(Method::Ahd).unwrap();
assert!(rgb.data.iter().flatten().all(|v| v.is_finite()), "{black:?}: finite output");
let _ = img.develop(Method::Ppg);
}
// float samples: a block of NaN and infinities in the middle of the mosaic
let (w, h) = (24usize, 16usize);
let data: Vec<f32> = (0..w * h)
.map(|i| match (i % w, i / w) {
(8..=13, 5..=10) => f32::NAN,
(2, _) => f32::INFINITY,
_ => (i * 97 % 1000) as f32 / 1000.0,
})
.collect();
let raw = RawImage {
format: RawFormat::Dng,
width: w,
height: h,
cpp: 1,
data: RawData::F32(data),
cfa: Some(Cfa::bayer("RGGB").unwrap()),
bits: 32,
black: BlackLevel::uniform(0.0),
white: vec![1.0],
active_area: Rect::new(0, 0, w, h),
crop: Rect::new(0, 0, w, h),
orientation: Orientation::Normal,
color: ColorData::default(),
wb_multipliers: None,
linearized: false,
opcodes: OpcodeLists::default(),
metadata: Default::default(),
lens_metadata: Default::default(),
pixel_aspect: 1.0,
};
for img in [raw.clone(), lightcraft_raw::decode(&lightcraft_raw::write_dng(&raw, &DngWriteOptions::default()).unwrap()).unwrap()] {
let rgb = img.develop(Method::Ahd).unwrap();
assert!(rgb.data.iter().flatten().all(|v| v.is_finite()), "float NaN samples: finite output");
}
}
// --- CR3 containers (Canon CRX): structurally valid files whose every byte may be mutated ---
/// A CR3 with a single CRAW track carrying a CMP1 descriptor (and optional CDI1/IAD1) plus raw samples.
mod cr3 {
pub(crate) fn box_bytes(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut out = ((body.len() + 8) as u32).to_be_bytes().to_vec();
out.extend_from_slice(kind);
out.extend_from_slice(body);
out
}
fn track(entry: &[u8], offset: u64, size: u32) -> Vec<u8> {
let mut stsd = vec![0u8; 4];
stsd.extend(1u32.to_be_bytes());
stsd.extend_from_slice(entry);
let mut stsz = vec![0u8; 4];
stsz.extend(size.to_be_bytes());
stsz.extend(1u32.to_be_bytes());
let mut co64 = vec![0u8; 4];
co64.extend(1u32.to_be_bytes());
co64.extend(offset.to_be_bytes());
let tables = [box_bytes(b"stsd", &stsd), box_bytes(b"stsz", &stsz), box_bytes(b"co64", &co64)].concat();
box_bytes(b"trak", &box_bytes(b"mdia", &box_bytes(b"minf", &box_bytes(b"stbl", &tables))))
}
/// `CMP1` coding descriptor: 52 bytes, four planes, 14 bits, RGGB, `levels` wavelet levels.
pub(crate) fn cmp1(width: u32, height: u32, tile_width: u32, tile_height: u32, levels: u8, header_size: u32) -> Vec<u8> {
let mut coding = vec![0u8; 52];
coding[0..2].copy_from_slice(&0xff00u16.to_be_bytes());
coding[2..4].copy_from_slice(&48u16.to_be_bytes());
coding[4..6].copy_from_slice(&0x100u16.to_be_bytes());
coding[8..12].copy_from_slice(&width.to_be_bytes());
coding[12..16].copy_from_slice(&height.to_be_bytes());
coding[16..20].copy_from_slice(&tile_width.to_be_bytes());
coding[20..24].copy_from_slice(&tile_height.to_be_bytes());
coding[24] = 14;
coding[25] = 0x40;
coding[26] = levels;
coding[28..32].copy_from_slice(&header_size.to_be_bytes());
coding
}
pub(crate) fn file(coding: &[u8], width: u16, height: u16, sample: &[u8]) -> Vec<u8> {
let mut craw = vec![0u8; 82];
craw[24..26].copy_from_slice(&width.to_be_bytes());
craw[26..28].copy_from_slice(&height.to_be_bytes());
craw.extend(box_bytes(b"CMP1", coding));
let entry = box_bytes(b"CRAW", &craw);
let ftyp = box_bytes(b"ftyp", b"crx \0\0\0\x01crx isom");
let track_len = track(&entry, 0, sample.len() as u32).len();
let offset = (ftyp.len() + 8 + track_len + 7) & !7;
let mut out = ftyp;
out.extend(box_bytes(b"moov", &track(&entry, offset as u64, sample.len() as u32)));
out.resize(offset, 0);
out.extend_from_slice(sample);
out
}
fn marker(out: &mut Vec<u8>, code: u16, size: u32, flags: u32) {
out.extend(code.to_be_bytes());
out.extend(8u16.to_be_bytes());
out.extend(size.to_be_bytes());
out.extend(flags.to_be_bytes());
}
/// A 4×4 lossless CRX stream: four 2×2 planes, each sample value 1 (sensor level 8193).
pub(crate) fn lossless_sample() -> Vec<u8> {
let mut sample = Vec::new();
marker(&mut sample, 0xff01, 4, 0);
for p in 0..4u32 {
marker(&mut sample, 0xff02, 1, (p << 28) | 0x0800_0000);
marker(&mut sample, 0xff03, 1, 4 << 19);
}
sample.extend([0x1e; 4]);
sample
}
pub(crate) fn lossless_file() -> Vec<u8> {
file(&cmp1(4, 4, 4, 4, 0, 108), 4, 4, &lossless_sample())
}
/// A 16×16 three-level C-RAW CRX stream of flat zero subbands (sensor level 8192).
pub(crate) fn craw_sample() -> Vec<u8> {
let mut sample = Vec::new();
marker(&mut sample, 0xff01, 52, 0);
for plane in 0..4u32 {
marker(&mut sample, 0xff02, 13, (plane << 28) | 0x0800_0000);
for band in 0..10u32 {
marker(&mut sample, 0xff03, if band >= 7 { 2 } else { 1 }, (band << 28) | (4 << 19));
}
}
for _ in 0..4 {
sample.extend([0x80; 4]);
sample.extend([0xf0; 3]);
for _ in 0..3 {
sample.extend([0xff, 0xf8]);
}
}
sample
}
pub(crate) fn craw_file() -> Vec<u8> {
file(&cmp1(16, 16, 16, 16, 3, 540), 16, 16, &craw_sample())
}
}
/// Both fixtures decode as built, so the mutations below hit live decode paths.
#[test]
fn cr3_fixtures_decode() {
let lossless = lightcraft_raw::decode(&cr3::lossless_file()).unwrap();
assert_eq!((lossless.width, lossless.height, lossless.bits), (4, 4, 14));
assert_eq!(lossless.data.get(0), 8193.0);
let craw = lightcraft_raw::decode(&cr3::craw_file()).unwrap();
assert_eq!((craw.width, craw.height, craw.bits), (16, 16, 14));
assert_eq!(craw.data.get(0), 8192.0);
assert_eq!(lightcraft_raw::probe_info(&cr3::craw_file()).unwrap(), craw.info());
}
proptest! {
/// Mutating or truncating structurally valid CR3 containers exercises the CRX marker walker and
/// entropy readers far deeper than random TIFF bytes: errors or clean decodes, never a panic.
#[test]
fn mutated_cr3_containers_never_panic(
kind in 0usize..2,
flips in proptest::collection::vec((any::<usize>(), any::<u8>()), 1..24),
cut in any::<usize>(),
) {
let mut data = if kind == 0 { cr3::lossless_file() } else { cr3::craw_file() };
for (i, v) in flips {
let n = data.len();
data[i % n] = v;
}
let keep = if cut % 3 == 0 { cut % data.len() } else { data.len() };
exercise(&data[..keep]);
// The header-only description path (used when importing) must hold up against the same mutations.
let _ = lightcraft_raw::probe_info(&data[..keep]);
}
}