mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-23 19:55:59 +02:00
Read SMPTE 268M DPX in both byte orders (SDPX, XPDS): the first image element, descriptor 6 (luma), 50 (RGB) or 51 (RGBA), at 8, 10, 12 or 16 bits, with 10-bit packing 0, 1 (method A) or 2 (method B) and 12-bit packing 0 or 1. 10- and 12-bit code values open as U16 round(c * 65535 / (2^b - 1)) and come back exactly when written at their depth. No colour transform: the transfer characteristic and colorimetric specification are reported as import notes; extra image elements are reported; the orientation is applied on import. Write big-endian, one unencoded element, at 8, 10, 12 or 16 bits (EncodeOptions::dpx_bits; `dpxBits` on the save commands, MCP and RPC; `--dpx-bits` in the CLI), following the document depth by default. Gray with alpha is written as luma. Creator, project, copyright, creation time, transfer, colorimetric, frame position, frame rate and time code travel as DPX:* text metadata and are written back. Unsupported descriptors, signed or run-length-encoded data and other bit depths return CodecError::Unsupported; malformed and truncated files return errors. Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
271 lines
10 KiB
Rust
271 lines
10 KiB
Rust
//! Malformed input must produce errors, never panics or huge allocations.
|
|
|
|
mod common;
|
|
use common::*;
|
|
use photocraft_codecs::*;
|
|
use proptest::prelude::*;
|
|
|
|
fn tight() -> DecodeOptions {
|
|
DecodeOptions { limits: Limits { max_width: 4096, max_height: 4096, max_pixels: 1 << 22, max_alloc: 64 << 20 }, ..Default::default() }
|
|
}
|
|
|
|
fn samples() -> Vec<(Format, Vec<u8>)> {
|
|
let mut v = Vec::new();
|
|
for f in rw_formats() {
|
|
for (l, s) in [(ChannelLayout::Rgba, SampleType::U8), (ChannelLayout::Gray, SampleType::U16), (ChannelLayout::Rgb, SampleType::F32)] {
|
|
let mut img = synth(19, 13, l, s, 11, 0.3);
|
|
img.icc = Some(sample_icc(300));
|
|
img.meta.exif = Some(sample_exif());
|
|
img.meta.xmp = Some(SAMPLE_XMP.into());
|
|
v.push((f, encode(&img, f, &EncodeOptions::default()).unwrap()));
|
|
}
|
|
}
|
|
// Read-only formats: Apple-encoded HEIC from `corpus/heif` (`cargo xtask corpus --heif`), a
|
|
// single picture and a grid of tiles.
|
|
#[cfg(all(feature = "corpus", feature = "heif"))]
|
|
for name in ["rgb-strips-96.heic", "checker-1024.heic"] {
|
|
let p = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/heif/heic-rs").join(name);
|
|
let bytes = std::fs::read(&p).unwrap_or_else(|e| panic!("{}: {e}: run `cargo xtask corpus --all`", p.display()));
|
|
v.push((Format::Heif, bytes));
|
|
}
|
|
// Extra variants: interlaced PNG, ASCII PNM, CMYK JPEG.
|
|
let img = synth(19, 13, ChannelLayout::Rgb, SampleType::U8, 1, 0.3);
|
|
v.push((Format::Png, encode(&img, Format::Png, &EncodeOptions { png_interlaced: true, ..Default::default() }).unwrap()));
|
|
v.push((Format::Pnm, b"P3\n2 2\n255\n0 1 2 3 4 5 6 7 8 9 10 11\n".to_vec()));
|
|
v.push((Format::Jpeg, encode(&synth(19, 13, ChannelLayout::Cmyk, SampleType::U8, 1, 0.3), Format::Jpeg, &EncodeOptions::default()).unwrap()));
|
|
v
|
|
}
|
|
|
|
fn no_panic(f: Format, bytes: &[u8]) {
|
|
let _ = decode_with(bytes, &tight());
|
|
let _ = decode_as_with(f, bytes, &tight());
|
|
}
|
|
|
|
#[test]
|
|
fn truncated_at_many_offsets() {
|
|
for (f, bytes) in samples() {
|
|
let n = bytes.len();
|
|
let step = (n / 97).max(1);
|
|
let mut cuts: Vec<usize> = (0..n.min(80)).collect();
|
|
cuts.extend((0..n).step_by(step));
|
|
cuts.extend(n.saturating_sub(40)..n);
|
|
for cut in cuts {
|
|
no_panic(f, &bytes[..cut]);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn truncated_files_mostly_error() {
|
|
// Chopping off the second half must not silently succeed for formats
|
|
// with length-checked rasters.
|
|
for (f, bytes) in samples() {
|
|
if matches!(f, Format::Png | Format::Pnm | Format::Fits | Format::Dpx | Format::Qoi | Format::OpenExr | Format::Tiff | Format::Bmp) {
|
|
let r = decode_as_with(f, &bytes[..bytes.len() / 2], &tight());
|
|
assert!(r.is_err(), "{f:?} decoded half a file");
|
|
}
|
|
// JPEG decodes leniently (the missing part grey) but never silently (#518).
|
|
if f == Format::Jpeg {
|
|
match decode_as_with(f, &bytes[..bytes.len() / 2], &tight()) {
|
|
Ok(img) => assert_eq!(img.warnings, [DecodeWarning::Truncated { format: f }]),
|
|
Err(e) => assert!(e.to_string().contains("before any image data"), "{e}"),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn bit_flips_never_panic() {
|
|
let mut rng = common::Rng::new(42);
|
|
for (f, bytes) in samples() {
|
|
for _ in 0..150 {
|
|
let mut b = bytes.clone();
|
|
let flips = 1 + (rng.next_u64() % 4) as usize;
|
|
for _ in 0..flips {
|
|
let i = (rng.next_u64() as usize) % b.len();
|
|
b[i] ^= 1 << (rng.next_u64() % 8);
|
|
}
|
|
no_panic(f, &b);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn byte_overwrites_in_header_never_panic() {
|
|
for (f, bytes) in samples() {
|
|
for i in 0..bytes.len().min(64) {
|
|
for v in [0x00, 0x7F, 0xFF] {
|
|
let mut b = bytes.clone();
|
|
b[i] = v;
|
|
no_panic(f, &b);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn empty_input_errors_for_every_format() {
|
|
for f in Format::ALL {
|
|
assert!(decode_as(f, &[]).is_err(), "{f:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pnm_specific_malformations() {
|
|
let cases: &[&[u8]] = &[
|
|
b"P6\n",
|
|
b"P6\n2 2\n",
|
|
b"P6\n2 2\n255",
|
|
b"P6\n2 2\n0\n",
|
|
b"P6\n2 2\n70000\n",
|
|
b"P6\n-2 2\n255\n",
|
|
b"P6\n2 2\n255\n\x01\x02",
|
|
b"P5\n2 2\n65535\n\x00\x00\x00",
|
|
b"P2\n2 2\n10\n1 2 3 11\n",
|
|
b"P1\n2 2\n0 1 2 0\n",
|
|
b"P4\n9 2\n\x00",
|
|
b"P7\nWIDTH 2\nHEIGHT 2\nDEPTH 9\nMAXVAL 255\nENDHDR\n",
|
|
b"P7\nWIDTH 2\nHEIGHT 2\nMAXVAL 255\nENDHDR\n",
|
|
b"P7\nWIDTH 2\nBOGUS 3\nENDHDR\n",
|
|
b"PF\n2 2\n0.0\n",
|
|
b"PF\n2 2\nnan\n",
|
|
b"PF\n2 2\n-1.0\n\0\0\0\0",
|
|
b"P6 4294967295 4294967295 255\n",
|
|
];
|
|
for c in cases {
|
|
assert!(decode(c).is_err(), "{:?}", String::from_utf8_lossy(c));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn pnm_valid_variants() {
|
|
let img = decode(b"P1\n# comment\n3 2\n1 0 1\n0 1 0\n").unwrap();
|
|
assert_eq!(img.data(), &[0, 255, 0, 255, 0, 255]);
|
|
let img = decode(b"P1\n3 2\n101010").unwrap();
|
|
assert_eq!(img.data(), &[0, 255, 0, 255, 0, 255]);
|
|
let img = decode(b"P4\n3 2\n\xA0\x40").unwrap();
|
|
assert_eq!(img.data(), &[0, 255, 0, 255, 0, 255]);
|
|
let img = decode(b"P2\n2 1\n15\n0 15\n").unwrap();
|
|
assert_eq!(img.data(), &[0, 255]);
|
|
let img = decode(b"P2\n2 1\n1023\n0 1023\n").unwrap();
|
|
assert_eq!(img.sample_type(), SampleType::U16);
|
|
assert_eq!(img.to_u16_samples().unwrap(), vec![0, 65535]);
|
|
let img = decode(b"P3\n1 1\n255\n1 2 3\n").unwrap();
|
|
assert_eq!((img.layout(), img.data()), (ChannelLayout::Rgb, &[1u8, 2, 3][..]));
|
|
let img = decode(b"P7\nWIDTH 1\nHEIGHT 1\nDEPTH 2\nMAXVAL 255\nTUPLTYPE GRAYSCALE_ALPHA\nENDHDR\n\x10\x20").unwrap();
|
|
assert_eq!((img.layout(), img.data()), (ChannelLayout::GrayA, &[0x10u8, 0x20][..]));
|
|
let mut pfm = b"PF\n1 2\n1.0\n".to_vec(); // big-endian, bottom-up
|
|
for v in [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0] {
|
|
pfm.extend_from_slice(&v.to_be_bytes());
|
|
}
|
|
let img = decode(&pfm).unwrap();
|
|
assert_eq!(img.to_f32_samples().unwrap(), vec![4.0, 5.0, 6.0, 1.0, 2.0, 3.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn jpeg_marker_garbage() {
|
|
let cases: &[&[u8]] = &[
|
|
&[0xFF, 0xD8, 0xFF],
|
|
&[0xFF, 0xD8, 0xFF, 0xE0, 0x00],
|
|
&[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x01],
|
|
&[0xFF, 0xD8, 0xFF, 0xE2, 0x00, 0x10, b'I', b'C', b'C', b'_', b'P', b'R', b'O', b'F', b'I', b'L', b'E', 0, 1, 1],
|
|
&[0xFF, 0xD8, 0xFF, 0xC0, 0x00, 0x0B, 8, 0xFF, 0xFF, 0xFF, 0xFF, 3, 0, 0, 0],
|
|
&[0xFF, 0xD8, 0xFF, 0xD9],
|
|
];
|
|
for c in cases {
|
|
assert!(decode(c).is_err());
|
|
}
|
|
}
|
|
|
|
proptest! {
|
|
#![proptest_config(ProptestConfig { cases: 400, .. ProptestConfig::default() })]
|
|
|
|
#[test]
|
|
fn random_bytes_never_panic(data in proptest::collection::vec(any::<u8>(), 0..2048)) {
|
|
let _ = decode_with(&data, &tight());
|
|
}
|
|
|
|
#[test]
|
|
fn random_bytes_with_magic_never_panic(idx in 0usize..14, data in proptest::collection::vec(any::<u8>(), 0..1024)) {
|
|
let magics: [&[u8]; 14] = [
|
|
b"\x89PNG\r\n\x1a\n", &[0xFF, 0xD8, 0xFF], b"II*\0", b"RIFF\0\0\0\0WEBP", b"GIF89a", b"BM",
|
|
&[0, 0, 1, 0, 1, 0], b"P6\n", b"qoif", &[0x76, 0x2F, 0x31, 0x01], b"#?RADIANCE\n", b"P7\n", b"PF\n",
|
|
b"\0\0\0\x18ftypheic\0\0\0\0mif1heic",
|
|
];
|
|
let mut b = magics[idx].to_vec();
|
|
b.extend_from_slice(&data);
|
|
let _ = decode_with(&b, &tight());
|
|
}
|
|
|
|
#[test]
|
|
fn random_bytes_each_format_never_panic(idx in 0usize..Format::ALL.len(), data in proptest::collection::vec(any::<u8>(), 0..512)) {
|
|
let _ = decode_as_with(Format::ALL[idx], &data, &tight());
|
|
}
|
|
|
|
#[test]
|
|
fn mutated_valid_files_never_panic(which in 0usize..40, pos in any::<usize>(), val in any::<u8>(), cut in any::<usize>()) {
|
|
let s = samples_cached();
|
|
let (f, bytes) = &s[which % s.len()];
|
|
let mut b = bytes.clone();
|
|
let p = pos % b.len();
|
|
b[p] = val;
|
|
let c = cut % (b.len() + 1);
|
|
let _ = decode_as_with(*f, &b[..c.max(p + 1).min(b.len())], &tight());
|
|
}
|
|
}
|
|
|
|
fn samples_cached() -> &'static [(Format, Vec<u8>)] {
|
|
static S: std::sync::OnceLock<Vec<(Format, Vec<u8>)>> = std::sync::OnceLock::new();
|
|
S.get_or_init(samples)
|
|
}
|
|
|
|
/// A little-endian TIFF with one 1x1 8-bit grey strip and the given extra IFD entries
|
|
/// (tag, type, count, value), sorted into place.
|
|
fn tiny_tiff(extra: &[(u16, u16, u32, u32)]) -> Vec<u8> {
|
|
const SHORT: u16 = 3;
|
|
const LONG: u16 = 4;
|
|
let mut entries = vec![
|
|
(256, SHORT, 1, 1), // ImageWidth
|
|
(257, SHORT, 1, 1), // ImageLength
|
|
(258, SHORT, 1, 8), // BitsPerSample
|
|
(259, SHORT, 1, 1), // Compression: none
|
|
(262, SHORT, 1, 1), // Photometric: BlackIsZero
|
|
(273, LONG, 1, 0), // StripOffsets (patched below)
|
|
(277, SHORT, 1, 1), // SamplesPerPixel
|
|
(278, SHORT, 1, 1), // RowsPerStrip
|
|
(279, LONG, 1, 1), // StripByteCounts
|
|
];
|
|
entries.extend_from_slice(extra);
|
|
entries.sort_by_key(|e| e.0);
|
|
let ifd_len = 2 + 12 * entries.len() + 4;
|
|
let strip = 8 + ifd_len as u32;
|
|
let mut b = b"II*\0".to_vec();
|
|
b.extend_from_slice(&8u32.to_le_bytes());
|
|
b.extend_from_slice(&(entries.len() as u16).to_le_bytes());
|
|
for (tag, ty, count, value) in entries {
|
|
let value = if tag == 273 { strip } else { value };
|
|
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(&value.to_le_bytes());
|
|
}
|
|
b.extend_from_slice(&0u32.to_le_bytes());
|
|
b.push(0x80);
|
|
b
|
|
}
|
|
|
|
#[test]
|
|
fn tiff_fixture_decodes() {
|
|
let img = decode_as_with(Format::Tiff, &tiny_tiff(&[]), &tight()).unwrap();
|
|
assert_eq!((img.width(), img.height()), (1, 1));
|
|
}
|
|
|
|
/// Regression: an empty SampleFormat tag made the tiff 0.10 decoder index an empty list
|
|
/// (`index out of bounds: the len is 0`). Found by mutation fuzzing; fixed by tiff 0.11.
|
|
#[test]
|
|
fn tiff_empty_sample_format_is_an_error() {
|
|
let bytes = tiny_tiff(&[(339, 3, 0, 0)]);
|
|
assert!(decode_as_with(Format::Tiff, &bytes, &tight()).is_err());
|
|
assert!(decode_with(&bytes, &tight()).is_err());
|
|
}
|