Files
craft-libs/crates/crw/src/tests.rs
T

777 lines
34 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.
//! Unit tests: synthetic CIFF files built with a test-only encoder that is the inverse of the
//! frozen model, plus the frozen tables' structural properties. The decoder's behaviour on real
//! files is pinned in `tests/samples.rs`.
use super::*;
fn put16(v: u16, o: Order) -> [u8; 2] {
match o {
Order::Le => v.to_le_bytes(),
Order::Be => v.to_be_bytes(),
}
}
fn put32(v: u32, o: Order) -> [u8; 4] {
match o {
Order::Le => v.to_le_bytes(),
Order::Be => v.to_be_bytes(),
}
}
/// A heap block: values, record table, trailing offset-table pointer.
fn heap(records: &[(u16, Vec<u8>)], o: Order) -> Vec<u8> {
let mut b = Vec::new();
let mut entries = Vec::new();
for (tc, v) in records {
let off = b.len();
b.extend_from_slice(v);
if b.len() % 2 == 1 {
b.push(0);
}
entries.push((*tc, v.len() as u32, off as u32));
}
let tbl = b.len() as u32;
b.extend_from_slice(&put16(entries.len() as u16, o));
for (tc, l, off) in entries {
b.extend_from_slice(&put16(tc, o));
b.extend_from_slice(&put32(l, o));
b.extend_from_slice(&put32(off, o));
}
b.extend_from_slice(&put32(tbl, o));
b
}
/// A complete CIFF file: 26-byte header + the root heap.
fn ciff(records: &[(u16, Vec<u8>)], o: Order) -> Vec<u8> {
let mut f = Vec::new();
f.extend_from_slice(match o {
Order::Le => b"II",
Order::Be => b"MM",
});
f.extend_from_slice(&put32(26, o));
f.extend_from_slice(b"HEAPCCDR");
f.extend_from_slice(&put32(0x0001_0002, o));
f.extend_from_slice(&[0; 8]);
f.extend_from_slice(&heap(records, o));
f
}
fn sensor_info(w: u16, h: u16, crop: Option<(u16, u16, u16, u16)>, o: Order) -> Vec<u8> {
let (l, t, r, b) = crop.unwrap_or((0, 0, 0, 0));
[34, w, h, 0, 0, l, t, r, b, 0, 0, 0, 0, 0, 0, 0, 0].iter().flat_map(|&v| put16(v, o)).collect()
}
fn decoder_table(sel: u32, o: Order) -> Vec<u8> {
[sel, 0, 514, 0].iter().flat_map(|&v| put32(v, o)).collect()
}
/// Entropy-encode a 10-bit high plane with the model's inverse: per-block first/main tables,
/// (0, s) tokens only (the caller keeps mid-block diffs nonzero), JPEG magnitudes, carry,
/// parity chains reset at row starts; MSB-first packing, zero padding, ff00 stuffing.
fn encode_hi(hi: &[u16], w: usize, t: &tables::SelTables) -> Vec<u8> {
let mut bits: Vec<bool> = Vec::new();
let push_code = |bits: &mut Vec<bool>, tab: &[(u16, u8, u8, u8)], s: u32| {
let &(c, l, ..) = tab.iter().find(|e| e.2 == 0 && e.3 as u32 == s).expect("(0, s) code exists");
bits.extend((0..l).rev().map(|i| (c >> i) & 1 == 1));
};
let size_of = |d: i32| if d == 0 { 0 } else { 32 - d.unsigned_abs().leading_zeros() };
let (mut ce, mut co) = (512i32, 512i32);
let mut carry = 0i32;
for (qi, &v) in hi.iter().enumerate() {
if qi % w == 0 {
ce = 512;
co = 512;
}
let c = if qi % 2 == 0 { &mut ce } else { &mut co };
let dd = v as i32 - *c;
*c = v as i32;
let (tab, d) = if qi % 64 == 0 {
let d = dd - carry;
carry = dd;
(t.first, d)
} else {
assert_ne!(dd, 0, "test images avoid zero diffs mid-block");
(t.main, dd)
};
let s = size_of(d);
push_code(&mut bits, tab, s);
if s > 0 {
let m = if d > 0 { d } else { d + (1 << s) - 1 } as u32;
bits.extend((0..s).rev().map(|i| (m >> i) & 1 == 1));
}
}
pack_entropy(&bits)
}
/// MSB-first packing into bytes, zero padding in the last byte, then ff00 stuffing.
fn pack_entropy(bits: &[bool]) -> Vec<u8> {
let mut stuffed = Vec::new();
for chunk in bits.chunks(8) {
let mut b = 0u8;
for (i, &bit) in chunk.iter().enumerate() {
b |= (bit as u8) << (7 - i);
}
stuffed.push(b);
if b == 0xff {
stuffed.push(0x00);
}
}
stuffed
}
/// One end-of-block token pair per 64-sample block: the block's first sample takes the carry
/// (zero here), the other 63 keep their chain values. A flat frame like this compresses to a
/// small fraction of its low-bits plane, the shape a lens-cap or blown-out frame has.
fn encode_eob(n: usize, t: &tables::SelTables) -> Vec<u8> {
let code = |tab: &[(u16, u8, u8, u8)]| {
let &(c, l, ..) = tab.iter().find(|e| e.2 == 0 && e.3 == 0).expect("an end-of-block code");
(c, l)
};
let (fc, fl) = code(t.first);
let (mc, ml) = code(t.main);
let mut bits: Vec<bool> = Vec::new();
for _ in 0..n.div_ceil(64) {
for (c, l) in [(fc, fl), (mc, ml)] {
bits.extend((0..l).rev().map(|i| (c >> i) & 1 == 1));
}
}
pack_entropy(&bits)
}
/// A high plane whose diffs are all nonzero, bounded around 512: values follow
/// `(3 qi) mod 5 + 1`, so same-parity neighbours differ by 1 or −4 (never 0) and
/// row starts sit 1–5 above the reset value.
fn test_hi(n: usize) -> Vec<u16> {
(0..n).map(|qi| 512 + (qi as u16 * 3) % 5 + 1).collect()
}
fn payload(low: Option<&[u8]>, entropy: &[u8]) -> Vec<u8> {
let mut p = low.map(|l| l.to_vec()).unwrap_or_default();
p.extend_from_slice(&[0; 514]);
p.extend_from_slice(entropy);
p.extend_from_slice(&[0, 0]);
p
}
/// The real PowerShot record shape: the payload closes with `ff d9` and a pad byte, not `00 00`.
fn payload_with_trailer(low: Option<&[u8]>, entropy: &[u8]) -> Vec<u8> {
let mut p = low.map(|l| l.to_vec()).unwrap_or_default();
p.extend_from_slice(&[0; 514]);
p.extend_from_slice(entropy);
p.extend_from_slice(&[0xff, 0xd9, 0x00]);
p
}
/// A file whose raw record is `payload` verbatim, with `extra` records beside the usual three.
fn file_with_payload(w: u16, h: u16, model: &str, payload: &[u8], extra: &[(u16, Vec<u8>)], o: Order) -> Vec<u8> {
let mut props = vec![(SENSOR_INFO, sensor_info(w, h, None, o)), (DECODER_TABLE, decoder_table(0, o)), (MAKE_MODEL, make_model(model))];
props.extend_from_slice(extra);
ciff(&[(RAW_DATA, payload.to_vec()), (0x300a, heap(&props, o))], o)
}
fn make_model(model: &str) -> Vec<u8> {
let mut v = b"Canon\0".to_vec();
v.extend_from_slice(model.as_bytes());
v.push(0);
v
}
fn roundtrip_file(w: u16, h: u16, model: &str, low: Option<&[u8]>, hi: &[u16], o: Order) -> Vec<u8> {
let tables = tables::tables(0).expect("selector 0");
let entropy = encode_hi(hi, w as usize, &tables);
// the realistic shape: raw data in the root, properties in a nested heap
let props = heap(&[(SENSOR_INFO, sensor_info(w, h, None, o)), (DECODER_TABLE, decoder_table(0, o)), (MAKE_MODEL, make_model(model))], o);
ciff(&[(RAW_DATA, payload(low, &entropy)), (0x300a, props)], o)
}
fn full(bytes: &[u8]) -> Result<Image> {
decode(bytes, &Limits::default())
}
#[test]
fn probe_needs_the_signature() {
assert!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"));
assert!(probe(b"MM\0\0\0\x1aHEAPCCDR"));
assert!(!probe(b"II\x1a\0\0\0HEAP"));
assert!(!probe(b"II*\0\x08\0\0\0"), "a TIFF");
assert!(!probe(b""));
assert_eq!(full(b"II*\0\x08\0\0\0"), Err(Error::NotCrw));
// a signature with no heap behind it is corrupt, not a panic
assert!(matches!(full(b"II HEAPCCDR "), Err(Error::Corrupt(_))));
}
#[test]
fn roundtrips_a_10_bit_frame() {
let (w, h) = (16u16, 8u16);
let hi = test_hi(128);
let f = roundtrip_file(w, h, "Canon EOS D60", None, &hi, Order::Le);
let img = full(&f).expect("decodes");
assert_eq!((img.header.width, img.header.height, img.header.bits), (16, 8, 10));
assert_eq!(img.samples, hi);
assert_eq!(img.header.cfa, Cfa::Rggb);
assert_eq!(img.header.selector, 0);
assert_eq!(img.header.make.as_deref(), Some("Canon"));
assert_eq!(img.header.model.as_deref(), Some("Canon EOS D60"));
assert_eq!(img.header.exif_orientation, 1);
assert_eq!(img.header.capture_unix, None);
assert_eq!(img.header.wb_multipliers, None);
}
#[test]
fn roundtrips_a_12_bit_frame_with_a_low_bits_plane() {
let (w, h) = (16u16, 8u16);
let hi = test_hi(128);
// low bits all 3: the plane is all 0xff bytes, which trips the presence rule
let low = vec![0xffu8; 32];
let f = roundtrip_file(w, h, "Canon EOS D60", Some(&low), &hi, Order::Le);
let img = full(&f).expect("decodes");
assert_eq!(img.header.bits, 12);
let expected: Vec<u16> = hi.iter().map(|&v| (v << 2) | 3).collect();
assert_eq!(img.samples, expected);
}
/// A low-bits plane without a single 0xff byte (real D60 files whose lowest bit is nearly
/// always 0) used to be taken for "no plane": the plane was read as entropy data and the
/// file came out as 10-bit garbage. The zero header after the plane gives it away.
#[test]
fn a_low_bits_plane_without_ff_bytes_is_still_a_plane() {
let hi = test_hi(128);
for byte in [0x22u8, 0x00] {
let low = vec![byte; 32];
let f = roundtrip_file(16, 8, "Canon EOS D60", Some(&low), &hi, Order::Le);
let img = full(&f).expect("decodes");
assert_eq!(img.header.bits, 12);
let expected: Vec<u16> = hi.iter().enumerate().map(|(i, &v)| (v << 2) | u16::from((byte >> ((i & 3) * 2)) & 3)).collect();
assert_eq!(img.samples, expected);
}
}
#[test]
fn big_endian_ciff_decodes_too() {
let hi = test_hi(128);
let f = roundtrip_file(16, 8, "Canon EOS D60", None, &hi, Order::Be);
let img = full(&f).expect("decodes");
assert_eq!(img.samples, hi);
}
#[test]
fn header_reads_geometry_without_samples() {
let hi = test_hi(128);
let f = roundtrip_file(16, 8, "Canon EOS D60", None, &hi, Order::Le);
let hdr = header(&f, &Limits::default()).expect("header decodes");
assert_eq!((hdr.width, hdr.height, hdr.bits), (16, 8, 10));
assert_eq!(hdr, full(&f).expect("decodes").header, "the header is the same either way");
}
#[test]
fn limits_cap_the_sample_count() {
let hi = test_hi(128);
let f = roundtrip_file(16, 8, "Canon EOS D60", None, &hi, Order::Le);
let mut limits = Limits { max_samples: 127, ..Limits::default() };
assert_eq!(decode(&f, &limits), Err(Error::LimitExceeded("image too large")));
assert!(header(&f, &limits).is_err());
limits.max_samples = 128;
assert!(decode(&f, &limits).is_ok());
}
/// A header declaring 2²⁴ samples over a 128-sample stream is refused by the default limits
/// before any sample-sized buffer exists: the cap must stay above every real frame (7.4 million)
/// and below anything that would let a crafted file demand gigabytes.
#[test]
fn default_limits_refuse_a_crafted_giant_frame() {
let hi = test_hi(128);
let f = roundtrip_file(4096, 4096, "Canon EOS D60", None, &hi, Order::Le);
assert_eq!(decode(&f, &Limits::default()), Err(Error::LimitExceeded("image too large")));
assert_eq!(header(&f, &Limits::default()), Err(Error::LimitExceeded("image too large")));
assert!(Limits::default().max_samples >= 7_407_040, "the cap must admit the largest real frame");
}
#[test]
fn g1_is_gbrg() {
let hi = test_hi(128);
let f = roundtrip_file(16, 8, "Canon PowerShot G1", None, &hi, Order::Le);
let img = full(&f).expect("decodes");
assert_eq!(img.header.cfa, Cfa::Gbrg);
assert_eq!(img.header.cfa.pattern(), [1, 2, 0, 1]);
assert_eq!(Cfa::Rggb.pattern(), [0, 1, 1, 2]);
}
#[test]
fn g5_is_refused_with_its_anomaly_note() {
let hi = test_hi(128);
let f = roundtrip_file(16, 8, "Canon PowerShot G5", None, &hi, Order::Le);
let Err(Error::Unsupported(why)) = full(&f) else { panic!("expected Unsupported") };
assert!(why.contains("G5"), "{why}");
assert!(matches!(header(&f, &Limits::default()), Err(Error::Unsupported(_))));
}
/// A D60 at ISO 100 clips near 4000, and a photo with a small blown area has no plateau:
/// the white level must still come down to where the data stops, not stay at 4095. A few
/// hot pixels, or a photo that never gets near the top, leave the full range.
#[test]
fn white_level_follows_a_small_clipped_area() {
let mut data: Vec<u16> = (0..200_000).map(|i| 300 + (i % 3000) as u16).collect();
assert_eq!(white_level(&data, 12), 4095.0, "nothing near the top");
for v in data.iter_mut().take(100) {
*v = 4002;
}
assert_eq!(white_level(&data, 12), (4002 - 15) as f32, "100 samples at the clip level");
let mut hot: Vec<u16> = (0..200_000).map(|i| 300 + (i % 3000) as u16).collect();
for v in hot.iter_mut().take(10) {
*v = 4002;
}
assert_eq!(white_level(&hot, 12), 4095.0, "ten hot pixels are not a clip level");
}
#[test]
fn saturation_plateau_is_found_with_strays_above_it() {
let mut d: Vec<u16> = (0..100_000).map(|i| (i % 10000) as u16).collect();
assert_eq!(saturation_plateau(&d, 14), 16383.0);
d.extend(std::iter::repeat_n(15000u16, 1000));
let w = saturation_plateau(&d, 14);
assert!((14900.0..=15000.0).contains(&w), "{w}");
assert_eq!(saturation_plateau(&[], 12), 4095.0);
let mut d: Vec<u16> = (0..100_000).map(|i| (2048 + i % 10000) as u16).collect();
d.extend(std::iter::repeat_n(13583u16, 5000));
d.extend([13700, 13900, 14100, 14426]);
let w = saturation_plateau(&d, 14);
assert!((13500.0..=13583.0).contains(&w), "{w}");
}
/// The D60 layout: sensor rows clamped to 0 above the image, dark columns left of it. The
/// clamped rows must not count towards the black level.
#[test]
fn black_level_ignores_clamped_rows() {
let (w, h) = (64usize, 40usize);
let active = Rect::new(24, 16, 40, 24);
let mut data = vec![900u16; w * h];
for y in 0..h {
for x in 0..w {
data[y * w + x] = if y < 7 {
0
} else if x < 24 {
128
} else {
900
};
}
}
let black = black_from_borders(&data, w, h, active);
assert!(black.values.iter().all(|v| (v - 128.0).abs() < 0.5), "{:?}", black.values);
assert_eq!(black.at(active, 24, 16), black.values[0]);
assert_eq!(black.at(active, 25, 16), black.values[1]);
assert_eq!(black.at(active, 24, 17), black.values[2]);
assert_eq!(black.at(active, 23, 15), black.values[3], "sites are counted from the active origin, modulo 2");
assert_eq!(black_from_borders(&data, w, h, Rect::new(0, 0, w, h)), BlackLevel::uniform(0.0), "no border, no level");
}
/// The frozen stream-end rule: a truncated entropy stream leaves the remaining samples at
/// their chain values instead of failing the file, when the limit allows that many.
#[test]
fn truncated_stream_keeps_chain_values() {
let (w, h) = (16u16, 8u16);
let hi = test_hi(128);
let tables = tables::tables(0).expect("selector 0");
let entropy = encode_hi(&hi, 16, &tables);
let cut = entropy.len() / 2;
let props = heap(
&[(SENSOR_INFO, sensor_info(w, h, None, Order::Le)), (DECODER_TABLE, decoder_table(0, Order::Le)), (MAKE_MODEL, make_model("Canon EOS D60"))],
Order::Le,
);
let f = ciff(&[(RAW_DATA, payload(None, &entropy[..cut])), (0x300a, props)], Order::Le);
let lenient = Limits { max_stream_end_fill: 128, ..Limits::default() };
let img = decode(&f, &lenient).expect("truncated stream still decodes");
assert_eq!(img.samples.len(), 128);
assert_eq!(&img.samples[..32], &hi[..32], "the leading samples decode exactly");
assert!(img.stream_end_fill > 0, "the samples after the cut are filled");
let tail_start = img.samples.len() - img.stream_end_fill;
for i in tail_start..img.samples.len() {
let expected = if i % (w as usize) < 2 { 512 } else { img.samples[i - 2] };
assert_eq!(img.samples[i], expected, "filled sample at {i} keeps its predictor value");
}
}
/// A stream cut short decoded as a smeared image: the samples after its end take their chain
/// values. Past one block that is `Corrupt` by default; a complete stream fills none, and a fill
/// within the limit is accepted.
#[test]
fn a_stream_cut_short_is_corrupt() {
let (w, h) = (64u16, 32u16);
let hi = test_hi(2048);
let tables = tables::tables(0).expect("selector 0");
let entropy = encode_hi(&hi, 64, &tables);
let file = |entropy: &[u8], with_trailer: bool| {
let mut p = Vec::with_capacity(514 + entropy.len() + 2);
p.extend_from_slice(&[0; 514]);
p.extend_from_slice(entropy);
if with_trailer {
p.extend_from_slice(&[0, 0]);
}
let props = heap(
&[
(SENSOR_INFO, sensor_info(w, h, None, Order::Le)),
(DECODER_TABLE, decoder_table(0, Order::Le)),
(MAKE_MODEL, make_model("Canon EOS D60")),
],
Order::Le,
);
ciff(&[(RAW_DATA, p), (0x300a, props)], Order::Le)
};
let whole = full(&file(&entropy, true)).expect("a complete stream decodes");
assert_eq!(whole.stream_end_fill, 0);
let cut = file(&entropy[..entropy.len() / 2], false);
let Err(Error::Corrupt(why)) = full(&cut) else { panic!("a stream cut in half must be refused") };
assert!(why.contains("samples before the frame does"), "{why}");
let filled = decode(&cut, &Limits { max_stream_end_fill: usize::MAX, ..Limits::default() }).expect("lenient").stream_end_fill;
assert_eq!(filled, 1024, "half the 2048-sample frame is 1024 filled samples");
let at_limit = Limits { max_stream_end_fill: 1024, ..Limits::default() };
assert_eq!(decode(&cut, &at_limit).map(|i| i.stream_end_fill), Ok(1024), "a fill at the limit is accepted");
}
/// Truncated entropy streams at 1 sample, 64 samples (at the default limit), 65 samples (past
/// the default limit, returning Corrupt), and a complete file; and verifying the limit can be raised
/// by callers wanting partial recovery.
#[test]
fn truncated_stream_fill_limits_and_recovery() {
let (w, h) = (16u16, 8u16);
let n = (w * h) as usize; // 128
// Construct hi where every sample difference has magnitude 16 (size 5), encoding to exactly 8 bits
// (1 byte) per sample across both first and main tables.
let mut hi = Vec::with_capacity(n);
let (mut ce, mut co) = (512i32, 512i32);
for qi in 0..n {
if qi % w as usize == 0 {
ce = 512;
co = 512;
}
let c = if qi % 2 == 0 { &mut ce } else { &mut co };
*c += 16;
hi.push(*c as u16);
}
let tables = tables::tables(0).expect("selector 0");
let entropy = encode_hi(&hi, w as usize, &tables);
assert_eq!(entropy.len(), n, "each sample encodes to exactly 1 byte");
let file = |entropy_bytes: &[u8]| {
let mut p = Vec::with_capacity(514 + entropy_bytes.len());
p.extend_from_slice(&[0; 514]);
p.extend_from_slice(entropy_bytes);
let props = heap(
&[
(SENSOR_INFO, sensor_info(w, h, None, Order::Le)),
(DECODER_TABLE, decoder_table(0, Order::Le)),
(MAKE_MODEL, make_model("Canon EOS D60")),
],
Order::Le,
);
ciff(&[(RAW_DATA, p), (0x300a, props)], Order::Le)
};
// 1. Complete file: fills 0 samples, decodes cleanly with default limits
let complete = file(&entropy);
let img = full(&complete).expect("complete file decodes");
assert_eq!(img.stream_end_fill, 0);
// 2. Truncated 1 sample before end: fills 1 sample, accepted under default limit (64)
let cut_1 = file(&entropy[..n - 1]);
let img_1 = full(&cut_1).expect("1-sample truncation accepted by default limits");
assert_eq!(img_1.stream_end_fill, 1);
// 3. Truncated 64 samples before end: fills 64 samples, accepted at the boundary of default limit (64)
let cut_64 = file(&entropy[..n - 64]);
let img_64 = full(&cut_64).expect("64-sample truncation accepted at boundary by default limits");
assert_eq!(img_64.stream_end_fill, 64);
// 4. Truncated 65 samples before end: fills 65 samples, refused by default limit (64) as Corrupt
let cut_65 = file(&entropy[..n - 65]);
let err_65 = full(&cut_65).expect_err("65-sample truncation must be refused by default limits");
assert_eq!(err_65, Error::Corrupt("entropy stream ends 65 samples before the frame does".into()));
// 5. Check the limit can be raised by callers who want partial recovery
let lenient = Limits { max_stream_end_fill: 65, ..Limits::default() };
let img_65_recovered = decode(&cut_65, &lenient).expect("decodes when limit is raised");
assert_eq!(img_65_recovered.stream_end_fill, 65);
}
/// Every record the decoder needs missing, bogus selectors, dimensions and offsets: errors,
/// never panics; and every truncation of a valid file survives.
#[test]
fn hostile_inputs_error_cleanly() {
let o = Order::Le;
let hi = test_hi(128);
let good = roundtrip_file(16, 8, "Canon EOS D60", None, &hi, o);
for n in 0..good.len() {
let _ = full(&good[..n]);
let _ = header(&good[..n], &Limits::default());
}
// single-byte mutations over the whole file
for i in 0..good.len() {
let mut bad = good.clone();
bad[i] ^= 0xa5;
let _ = full(&bad);
}
let props_no_sensor = heap(&[(DECODER_TABLE, decoder_table(0, o))], o);
let f = ciff(&[(RAW_DATA, vec![0; 600]), (0x300a, props_no_sensor)], o);
assert!(matches!(full(&f), Err(Error::Corrupt(_))));
let props_bad_sel = heap(&[(SENSOR_INFO, sensor_info(16, 8, None, o)), (DECODER_TABLE, decoder_table(7, o))], o);
let f = ciff(&[(RAW_DATA, vec![0; 600]), (0x300a, props_bad_sel)], o);
assert!(matches!(full(&f), Err(Error::Unsupported(_))));
let props_zero = heap(&[(SENSOR_INFO, sensor_info(0, 0, None, o)), (DECODER_TABLE, decoder_table(0, o))], o);
let f = ciff(&[(RAW_DATA, vec![0; 600]), (0x300a, props_zero)], o);
assert!(full(&f).is_err());
// a heap that nests into itself must hit the depth bound, not recurse forever
let mut self_heap = heap(&[(0x300a, vec![0; 10])], o);
let len = self_heap.len() as u32;
// point the nested record at the whole block
let tbl = u32::from_le_bytes(self_heap[len as usize - 4..].try_into().unwrap()) as usize;
self_heap[tbl + 4..tbl + 8].copy_from_slice(&len.to_le_bytes());
self_heap[tbl + 8..tbl + 12].copy_from_slice(&0u32.to_le_bytes());
let mut f = ciff(&[], o);
f.truncate(26);
f.extend_from_slice(&self_heap);
assert!(full(&f).is_err());
}
/// The frozen tables are prefix-free within each table, so the 16-bit lookup is unambiguous.
#[test]
fn frozen_tables_are_prefix_free() {
for sel in 0..3 {
let t = tables::tables(sel).expect("selector");
for (name, tab) in [("first", t.first), ("main", t.main)] {
let mut lut = vec![false; 1 << 16];
for &(code, len, ..) in tab {
assert!((1..=16).contains(&len), "sel {sel} {name}: code length {len}");
let base = (code as usize) << (16 - len);
for cell in lut.iter_mut().skip(base).take(1 << (16 - len)) {
assert!(!*cell, "sel {sel} {name}: overlapping codes");
*cell = true;
}
}
}
}
assert!(tables::tables(3).is_none());
}
/// Every second table codes all 162 symbols: (r, s) for r 0..=15 and s 1..=10, end of block
/// and (15, 0). Selectors 0 and 1 lacked (7..=14, 10) and (14, 10), learned from too few
/// files, and 223 of 1,516 real D60 files failed with "undecodable entropy code".
#[test]
fn second_tables_code_every_symbol() {
for sel in 0..3 {
let t = tables::tables(sel).expect("selector");
let have: std::collections::BTreeSet<(u8, u8)> = t.main.iter().map(|e| (e.2, e.3)).collect();
let mut want: std::collections::BTreeSet<(u8, u8)> = (0..16).flat_map(|r| (1..=10).map(move |s| (r, s))).collect();
want.extend([(0, 0), (15, 0)]);
assert_eq!(have, want, "selector {sel}");
assert_eq!(t.main.len(), 162, "selector {sel}: one code per symbol");
}
}
#[test]
fn white_balance_comes_from_the_shot_info_slot() {
let o = Order::Le;
let hi = test_hi(128);
// D60-shaped table: 7 slots + black levels; Daylight slot (1) = 1800, 800, 800, 960
let mut cb: Vec<u16> = vec![66];
cb.extend([2000u16, 760, 760, 830]);
cb.extend([1800u16, 800, 800, 960]);
for _ in 0..5 {
cb.extend([1500u16, 900, 900, 1500]);
}
cb.extend([126u16, 127, 126, 126]);
let cb: Vec<u8> = cb.iter().flat_map(|&v| put16(v, o)).collect();
let mut shot: Vec<u16> = vec![54; 27];
shot[7] = 1; // Daylight
let shot: Vec<u8> = shot.iter().flat_map(|&v| put16(v, o)).collect();
let tables = tables::tables(0).expect("selector 0");
let props = heap(
&[
(SENSOR_INFO, sensor_info(16, 8, None, o)),
(DECODER_TABLE, decoder_table(0, o)),
(MAKE_MODEL, make_model("Canon EOS D60")),
(COLOR_BALANCE, cb),
(SHOT_INFO, shot),
],
o,
);
let f = ciff(&[(RAW_DATA, payload(None, &encode_hi(&hi, 16, &tables))), (0x300a, props)], o);
let img = full(&f).expect("decodes");
let wb = img.header.wb_multipliers.expect("wb");
assert!((wb[0] - 2.25).abs() < 1e-3 && wb[1] == 1.0 && (wb[2] - 1.2).abs() < 1e-3, "{wb:?}");
}
#[test]
fn rotation_maps_to_exif_orientation_and_the_time_stamp_is_unix_seconds() {
let o = Order::Le;
let hi = test_hi(128);
let tables = tables::tables(0).expect("selector 0");
// The record holds a signed degree count, so 270 may also be written as −90.
for (deg, want) in [(0u32, 1u8), (90, 6), (180, 3), (270, 8), (-90i32 as u32, 8), (-270i32 as u32, 6)] {
let image_info: Vec<u8> = [3072u32, 2048, 0, deg, 12, 12, 0].iter().flat_map(|&v| put32(v, o)).collect();
let stamp: Vec<u8> = [1083410638u32, 0, 0].iter().flat_map(|&v| put32(v, o)).collect();
let extra = [(IMAGE_INFO, image_info), (TIME_STAMP, stamp)];
let f = file_with_payload(16, 8, "Canon EOS 300D DIGITAL", &payload(None, &encode_hi(&hi, 16, &tables)), &extra, o);
let img = full(&f).expect("decodes");
assert_eq!(img.header.exif_orientation, want, "rotation {deg}");
assert_eq!(img.header.capture_unix, Some(1083410638));
}
}
#[test]
fn sensor_info_crop_is_the_active_area() {
let o = Order::Le;
let hi = test_hi(128);
let tables = tables::tables(0).expect("selector 0");
let with = |crop| {
let props =
heap(&[(SENSOR_INFO, sensor_info(16, 8, crop, o)), (DECODER_TABLE, decoder_table(0, o)), (MAKE_MODEL, make_model("Canon EOS D60"))], o);
let f = ciff(&[(RAW_DATA, payload(None, &encode_hi(&hi, 16, &tables))), (0x300a, props)], o);
full(&f).expect("decodes").header.active_area
};
assert_eq!(with(Some((4, 1, 15, 7))), Rect::new(4, 1, 12, 7), "inclusive left, top, right, bottom");
assert_eq!(with(None), Rect::new(0, 0, 16, 8), "no crop: the whole sensor");
assert_eq!(with(Some((4, 1, 16, 7))), Rect::new(0, 0, 16, 8), "a crop beyond the sensor is ignored");
assert_eq!(with(Some((5, 1, 4, 7))), Rect::new(0, 0, 16, 8), "an inverted crop is ignored");
}
/// A block whose every record is a sub-heap pointing back at the block itself: depth alone
/// would allow N^MAX_DEPTH visits (here 64^17), the record budget ends the walk promptly.
#[test]
fn self_referencing_sub_heaps_terminate() {
const N: usize = 64;
for o in [Order::Le, Order::Be] {
let len = (2 + N * 10 + 4) as u32;
let mut f = Vec::new();
f.extend_from_slice(match o {
Order::Le => b"II",
Order::Be => b"MM",
});
f.extend_from_slice(&put32(26, o));
f.extend_from_slice(b"HEAPCCDR");
f.extend_from_slice(&put32(0x0001_0002, o));
f.extend_from_slice(&[0; 8]);
f.extend_from_slice(&put16(N as u16, o)); // record table at block offset 0
for _ in 0..N {
f.extend_from_slice(&put16(5 << 11 | 1, o)); // storage class 5: sub-heap
f.extend_from_slice(&put32(len, o)); // ... covering the whole parent block
f.extend_from_slice(&put32(0, o));
}
f.extend_from_slice(&put32(0, o)); // trailing offset-table pointer
let started = std::time::Instant::now();
assert!(matches!(header(&f, &Limits::default()), Err(Error::Corrupt(_))));
assert!(matches!(full(&f), Err(Error::Corrupt(_))));
assert!(started.elapsed() < std::time::Duration::from_secs(5), "walk did not stay within budget");
let mut rec = BTreeMap::new();
let mut budget = MAX_RECORDS;
walk(&f, 26, len as usize, o, 0, &mut budget, &mut rec);
assert_eq!(budget, 0);
assert!(rec.is_empty());
}
}
/// A no-plane G1 frame that compresses well closes with the record's real `ff d9 00` trailer and
/// is still shorter than plane + header — the shape of a lens-cap or blown-out frame. Rule (a)
/// read that `ff d9` as a plane byte, reported 12 bits and then refused the file.
#[test]
fn a_short_powershot_payload_closing_with_the_trailer_has_no_plane() {
let o = Order::Le;
let (w, h) = (64u16, 64u16);
let n = w as usize * h as usize;
let tables = tables::tables(0).expect("selector 0");
let entropy = encode_eob(n, &tables);
let p = payload_with_trailer(None, &entropy);
let span = n.div_ceil(4) + 514;
assert!(p.len() < span, "the payload must be shorter than plane + header: {} vs {span}", p.len());
assert!(p.windows(2).any(|q| q[0] == 0xff && q[1] != 0x00), "the trailer is the unstuffed ff pair");
let f = file_with_payload(w, h, "Canon PowerShot G1", &p, &[], o);
let hdr = header(&f, &Limits::default()).expect("header");
assert_eq!(hdr.bits, 10, "the trailer is not a low-bits plane");
// Every block is one end-of-block token, so the chain values stand (512 at each row start):
// the frame decodes instead of being refused for a plane it does not have.
let img = full(&f).expect("decodes");
assert_eq!((img.header.bits, img.samples.iter().filter(|&&v| v == 512).count()), (10, n));
// The same frame without the trailer was already read as 10-bit.
let f = file_with_payload(w, h, "Canon PowerShot G1", &payload(None, &entropy), &[], o);
assert_eq!(header(&f, &Limits::default()).expect("header").bits, 10);
}
/// An end-of-block token at the head of a block emits the carry and leaves the other 63 samples
/// at their chain values: the runs the sample hashes cover, on a file the unit tests can read.
#[test]
fn end_of_block_keeps_the_chain_values() {
let o = Order::Le;
let (w, h) = (64u16, 64u16);
let n = w as usize * h as usize;
let tables = tables::tables(0).expect("selector 0");
let f = file_with_payload(w, h, "Canon EOS D30", &payload(None, &encode_eob(n, &tables)), &[], o);
let img = full(&f).expect("decodes");
assert_eq!(img.samples.len(), n);
assert!(img.samples.iter().all(|&v| v == 512), "every sample keeps its row's chain value");
}
/// Zero sensor dimensions are damage, not a request for too much memory.
#[test]
fn zero_sensor_dimensions_are_corrupt_not_a_limit() {
let f = roundtrip_file(0, 0, "Canon EOS D60", None, &[], Order::Le);
assert!(matches!(full(&f), Err(Error::Corrupt(_))));
assert!(matches!(header(&f, &Limits::default()), Err(Error::Corrupt(_))));
}
/// A hostile stream can run the chain past the bit depth (here a "10-bit" body writing 1535).
/// `samples` is documented as `bits` deep, so the surplus never reaches a `1 << bits` LUT.
#[test]
fn samples_are_clamped_to_the_declared_depth() {
let f = roundtrip_file(2, 1, "Canon PowerShot G1", None, &[512, 1535], Order::Le);
let img = full(&f).expect("decodes");
assert_eq!((img.header.bits, img.samples.as_slice()), (10, &[512u16, 1023][..]));
// the 12-bit merge of the same chain cannot leave 0..=4095 either
let low = vec![0xffu8; 1];
let f = roundtrip_file(2, 1, "Canon EOS D60", Some(&low), &[512, 1535], Order::Le);
let img = full(&f).expect("decodes");
assert_eq!(img.header.bits, 12);
assert!(img.samples.iter().all(|&v| v <= 4095), "{:?}", img.samples);
}
/// The two ColorBalance layouts and every Canon setting that picks a slot. Every corpus file is
/// set to Auto, so a wrong arm would still pass on the samples alone.
#[test]
fn white_balance_slot_arms_follow_the_camera_setting() {
let o = Order::Le;
// Quadruple k is (100+k, 200, 200, 300+k): the multipliers name the slot chosen, and the last
// quadruple is black levels, which is never a slot.
let wb = |usable: u16, setting: u16| {
let mut vals = vec![(usable + 1) * 8];
for k in 0..=usable {
vals.extend([100 + k, 200, 200, 300 + k]);
}
let mut bytes: Vec<u8> = vals.iter().flat_map(|&v| put16(v, o)).collect();
let cb_len = bytes.len();
let si_off = bytes.len();
let mut shot = [0u16; 8];
shot[7] = setting;
bytes.extend(shot.iter().flat_map(|&v| put16(v, o)));
let mut rec = BTreeMap::new();
rec.insert(COLOR_BALANCE, (0usize, cb_len));
rec.insert(SHOT_INFO, (si_off, 16usize));
wb_from_color_balance(&bytes, &rec, o)
};
let expect = |k: u16| Some([(100 + k) as f32 / 200.0, 1.0, (300 + k) as f32 / 200.0]);
// 10D/300D: Auto, Daylight, Shade, Cloudy, Tungsten, Fluorescent, Flash, Custom, Kelvin
for (setting, slot) in [(0u16, 0u16), (1, 1), (8, 2), (2, 3), (3, 4), (4, 5), (5, 6), (6, 7), (9, 8), (7, 0)] {
assert_eq!(wb(9, setting), expect(slot), "nine-slot setting {setting}");
}
// D60: Auto, Daylight, Cloudy, Tungsten, Fluorescent, Flash, Custom. Shade and Kelvin have no
// slot of their own and fall back to Auto.
for (setting, slot) in [(0u16, 0u16), (1, 1), (2, 2), (3, 3), (4, 4), (5, 5), (6, 6), (8, 0), (9, 0)] {
assert_eq!(wb(7, setting), expect(slot), "seven-slot setting {setting}");
}
}
#[test]
fn errors_display_their_cause() {
assert_eq!(Error::NotCrw.to_string(), "not a Canon CRW (CIFF) file");
assert_eq!(corrupt("no raw data record").to_string(), "CRW: no raw data record");
assert_eq!(Error::Unsupported("decoder table 7".into()).to_string(), "unsupported CRW: decoder table 7");
assert_eq!(Error::LimitExceeded("image too large").to_string(), "CRW exceeds a limit: image too large");
}