Files
photocraft/crates/codecs/tests/malformed.rs
T
47f9306fd0 Open HEIC/HEIF photos (#355) (#372)
* Open HEIC/HEIF photos (#355)

iPhones save HEIC by default and Photoshop opens it; PhotoCraft had no
HEIF code, so .heic was missing from File > Open and failed to decode.

Add Format::Heif to photocraft-codecs, decoded by heic-rs (pure Rust,
forbid(unsafe), MIT OR Apache-2.0, wasm-ready): single pictures and
grid-tiled photos, 8-bit to U8 and 10/12-bit to U16, auxiliary alpha,
ICC, EXIF and XMP. The container's irot/imir/clap are applied and EXIF
Orientation is rewritten to 1, so photos are never turned twice. Limits
are checked against the declared size before decoding. Read-only (no
pure-Rust HEVC encoder), listed in ASYMMETRIC_EXCEPTIONS.

File > Open (desktop, web) and the batch list accept heic/heif/hif.
Tests compare against Apple's own decode and a 16-bit source, cover
orientation, metadata, limits and every truncation; fixtures from
heic-rs and pillow-heif are attributed in ATTRIBUTION.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* HEIF: pin heic-rs exactly and add a decode_heif fuzz target

Review follow-up: heic-rs is young and parses untrusted files, so pin it
(=0.1.1) and fuzz the HEIF path. The target covers both orientation
paths and is meant to run with -timeout so a hang is reported, not
only a panic. It is added to the CI fuzz matrix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* HEIF: a heic-rs panic is a malformed-file error, not a crash

Fuzzing decode_heif found two out-of-range slices in heic-rs 0.1.1 on
malformed files:
- in its box parser (boxes.rs:130), reachable from a 72-byte file, even
  through the metadata-only probe;
- during HEVC reconstruction (hevc/decode/recon.rs:70).

The background job path catches panics, but the synchronous ones (jobs
off, the CLI) did not. The HEIF codec now runs the decoder under
catch_unwind and reports a panic as CodecError::Malformed, as
gpu/health.rs already does around wgpu.

The fuzz target also calls heic-rs directly, so its panics still reach
the fuzzer. The 72-byte input is a regression test, which fails
without the guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* HEIF: move the decoder into an optional photocraft-heif crate

The heic-rs decoding from #372 now lives in `crates/heif` (photocraft-heif, standalone, depends
only on heic-rs =0.1.1): probe, decode with a pixel limit, container transforms, alpha, 8/10-bit,
ICC/EXIF/XMP and the catch_unwind guard, behind a small API of its own (Info, Options, Decoded,
Error). photocraft-codecs uses it behind a new non-default `heif` feature; the apps and
photocraft-io forward it. HEIF detection, extensions and mime stay compiled in, so without the
feature a HEIC file is still recognised and opening it is a clear "HEIC/HEIF support isn't
included in this build of PhotoCraft" error. `cargo xtask layers` allows exactly codecs -> heif
between standalone crates.

The fixtures are no longer committed: `cargo xtask corpus --heif` fetches them from heic-rs
(tests/fixtures, v0.1.1) and pillow-heif (tests/images) at pinned commits, verified against
xtask/heif-corpus.sha256 (same bytes as the PR's copies). The fixture tests run with
`--features corpus,heif` (`cargo xtask test-corpus` enables both).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Build CI and official binaries with HEIC support (--features heif)

CI test and clippy, the FreeBSD job, and the macOS, Linux (and Flatpak), FreeBSD, Windows and
web packaging all build with the `heif` feature; CI also tests codecs/io without it on Linux so
the "not in this build" path stays covered. `cargo xtask wasm` also checks photocraft-codecs with
`--features heif`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Docs: HEIC open is an optional build feature, on in official builds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-07 10:20:40 -04:00

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::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());
}