diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 05824eb0..18743ad0 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -14,6 +14,7 @@ let d: Decoded = decode(&bytes, DecodeOptions::default())?; let working: Rgb32f = to_working(&d); // linear Rec.2020 D65 (Bradford) let fast = decode(&bytes, DecodeOptions::fit(2048, 2048))?; // JPEG: DCT-domain 1/2..1/8 scaling let t: Thumbnail = decode_thumbnail(&bytes, 256)?; // embedded EXIF/MPF preview or scaled decode +let h: Header = read_header(&bytes)?; // stored width/height + orientation, no pixels decoded let icc = icc::write_named(NamedSpace::DisplayP3); // or icc::write_matrix_trc(&space, &Trc::…) let meta = EncodeMeta { icc: Some(&icc), exif: Some(&exif), xmp: Some(&xmp) }; diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index ce8712ad..55d4f682 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -108,6 +108,57 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { decode_with_fallback(bytes, opts, None) } +/// Stored dimensions and EXIF orientation from the markers, without decoding the scan data. The +/// file is refused, as a decode would refuse it, when it has no frame header, an unsupported frame +/// type or component count, or when its markers don't run to an end-of-image marker after the +/// image data (a truncated file). Damage *inside* the entropy-coded data can't be seen this way. +pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> { + let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?; + if m.components == 0 { + return Err(Error::Malformed(F, "no frame header".into())); + } + check_size(F, m.width as u64, m.height as u64, &DecodeOptions::default())?; + if !matches!(m.sof, 0xC0..=0xC3) { + return Err(Error::Unsupported(F, "arithmetic-coded or hierarchical JPEG")); + } + if !matches!(m.components, 1 | 3 | 4) { + return Err(Error::Malformed(F, format!("{} colour components", m.components))); + } + if m.first_scan_components == 0 || !reaches_eoi(bytes) { + return Err(Error::Malformed(F, "truncated: the image data doesn't end with an end-of-image marker".into())); + } + let orientation = m.exif.as_deref().map(exif::summarize).unwrap_or_default().orientation.unwrap_or(1); + Ok((m.width, m.height, orientation)) +} + +/// Whether the markers after SOI, followed through every segment and scan, reach an EOI marker +/// (the first one: the primary image's, ahead of any MPF images appended to the file). Segments +/// are skipped by their length; in scan data `FF 00` (stuffing), `FF FF` (fill) and RSTn are not +/// markers that end it. +fn reaches_eoi(b: &[u8]) -> bool { + let mut p = 2usize; + loop { + // entropy-coded data (or garbage between segments, as `parse_markers` tolerates it) + let Some(skip) = b.get(p..).and_then(find_ff) else { return false }; + p = p.saturating_add(skip); + let Some(&marker) = b.get(p.saturating_add(1)) else { return false }; + p = match marker { + 0xD9 => return true, + 0xFF => p.saturating_add(1), + 0x00 | 0x01 | 0xD0..=0xD8 => p.saturating_add(2), + _ => { + let Some(len) = b.get(p.saturating_add(2)..p.saturating_add(4)).map(|l| u16::from_be_bytes([l[0], l[1]]) as usize) else { + return false; + }; + if len < 2 { + return false; + } + p.saturating_add(2).saturating_add(len) + } + }; + } +} + pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: Option) -> Result { let m = parse_markers(bytes).ok_or_else(|| Error::Malformed(F, "missing SOI".into()))?; if m.components == 0 { @@ -175,6 +226,21 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth }) } +/// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this +/// is most of [`header`]'s time). +fn find_ff(b: &[u8]) -> Option { + const ONES: u64 = 0x0101_0101_0101_0101; + let (words, tail) = b.as_chunks::<8>(); + for (i, w) in words.iter().enumerate() { + // a zero byte in !w is an 0xFF byte in w + let x = !u64::from_ne_bytes(*w); + if x.wrapping_sub(ONES) & !x & (ONES << 7) != 0 { + return w.iter().position(|&v| v == 0xFF).map(|j| i * 8 + j); + } + } + tail.iter().position(|&v| v == 0xFF).map(|j| words.len() * 8 + j) +} + /// The box to request from `jpeg-decoder::scale` so the decoded image still covers `mw × mh` once /// fitted with the source aspect ratio. fn scaled_request(w: u32, h: u32, mw: u32, mh: u32) -> (u16, u16) { @@ -297,6 +363,20 @@ mod tests { assert_eq!(scaled_request(100, 100, 256, 256), (100, 100)); } + #[test] + fn find_ff_matches_a_plain_search() { + let mut b: Vec = (0..300u32).map(|i| (i * 7 % 255) as u8).collect(); + for at in [None, Some(0), Some(7), Some(8), Some(15), Some(203), Some(296), Some(299)] { + if let Some(i) = at { + b[i] = 0xFF; + } + for start in [0, 1, 5, 9, 290, 299, 300] { + let s = &b[start..]; + assert_eq!(find_ff(s), s.iter().position(|&v| v == 0xFF), "{at:?} from {start}"); + } + } + } + #[test] fn markers_on_garbage() { assert!(parse_markers(b"").is_none()); diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 0328f33c..15990329 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -6,6 +6,8 @@ //! profiles (matrix/TRC exactly, LUT/CMYK via the `moxcms` CMS), 16-bit and float precision are //! preserved. EXIF/XMP/ICC blobs are passed through untouched; orientation is reported, never applied. //! - [`to_working`] converts a decode result to linear Rec.2020 D65 (the pipeline working space). +//! - [`read_header`] reads a file's stored dimensions and orientation from its headers, without +//! decoding pixels (import probes). //! - [`decode_thumbnail`] is the fast path for grid thumbnails (EXIF thumbnail or DCT-scaled decode). //! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and (native, feature `avif`) AVIF, embedding //! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export. @@ -156,6 +158,52 @@ pub fn decode(bytes: &[u8], opts: DecodeOptions) -> Result { std::panic::catch_unwind(|| decode_unguarded(bytes, format, &opts)).unwrap_or_else(|_| Err(Error::Malformed(format, "decoder panicked".into()))) } +/// What a file's headers say about its image: enough to catalogue it without decoding pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Header { + pub format: Format, + /// Full-resolution stored dimensions, orientation **not** applied ([`Decoded::source_width`]). + pub width: u32, + pub height: u32, + /// EXIF/TIFF orientation 1..=8, 1 when absent ([`Decoded::orientation`]). + pub orientation: u16, +} + +/// Read the stored dimensions and orientation of any format [`decode`] supports, agreeing with +/// what a decode reports, without decoding pixels: JPEG, PNG, TIFF, WebP and PSD are read from +/// their headers (markers, chunks, IFD, resources). GIF, BMP and JPEG XL fall back to a small +/// decode. +/// +/// The header readers also refuse what a decode would refuse before reaching the pixels (an +/// unsupported layout, compression or frame type) and files whose image data is cut short +/// (truncated: the data runs past the end of the file, or a JPEG's markers don't reach its +/// end-of-image marker). Damage *inside* compressed data that is all present is only found by a +/// decode. Malformed input yields an error, never a panic (as for [`decode`], a panic inside a +/// third-party parser is caught on unwinding targets). +pub fn read_header(bytes: &[u8]) -> Result
{ + let format = sniff(bytes).ok_or(Error::UnknownFormat)?; + std::panic::catch_unwind(|| read_header_unguarded(bytes, format)) + .unwrap_or_else(|_| Err(Error::Malformed(format, "header reader panicked".into()))) +} + +/// [`read_header`] without the panic guard (for fuzzing our own code paths). +#[doc(hidden)] +pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result
{ + let (width, height, orientation) = match format { + Format::Jpeg => jpeg::header(bytes)?, + Format::Png => png_codec::header(bytes)?, + Format::Tiff => tiff_codec::header(bytes)?, + Format::WebP => webp::header(bytes)?, + Format::Psd => psd::header(bytes)?, + // rare here (or, for JPEG XL, without a header reader yet): a small decode, as before + _ => { + let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?; + (d.source_width, d.source_height, d.orientation) + } + }; + Ok(Header { format, width, height, orientation }) +} + /// Decode a JPEG preview with its enclosing container's colour space as a fallback. /// A JPEG carrying ICC or EXIF metadata keeps its own interpretation. The fallback is applied /// before transfer decoding and linear-light resizing, never to already-resampled pixels. diff --git a/crates/codecs/src/png_codec.rs b/crates/codecs/src/png_codec.rs index 529d120d..600f60b0 100644 --- a/crates/codecs/src/png_codec.rs +++ b/crates/codecs/src/png_codec.rs @@ -57,6 +57,75 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { finish(F, raw, meta, (w, h), opts) } +/// Stored dimensions and EXIF orientation (`eXIf`, wherever it is) from the chunk headers, without +/// inflating the image data. Refused, as a decode would refuse it: an invalid IHDR, an indexed +/// image without a palette, no IDAT, or a file that ends before the chunk after the last IDAT +/// begins (truncated image data). Chunk CRCs and the deflate stream are not checked. +pub(crate) fn header(b: &[u8]) -> Result<(u32, u32, u16)> { + if !b.starts_with(b"\x89PNG\r\n\x1a\n") { + return Err(err("bad signature")); + } + let be32 = |p: usize| b.get(p..p.checked_add(4)?).map(|v| u32::from_be_bytes([v[0], v[1], v[2], v[3]])); + if be32(8) != Some(13) || b.get(12..16) != Some(b"IHDR") { + return Err(err("missing IHDR")); + } + let ihdr = b.get(16..29).ok_or_else(|| err("truncated IHDR"))?; + let (w, h) = (u32::from_be_bytes([ihdr[0], ihdr[1], ihdr[2], ihdr[3]]), u32::from_be_bytes([ihdr[4], ihdr[5], ihdr[6], ihdr[7]])); + let (depth, color, compression, filter, interlace) = (ihdr[8], ihdr[9], ihdr[10], ihdr[11], ihdr[12]); + if w > i32::MAX as u32 || h > i32::MAX as u32 { + return Err(err("dimensions out of range")); + } + check_size(F, w as u64, h as u64, &DecodeOptions::default())?; + let depth_ok = match color { + 0 => matches!(depth, 1 | 2 | 4 | 8 | 16), + 3 => matches!(depth, 1 | 2 | 4 | 8), + 2 | 4 | 6 => matches!(depth, 8 | 16), + _ => false, + }; + if !depth_ok || compression != 0 || filter != 0 || interlace > 1 { + return Err(err("invalid IHDR")); + } + let (mut p, mut palette, mut idat, mut after_idat, mut exif) = (8usize, false, false, false, None); + while let Some(len) = be32(p) { + let Some(kind) = p.checked_add(4).and_then(|k| b.get(k..k.checked_add(4)?)) else { break }; + if idat && kind != b"IDAT" { + // a chunk follows the image data: the IDAT run is complete + after_idat = true; + } + if kind == b"IEND" { + break; + } + let start = p.saturating_add(8); + let data = (len <= i32::MAX as u32).then(|| start.checked_add(len as usize).and_then(|end| b.get(start..end))).flatten(); + let Some(data) = data else { + if after_idat { + break; // a damaged chunk after the image data: a decode ignores it too + } + return Err(err("truncated")); + }; + match kind { + b"PLTE" => palette = true, + b"IDAT" if after_idat => {} // a stray IDAT after other chunks: the first run is the image + b"IDAT" => { + if color == 3 && !palette { + return Err(err("indexed image without a palette")); + } + idat = true; + } + b"eXIf" if exif.is_none() => exif = Some(data.strip_prefix(b"Exif\0\0").unwrap_or(data)), + _ => {} + } + p = start.saturating_add(data.len()).saturating_add(4); + } + if !idat { + return Err(err("no image data")); + } + if !after_idat { + return Err(err("truncated image data")); + } + Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1))) +} + fn container_hint(info: &png::Info) -> Option { if let Some(c) = &info.coding_independent_code_points { let named = match c.color_primaries { diff --git a/crates/codecs/src/psd.rs b/crates/codecs/src/psd.rs index de668915..86cb2008 100644 --- a/crates/codecs/src/psd.rs +++ b/crates/codecs/src/psd.rs @@ -182,6 +182,79 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (width as u32, height as u32), opts) } +/// Stored dimensions and EXIF orientation (resource 1058) from the header and image resources, +/// without reading the merged image data. Refused, as a decode would refuse it: an unsupported +/// depth, colour mode or compression, or merged image data that runs past the end of the file +/// (truncated). The run-length data itself is not unpacked. +pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> { + let mut r = Rd { b: bytes, p: 0 }; + if r.take(4)? != b"8BPS" { + return Err(err("bad signature")); + } + let psb = match r.u16()? { + 1 => false, + 2 => true, + _ => return Err(err("bad version")), + }; + r.take(6)?; + let channels = r.u16()? as usize; + let height = r.u32()? as usize; + let width = r.u32()? as usize; + let depth = r.u16()?; + let mode = r.u16()?; + check_size(F, width as u64, height as u64, &DecodeOptions::default())?; + if channels == 0 || channels > 56 { + return Err(err("bad channel count")); + } + let cm_len = r.u32()? as usize; + let color_mode_data = r.take(cm_len)?; + let res_len = r.u32()? as usize; + let (_, exif, _) = resources(r.take(res_len)?); + let lm_len = if psb { r.u64()? as usize } else { r.u32()? as usize }; + r.take(lm_len)?; + let compression = r.u16()?; + let bpc = match depth { + 8 => 1usize, + 16 => 2, + 32 => 4, + 1 => return Err(Error::Unsupported(F, "1-bit bitmap PSD")), + _ => return Err(err("bad depth")), + }; + let n_color = match mode { + 1 | 8 | 7 | 2 => 1, + 3 => 3, + 4 => 4, + 9 => return Err(Error::Unsupported(F, "Lab PSD")), + _ => return Err(err("unknown colour mode")), + }; + if channels < n_color { + return Err(err("too few channels")); + } + if mode == 2 && (color_mode_data.len() < 768 || depth != 8) { + return Err(err("bad indexed palette")); + } + let rest = bytes.len().saturating_sub(r.p); + let need = match compression { + 0 => width.checked_mul(height).and_then(|n| n.checked_mul(bpc)).and_then(|n| n.checked_mul(channels)), + 1 => { + // the row byte counts, then the rows they count + let rows = channels.checked_mul(height).ok_or_else(|| err("truncated"))?; + let table = rows.checked_mul(if psb { 4 } else { 2 }).filter(|&t| t <= rest).ok_or_else(|| err("truncated"))?; + let mut total = table; + for _ in 0..rows { + total = total.saturating_add(if psb { r.u32()? as usize } else { r.u16()? as usize }); + } + Some(total) + } + _ => return Err(Error::Unsupported(F, "ZIP-compressed merged image data")), + }; + if need.is_none_or(|n| n > rest) { + return Err(err("truncated")); + } + let orientation = exif.as_deref().map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1); + Ok((width as u32, height as u32, orientation)) +} + /// PackBits: append exactly `row_bytes` bytes (zero-padded if the run data is short). fn unpackbits(mut src: &[u8], row_bytes: usize, out: &mut Vec) { let target = out.len() + row_bytes; diff --git a/crates/codecs/src/tiff_codec.rs b/crates/codecs/src/tiff_codec.rs index 7de56c43..b077c214 100644 --- a/crates/codecs/src/tiff_codec.rs +++ b/crates/codecs/src/tiff_codec.rs @@ -15,10 +15,7 @@ fn err(e: impl std::fmt::Display) -> Error { } pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { - let mut limits = Limits::default(); - limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize; - limits.ifd_value_size = 32 << 20; - let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits); + let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(opts)); let (w, h) = d.dimensions().map_err(err)?; check_size(F, w as u64, h as u64, opts)?; let ct = d.colortype().map_err(err)?; @@ -31,23 +28,7 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { let colormap = d.get_tag_u16_vec(Tag::ColorMap).ok(); let compression = d.get_tag_u32(Tag::Compression).ok(); - let (model, n_color, n_samples, bits) = match ct { - ColorType::Gray(b) => (Model::Gray, 1, 1, b), - ColorType::GrayA(b) => (Model::Gray, 1, 2, b), - ColorType::RGB(b) => (Model::Rgb, 3, 3, b), - ColorType::RGBA(b) => (Model::Rgb, 3, 4, b), - ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b), - ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b), - ColorType::Palette(b) => (Model::Rgb, 1, 1, b), - // JPEG-compressed YCbCr is converted to RGB by the JPEG decoder. - ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b), - ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth), - ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth), - other => return Err(Error::Unsupported(F, unsupported_name(other))), - }; - if bits < 8 && planar && n_samples > 1 { - return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte")); - } + let (model, n_color, n_samples, bits) = layout(ct, compression, planar)?; let has_alpha = n_samples > n_color && !matches!(ct, ColorType::Palette(_)); let premultiplied = has_alpha && extra.first() == Some(&1); @@ -111,6 +92,67 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { finish(F, raw, meta, (w, h), opts) } +fn limits(opts: &DecodeOptions) -> Limits { + let mut limits = Limits::default(); + limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize; + limits.ifd_value_size = 32 << 20; + limits +} + +/// The sample layout of a colour type we can decode: (model, colour samples, samples per pixel, +/// bits per sample). +fn layout(ct: ColorType, compression: Option, planar: bool) -> Result<(Model, usize, usize, u8)> { + let l = match ct { + ColorType::Gray(b) => (Model::Gray, 1, 1, b), + ColorType::GrayA(b) => (Model::Gray, 1, 2, b), + ColorType::RGB(b) => (Model::Rgb, 3, 3, b), + ColorType::RGBA(b) => (Model::Rgb, 3, 4, b), + ColorType::CMYK(b) => (Model::Cmyk, 4, 4, b), + ColorType::CMYKA(b) => (Model::Cmyk, 4, 5, b), + ColorType::Palette(b) => (Model::Rgb, 1, 1, b), + // JPEG-compressed YCbCr is converted to RGB by the JPEG decoder. + ColorType::YCbCr(b) if compression == Some(7) => (Model::Rgb, 3, 3, b), + ColorType::Multiband { bit_depth, num_samples } if num_samples >= 3 => (Model::Rgb, 3, num_samples as usize, bit_depth), + ColorType::Multiband { bit_depth, num_samples } if num_samples >= 1 => (Model::Gray, 1, num_samples as usize, bit_depth), + other => return Err(Error::Unsupported(F, unsupported_name(other))), + }; + if l.3 < 8 && planar && l.2 > 1 { + return Err(Error::Unsupported(F, "planar TIFF with samples narrower than a byte")); + } + Ok(l) +} + +/// Stored dimensions and orientation from the first IFD, without reading the image data. Refused, +/// as a decode would refuse it: a layout or compression we can't decode, or strips/tiles that +/// start past the end of the file (truncated). The compressed data itself is not checked. +pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> { + let mut d = Decoder::new(std::io::Cursor::new(bytes)).map_err(err)?.with_limits(limits(&DecodeOptions::default())); + let (w, h) = d.dimensions().map_err(err)?; + check_size(F, w as u64, h as u64, &DecodeOptions::default())?; + let ct = d.colortype().map_err(err)?; + let compression = d.get_tag_u32(Tag::Compression).ok(); + let planar = d.get_tag_u32(Tag::PlanarConfiguration).ok() == Some(2); + layout(ct, compression, planar)?; + // what this build of the `tiff` crate decompresses: none, CCITT G4, LZW, JPEG, Deflate, PackBits + if !matches!(compression.unwrap_or(1), 1 | 4 | 5 | 7 | 8 | 32773 | 32946) { + return Err(Error::Unsupported(F, "compression method")); + } + let chunks = |offsets: Tag, counts: Tag, d: &mut Decoder>| { + Some((d.get_tag_u64_vec(offsets).ok()?, d.get_tag_u64_vec(counts).ok()?)) + }; + let (offsets, counts) = chunks(Tag::StripOffsets, Tag::StripByteCounts, &mut d) + .or_else(|| chunks(Tag::TileOffsets, Tag::TileByteCounts, &mut d)) + .ok_or_else(|| err("no image data"))?; + // Only where each chunk starts: some writers overstate StripByteCounts, and the `tiff` crate + // decodes such files (it stops once it has the pixels), so a chunk's end proves nothing. + let len = bytes.len() as u64; + if offsets.is_empty() || offsets.iter().zip(&counts).any(|(&o, &c)| c > 0 && o >= len) { + return Err(err("truncated: image data past the end of the file")); + } + let orientation = d.get_tag_u32(Tag::Orientation).ok().map(|v| v as u16).filter(|o| (1..=8).contains(o)).unwrap_or(1); + Ok((w, h, orientation)) +} + /// A BYTE/UNDEFINED tag as bytes (the decoder may surface unknown byte tags as unsigned values). fn tag_bytes(d: &mut Decoder, tag: Tag) -> Option> { use tiff::decoder::ifd::Value; diff --git a/crates/codecs/src/webp.rs b/crates/codecs/src/webp.rs index 8da56101..189146ab 100644 --- a/crates/codecs/src/webp.rs +++ b/crates/codecs/src/webp.rs @@ -27,3 +27,30 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { let raw = Raw { width: w as usize, height: h as usize, model: Model::Rgb, alpha, premultiplied: false, buf: Buf::U8(buf), bit_depth: 8 }; finish(F, raw, Meta { icc, exif, xmp, ..Default::default() }, (w, h), opts) } + +/// Stored dimensions and EXIF orientation from the RIFF chunks and the bitstream header, without +/// decoding the image. Refused, as a decode would refuse it: no image chunk (`VP8 `, `VP8L` or an +/// animation frame), or one that runs past the end of the file (truncated). The compressed data +/// itself is not checked. +pub(crate) fn header(bytes: &[u8]) -> Result<(u32, u32, u16)> { + let mut p = 12usize; + loop { + let Some(h) = bytes.get(p..p.saturating_add(8)) else { return Err(err("no image data")) }; + let size = u32::from_le_bytes([h[4], h[5], h[6], h[7]]) as usize; + let end = p.saturating_add(8).saturating_add(size); + if matches!(&h[0..4], b"VP8 " | b"VP8L" | b"ANMF") { + if end > bytes.len() { + return Err(err("truncated image data")); + } + break; + } + p = end.saturating_add(size & 1); + } + let mut d = image_webp::WebPDecoder::new(std::io::Cursor::new(bytes)).map_err(err)?; + d.set_memory_limit(bytes.len()); + let (w, h) = d.dimensions(); + check_size(F, w as u64, h as u64, &DecodeOptions::default())?; + let exif = d.exif_metadata().ok().flatten(); + let exif = exif.as_deref().map(|e| e.strip_prefix(b"Exif\0\0").unwrap_or(e)); + Ok((w, h, exif.map(crate::exif::summarize).unwrap_or_default().orientation.unwrap_or(1))) +} diff --git a/crates/codecs/tests/fuzz.rs b/crates/codecs/tests/fuzz.rs index cf858d6b..15b236b0 100644 --- a/crates/codecs/tests/fuzz.rs +++ b/crates/codecs/tests/fuzz.rs @@ -30,8 +30,10 @@ 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); } diff --git a/crates/codecs/tests/header.rs b/crates/codecs/tests/header.rs new file mode 100644 index 00000000..7b67478a --- /dev/null +++ b/crates/codecs/tests/header.rs @@ -0,0 +1,363 @@ +//! `read_header` (import probes) agrees with a decode on dimensions and orientation, refuses what a +//! decode refuses (truncated files included), and returns errors — never panics — on hostile input. + +use lightcraft_codecs::exif::minimal_exif; +use lightcraft_codecs::*; +use lightcraft_raster::Rgba8; + +fn pixels(w: usize, h: usize) -> Rgba8 { + Rgba8::from_fn(w, h, |x, y| [(x * 37 % 256) as u8, (y * 53 % 256) as u8, ((x ^ y) * 9 % 256) as u8, (128 + x % 100) as u8]) +} + +fn meta(exif: &Option>) -> EncodeMeta<'_> { + EncodeMeta { exif: exif.as_deref(), xmp: Some(""), ..Default::default() } +} + +/// A TIFF with an Orientation tag (`encode_tiff` writes none), via the `tiff` crate's encoder. +fn tiff_oriented(img: &Rgba8, orientation: u16) -> Vec { + use tiff::encoder::{TiffEncoder, colortype}; + let rgb: Vec = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); + let mut cur = std::io::Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut cur).unwrap(); + let mut im = enc.new_image::(img.width as u32, img.height as u32).unwrap(); + im.encoder().write_tag(tiff::tags::Tag::Orientation, orientation).unwrap(); + im.write_data(&rgb).unwrap(); + cur.into_inner() +} + +/// A minimal 8-bit RGB PSD (raw or PackBits-literal merged data) with an EXIF resource. +fn psd(w: usize, h: usize, orientation: Option, rle: bool) -> Vec { + let mut b = Vec::new(); + b.extend_from_slice(b"8BPS"); + b.extend_from_slice(&1u16.to_be_bytes()); + b.extend_from_slice(&[0; 6]); + b.extend_from_slice(&3u16.to_be_bytes()); + b.extend_from_slice(&(h as u32).to_be_bytes()); + b.extend_from_slice(&(w as u32).to_be_bytes()); + b.extend_from_slice(&8u16.to_be_bytes()); + b.extend_from_slice(&3u16.to_be_bytes()); + b.extend_from_slice(&0u32.to_be_bytes()); // colour mode data + let mut res = Vec::new(); + if let Some(o) = orientation { + let exif = minimal_exif(o); + res.extend_from_slice(b"8BIM"); + res.extend_from_slice(&1058u16.to_be_bytes()); + res.extend_from_slice(&[0, 0]); // empty pascal name, padded + res.extend_from_slice(&(exif.len() as u32).to_be_bytes()); + res.extend_from_slice(&exif); + if exif.len() % 2 == 1 { + res.push(0); + } + } + b.extend_from_slice(&(res.len() as u32).to_be_bytes()); + b.extend_from_slice(&res); + b.extend_from_slice(&0u32.to_be_bytes()); // layer and mask info + b.extend_from_slice(&(rle as u16).to_be_bytes()); + let plane = |c: usize| (0..w * h).map(move |i| ((i * (c + 3) * 11) % 256) as u8); + if rle { + // every row one literal run of `w` bytes (w ≤ 128) + for _ in 0..3 * h { + b.extend_from_slice(&(w as u16 + 1).to_be_bytes()); + } + for c in 0..3 { + let p: Vec = plane(c).collect(); + for row in p.chunks(w) { + b.push(w as u8 - 1); + b.extend_from_slice(row); + } + } + } else { + for c in 0..3 { + b.extend(plane(c)); + } + } + b +} + +/// Generated files of every format with a header reader, plus a fallback one (BMP via `image`). +fn fixtures(w: usize, h: usize) -> Vec<(String, Vec)> { + let img = pixels(w, h); + let e = EncodeImage::rgba8(&img); + let rgb: Vec = img.data.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); + let rgb_e = EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&rgb)); + let u16s: Vec = (0..w * h * 3).map(|i| (i * 331) as u16).collect(); + let mut out = Vec::new(); + for o in [None, Some(1), Some(6), Some(8)] { + let exif = o.map(minimal_exif); + let tag = format!("o{}", o.unwrap_or(0)); + out.push((format!("jpeg 420 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S420, &meta(&exif)).unwrap())); + out.push((format!("jpeg 444 {tag}"), encode_jpeg(&rgb_e, 85, ChromaSubsampling::S444, &meta(&exif)).unwrap())); + out.push((format!("png rgba {tag}"), encode_png(&e, &meta(&exif)).unwrap())); + out.push((format!("png 16-bit {tag}"), encode_png(&EncodeImage::new(w as u32, h as u32, 3, Samples::U16(&u16s)), &meta(&exif)).unwrap())); + out.push((format!("webp {tag}"), encode_webp_lossless(&e, &meta(&exif)).unwrap())); + out.push((format!("psd {tag}"), psd(w, h, o, false))); + out.push((format!("psd rle {tag}"), psd(w, h, o, true))); + out.push((format!("tiff {tag}"), tiff_oriented(&img, o.unwrap_or(1)))); + } + out.push(("tiff lzw".into(), encode_tiff(&e, TiffCompression::Lzw, &EncodeMeta::default()).unwrap())); + out.push(("tiff packbits".into(), encode_tiff(&e, TiffCompression::PackBits, &EncodeMeta::default()).unwrap())); + let mut bmp = Vec::new(); + image::codecs::bmp::BmpEncoder::new(&mut bmp).encode(&rgb, w as u32, h as u32, image::ExtendedColorType::Rgb8).unwrap(); + out.push(("bmp".into(), bmp)); + out +} + +/// What the import probe learnt before it read headers: a small decode. +fn decoded(bytes: &[u8]) -> Result
{ + let d = decode(bytes, DecodeOptions::fit(64, 64))?; + Ok(Header { format: d.format, width: d.source_width, height: d.source_height, orientation: d.orientation }) +} + +#[test] +fn header_matches_decode() { + for (w, h) in [(160, 96), (96, 160), (7, 5)] { + for (name, bytes) in fixtures(w, h) { + let header = read_header(&bytes).unwrap_or_else(|e| panic!("{name} {w}×{h}: {e}")); + assert_eq!(header, decoded(&bytes).unwrap(), "{name} {w}×{h}"); + assert_eq!((header.width, header.height), (w as u32, h as u32), "{name}"); + } + } + let o = |name: &str| fixtures(16, 8).into_iter().find(|f| f.0 == name).map(|f| read_header(&f.1).unwrap().orientation).unwrap(); + assert_eq!((o("jpeg 420 o6"), o("png rgba o8"), o("webp o6"), o("psd o8"), o("tiff o6"), o("jpeg 444 o0")), (6, 8, 6, 8, 6, 1)); +} + +/// Whenever the decode the probe used to run accepts a file (here: every cut of every fixture), +/// the header agrees with it: reading headers never refuses a file that decodes. The reverse +/// is checked for truncation, which the header readers detect. +#[test] +fn truncated_files_agree_with_decode() { + // 160 px wide: large JPEGs take the DCT-scaled (strict) decode path the probe took for photos + for (name, bytes) in fixtures(160, 96) { + let n = bytes.len(); + let mut cuts: Vec = (0..100).map(|i| n * i / 100).collect(); + cuts.extend(n.saturating_sub(64)..n); + for cut in cuts { + let part = &bytes[..cut]; + if let Ok(d) = decoded(part) { + assert_eq!(read_header(part).ok(), Some(d), "{name} cut at {cut}/{n}"); + } + } + for cut in [n / 4, n / 2, n * 9 / 10] { + assert!(read_header(&bytes[..cut]).is_err(), "{name}: cut at {cut}/{n} accepted"); + } + } +} + +#[test] +fn truncation_details() { + let f = fixtures(160, 96); + let get = |name: &str| f.iter().find(|x| x.0 == name).map(|x| x.1.clone()).unwrap(); + let jpeg = get("jpeg 420 o6"); + assert!(read_header(&jpeg[..jpeg.len() - 2]).is_err(), "a JPEG without its EOI is truncated"); + let mut trailing = jpeg.clone(); + trailing.extend_from_slice(&[0; 100]); + assert_eq!(read_header(&trailing).unwrap().orientation, 6, "bytes after the EOI are fine"); + let png = get("png rgba o6"); + assert!(read_header(&png[..png.len() - 2]).is_ok(), "a damaged IEND is ignored, as by a decode"); + assert!(read_header(&png[..png.len() - 12]).is_err(), "nothing after the image data: truncated"); +} + +/// eXIf after the image data counts, as it does for a decode (which reads the whole file). +#[test] +fn png_exif_after_idat() { + let img = pixels(9, 4); + let plain = encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap(); + let exif = minimal_exif(6); + let mut chunk = (exif.len() as u32).to_be_bytes().to_vec(); + chunk.extend_from_slice(b"eXIf"); + chunk.extend_from_slice(&exif); + chunk.extend_from_slice(&[0; 4]); // CRC: not checked by either reader + let iend = plain.len() - 12; + let mut b = plain[..iend].to_vec(); + b.extend_from_slice(&chunk); + b.extend_from_slice(&plain[iend..]); + assert_eq!(read_header(&b).unwrap().orientation, 6); +} + +fn jpeg_segments(sof: u8, w: u16, h: u16, comps: u8) -> Vec { + let mut b = vec![0xFF, 0xD8, 0xFF, sof, 0, 8 + 3 * comps as u16 as u8, 8]; + b.extend_from_slice(&h.to_be_bytes()); + b.extend_from_slice(&w.to_be_bytes()); + b.push(comps); + for c in 0..comps { + b.extend_from_slice(&[c + 1, 0x11, 0]); + } + b.extend_from_slice(&[0xFF, 0xDA, 0, 8, 1, 1, 0, 0, 63, 0]); + b.extend_from_slice(&[0x12, 0xFF, 0x00, 0x34, 0xFF, 0xD0, 0x56]); + b.extend_from_slice(&[0xFF, 0xD9]); + b +} + +fn png_ihdr(w: u32, h: u32, depth: u8, color: u8) -> Vec { + let mut b = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec(); + b.extend_from_slice(&w.to_be_bytes()); + b.extend_from_slice(&h.to_be_bytes()); + b.extend_from_slice(&[depth, color, 0, 0, 0, 0, 0, 0, 0]); + b +} + +fn png_chunk(b: &mut Vec, kind: &[u8; 4], len: u32, data: &[u8]) { + b.extend_from_slice(&len.to_be_bytes()); + b.extend_from_slice(kind); + b.extend_from_slice(data); + b.extend_from_slice(&[0; 4]); +} + +#[test] +fn hostile_headers_are_errors() { + // a well-formed synthetic JPEG header is read + assert_eq!(read_header(&jpeg_segments(0xC0, 33, 21, 3)).unwrap(), Header { format: Format::Jpeg, width: 33, height: 21, orientation: 1 }); + let jpeg_bad = [ + jpeg_segments(0xC0, 0, 21, 3), // zero width + jpeg_segments(0xC0, 33, 0, 3), // zero height (DNL) + jpeg_segments(0xC0, 65535, 65535, 3), // 4.3 Gpx + jpeg_segments(0xC9, 33, 21, 3), // arithmetic coding + jpeg_segments(0xC5, 33, 21, 3), // hierarchical + jpeg_segments(0xC0, 33, 21, 2), // two components + jpeg_segments(0xC0, 33, 21, 3)[..30].to_vec(), + vec![0xFF, 0xD8, 0xFF, 0xE1, 0, 0], // segment length 0 + vec![0xFF, 0xD8, 0xFF, 0xE1, 0xFF, 0xFF], // segment past the end + vec![0xFF, 0xD8, 0xFF, 0xD9], + ]; + for (i, b) in jpeg_bad.iter().enumerate() { + assert!(read_header(b).is_err(), "JPEG case {i}"); + } + let mut no_sos = jpeg_segments(0xC0, 33, 21, 3); + no_sos.truncate(2 + 2 + 17); + no_sos.extend_from_slice(&[0xFF, 0xD9]); + assert!(read_header(&no_sos).is_err(), "JPEG without a scan"); + + let mut ok = png_ihdr(5, 3, 8, 2); + png_chunk(&mut ok, b"IDAT", 1, &[0]); + png_chunk(&mut ok, b"IEND", 0, &[]); + assert_eq!(read_header(&ok).unwrap(), Header { format: Format::Png, width: 5, height: 3, orientation: 1 }); + let png_case = |ihdr: Vec, chunks: &[(&[u8; 4], u32, &[u8])]| { + let mut b = ihdr; + for (k, l, d) in chunks { + png_chunk(&mut b, k, *l, d); + } + b + }; + let idat: (&[u8; 4], u32, &[u8]) = (b"IDAT", 1, &[0]); + let iend: (&[u8; 4], u32, &[u8]) = (b"IEND", 0, &[]); + let png_bad = [ + png_case(png_ihdr(0, 3, 8, 2), &[idat, iend]), + png_case(png_ihdr(5, 0, 8, 2), &[idat, iend]), + png_case(png_ihdr(0x8000_0000, 1, 8, 2), &[idat, iend]), + png_case(png_ihdr(65536, 65536, 8, 2), &[idat, iend]), + png_case(png_ihdr(5, 3, 4, 2), &[idat, iend]), // RGB at 4 bits + png_case(png_ihdr(5, 3, 8, 5), &[idat, iend]), // no colour type 5 + png_case(png_ihdr(5, 3, 8, 3), &[idat, iend]), // indexed without PLTE + png_case(png_ihdr(5, 3, 8, 2), &[iend]), // no IDAT + png_case(png_ihdr(5, 3, 8, 2), &[idat]), // nothing after the image data + png_case(png_ihdr(5, 3, 8, 2), &[(b"IDAT", 0xFFFF_FFFF, &[0]), iend]), + png_case(png_ihdr(5, 3, 8, 2), &[(b"tEXt", 0x7FFF_FFFF, &[0]), idat, iend]), + png_ihdr(5, 3, 8, 2)[..20].to_vec(), + b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDX\0\0\0\x05\0\0\0\x03\x08\x02\0\0\0".to_vec(), + ]; + for (i, b) in png_bad.iter().enumerate() { + assert!(read_header(b).is_err(), "PNG case {i}"); + } + + let tiff = encode_tiff(&EncodeImage::rgba8(&pixels(6, 4)), TiffCompression::None, &EncodeMeta::default()).unwrap(); + let ifd = u32::from_le_bytes([tiff[4], tiff[5], tiff[6], tiff[7]]) as usize; + let mut tiff_bad = vec![b"II*\0\xff\xff\xff\x7f".to_vec(), b"II*\0\x08\0\0\0".to_vec(), b"II*\0\x04\0\0\0".to_vec()]; + // cut short after the IFD + assert!(ifd < tiff.len() / 2, "the IFD comes before the image data"); + tiff_bad.push(tiff[..tiff.len() - 10].to_vec()); + for (i, b) in tiff_bad.iter().enumerate() { + assert!(read_header(b).is_err(), "TIFF case {i}"); + } + // a strip offset pointing past the end of the file + let t = |b: &[u8], p: usize| u16::from_le_bytes([b[p], b[p + 1]]); + let n = t(&tiff, ifd) as usize; + let mut far = tiff.clone(); + for e in 0..n { + let p = ifd + 2 + e * 12; + if t(&far, p) == 273 && t(&far, p + 2) == 4 && u32::from_le_bytes([far[p + 4], far[p + 5], far[p + 6], far[p + 7]]) == 1 { + far[p + 8..p + 12].copy_from_slice(&0x7FFF_0000u32.to_le_bytes()); + } + } + assert_ne!(far, tiff, "the strip offset was found"); + assert!(read_header(&far).is_err(), "strip past the end"); + + let webp_bad = [ + b"RIFF\0\0\0\0WEBP".to_vec(), + b"RIFF\0\0\0\0WEBPVP8L\xff\xff\xff\xff\x2f".to_vec(), + b"RIFF\0\0\0\0WEBPXXXX\xff\xff\xff\xff".to_vec(), + b"RIFF\0\0\0\0WEBPVP8 \x04\0\0\0\0\0\0\0".to_vec(), + ]; + for (i, b) in webp_bad.iter().enumerate() { + assert!(read_header(b).is_err(), "WebP case {i}"); + } + + let good = psd(4, 3, Some(6), true); + assert_eq!(read_header(&good).unwrap().orientation, 6); + let set = |pos: usize, v: &[u8]| { + let mut b = good.clone(); + b[pos..pos + v.len()].copy_from_slice(v); + b + }; + let res_len = u32::from_be_bytes([good[30], good[31], good[32], good[33]]) as usize; + let compression = 34 + res_len + 4; + let mut psd_bad = vec![ + set(12, &0u16.to_be_bytes()), // no channels + set(12, &57u16.to_be_bytes()), // too many + set(12, &2u16.to_be_bytes()), // too few for RGB + set(14, &0x7FFF_FFFFu32.to_be_bytes()), // absurd height + set(18, &0u32.to_be_bytes()), // zero width + set(22, &1u16.to_be_bytes()), // 1-bit + set(22, &7u16.to_be_bytes()), // bad depth + set(24, &9u16.to_be_bytes()), // Lab + set(24, &77u16.to_be_bytes()), // unknown mode + set(24, &2u16.to_be_bytes()), // indexed without a palette + set(compression, &2u16.to_be_bytes()), // ZIP + set(26, &0xFFFF_FFFFu32.to_be_bytes()), // colour mode data past the end + set(30, &0xFFFF_FFF0u32.to_be_bytes()), // resources past the end + ]; + // a 1 × 2^30 RLE image declared in a tiny file: refused without reading 2^30 row counts + let mut tall = good.clone(); + tall[14..18].copy_from_slice(&(1u32 << 30).to_be_bytes()); + tall[18..22].copy_from_slice(&1u32.to_be_bytes()); + psd_bad.push(tall); + psd_bad.push(good[..good.len() - 1].to_vec()); + for (i, b) in psd_bad.iter().enumerate() { + assert!(read_header(b).is_err(), "PSD case {i}"); + } + + for b in [&b""[..], b"\xFF\xD8\xFF", b"\x89PNG\r\n\x1a\n", b"II*\0", b"MM\0*", b"8BPS\0\x01", b"GIF89a", b"not an image at all"] { + assert!(read_header(b).is_err(), "{b:?}"); + } +} + +/// The same with a few random bytes changed: whenever the decode accepts the damaged file, the +/// header agrees with it (deterministic seed). +#[test] +fn mutated_files_agree_with_decode() { + let mut s: u64 = 0x1234_5678_9abc_def1; + let mut rnd = || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + s + }; + let (mut decodable, mut disagreements) = (0, Vec::new()); + for (name, bytes) in fixtures(160, 96) { + for _ in 0..300 { + let mut b = bytes.clone(); + for _ in 0..1 + rnd() % 3 { + let i = rnd() as usize % b.len(); + b[i] = rnd() as u8; + } + if let Ok(d) = decoded(&b) { + decodable += 1; + let h = read_header(&b); + if h.as_ref().ok() != Some(&d) { + disagreements.push(format!("{name}: decode {d:?}, header {h:?}")); + } + } + } + } + assert!(decodable > 1000, "{decodable}"); + assert!(disagreements.is_empty(), "{disagreements:#?}"); +} diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 90056249..1b21faa6 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -92,8 +92,10 @@ fn ext_upper(name: &str) -> String { std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default() } -/// Probe a file's bytes: kind, dimensions (oriented), metadata. Raws are described from their -/// headers ([`lightcraft_raw::probe_info`]): no pixel data is decompressed. +/// Probe a file's bytes: kind, dimensions (oriented), metadata. No pixel data is decompressed: +/// raws are described from their headers ([`lightcraft_raw::probe_info`]), other images too +/// ([`lightcraft_codecs::read_header`], which refuses truncated files but can't see damage inside +/// compressed data that is all there: such a file imports and shows as unreadable when rendered). pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string()); let m = lightcraft_meta::extract(bytes); @@ -152,9 +154,10 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result { if !fmt.can_decode() { return Err(format!("{fmt:?} files are not supported yet")); } - let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).map_err(|e| e.to_string())?; - let o = Orientation::from_exif(d.orientation); - let (mut w, mut h) = (d.source_width, d.source_height); + // headers only (issue #367: decoding the pixels was nearly all of an import's CPU time) + let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?; + let o = Orientation::from_exif(header.orientation); + let (mut w, mut h) = (header.width, header.height); if o.swaps_axes() { std::mem::swap(&mut w, &mut h); } @@ -442,6 +445,37 @@ mod tests { use super::*; use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + /// Issue #367: probes read headers, not pixels, and report what the decode-based probe did: + /// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated + /// file or one that isn't an image. + #[test] + fn image_probe_reads_headers_like_the_decode_did() { + let (w, h) = (300u32, 200u32); + let rgb: Vec = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect(); + let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb)); + for o in [None, Some(1), Some(6), Some(3), Some(8)] { + let exif = o.map(lightcraft_codecs::exif::minimal_exif); + let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() }; + let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap(); + let png = lightcraft_codecs::encode_png(&img, &meta).unwrap(); + for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] { + let p = probe_bytes(name, bytes).unwrap(); + // what the probe computed from a decode before + let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap(); + let swap = Orientation::from_exif(d.orientation).swaps_axes(); + let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) }; + assert_eq!((p.width, p.height), want, "{name} {o:?}"); + assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}"); + assert_eq!(p.kind, MediaKind::Image); + assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" }); + assert_eq!(p.file_size, bytes.len() as u64); + assert!(p.content_hash.is_some()); + assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted"); + } + } + assert!(probe_bytes("x.jpg", b"not an image").is_err()); + } + /// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG /// mostly cancel, edges remain. fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec {