From 0349b0c0f19de4687d40d920de8d42cec473d146 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 18:21:33 -0400 Subject: [PATCH] Harden EXIF orientation: limits after rotation, fallible allocation, strict IFD parsing (#345) (#345) --- apps/photocraft-cli/tests/orientation.rs | 146 +++++++++++++++++++ crates/codecs/README.md | 10 +- crates/codecs/src/lib.rs | 5 + crates/codecs/src/orientation.rs | 169 +++++++++++++++------- crates/codecs/tests/orientation.rs | 175 ++++++++++++++++++++++- 5 files changed, 447 insertions(+), 58 deletions(-) create mode 100644 apps/photocraft-cli/tests/orientation.rs diff --git a/apps/photocraft-cli/tests/orientation.rs b/apps/photocraft-cli/tests/orientation.rs new file mode 100644 index 000000000..0d3ff9838 --- /dev/null +++ b/apps/photocraft-cli/tests/orientation.rs @@ -0,0 +1,146 @@ +//! EXIF orientation end to end through the `photocraft-cli` binary (#285): a +//! phone-style JPEG (stored landscape, Orientation = 6) converts to an upright +//! portrait, an explicit rotation turns it back, and a `.pcraft` round trip +//! changes nothing. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use photocraft_codecs::{ChannelLayout, DecodeOptions, EncodeOptions, Format, Image, SampleType, decode, decode_with, exif_orientation}; +use serde_json::Value; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_photocraft-cli")) +} + +fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("pc-cli-orient-{}-{name}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn ok(cmd: &mut Command) -> String { + let o = cmd.output().unwrap(); + let (out, err) = (String::from_utf8_lossy(&o.stdout).into_owned(), String::from_utf8_lossy(&o.stderr).into_owned()); + assert!(o.status.success(), "exit {:?}\nstdout: {out}\nstderr: {err}", o.status); + out +} + +/// EXIF block: Orientation = `o` (SHORT) and an inline Software tag, little-endian. +fn exif(o: u16) -> Vec { + let mut v = b"II*\0\x08\0\0\0\x02\0".to_vec(); + v.extend_from_slice(&[0x12, 0x01, 3, 0, 1, 0, 0, 0]); + v.extend_from_slice(&o.to_le_bytes()); + v.extend_from_slice(&[0, 0]); + v.extend_from_slice(&[0x31, 0x01, 2, 0, 4, 0, 0, 0]); + v.extend_from_slice(b"PC1\0"); + v.extend_from_slice(&[0, 0, 0, 0]); + v +} + +/// Upright 16×32 portrait: red top-left quarter, green top-right, blue bottom half. +fn upright_color(x: usize, y: usize) -> [u8; 3] { + match (x < 8, y < 16) { + (true, true) => [230, 20, 20], + (false, true) => [20, 230, 20], + _ => [20, 20, 230], + } +} + +/// The portrait as a camera stores it for Orientation = 6: 32×16, with EXIF. +fn phone_jpeg() -> Vec { + let (w, h) = (16, 32); + let mut px = vec![0u8; w * h * 3]; + for y in 0..h { + for x in 0..w { + // Upright (x, y) sits at stored (y, w - 1 - x) in a 32-wide image. + let (sx, sy) = (y, w - 1 - x); + px[(sy * h + sx) * 3..][..3].copy_from_slice(&upright_color(x, y)); + } + } + let stored = Image::from_u8(h as u32, w as u32, ChannelLayout::Rgb, px).unwrap(); + let jpeg = + photocraft_codecs::encode(&stored, Format::Jpeg, &EncodeOptions { jpeg_quality: 100, jpeg_chroma_subsampling: false, ..Default::default() }).unwrap(); + let mut seg = b"Exif\0\0".to_vec(); + seg.extend_from_slice(&exif(6)); + let mut out = jpeg[..2].to_vec(); + out.extend_from_slice(&[0xFF, 0xE1]); + out.extend_from_slice(&((seg.len() + 2) as u16).to_be_bytes()); + out.extend_from_slice(&seg); + out.extend_from_slice(&jpeg[2..]); + out +} + +/// The file as stored (no orientation applied), so its own dimensions and tag can be checked. +fn stored(path: &Path) -> Image { + decode_with(&std::fs::read(path).unwrap(), &DecodeOptions { keep_orientation: true, ..Default::default() }).unwrap() +} + +fn assert_portrait_pixels(img: &Image) { + let img = img.convert(ChannelLayout::Rgb, SampleType::U8); + for (x, y) in [(3, 4), (12, 4), (3, 28), (12, 28)] { + let i = (y * 16 + x) * 3; + let got = &img.data()[i..i + 3]; + let want = upright_color(x, y); + assert!(got.iter().zip(want).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12), "({x},{y}): {got:?} vs {want:?}"); + } +} + +#[test] +fn orientation_6_jpeg_converts_upright_and_rotates_back() { + let d = tmp("e2e"); + let jpg = d.join("IMG_0006.JPG"); + std::fs::write(&jpg, phone_jpeg()).unwrap(); + + // convert → PNG: the stored pixels are already upright, and any EXIF says 1. + let png = d.join("upright.png"); + ok(bin().arg("convert").arg(&jpg).arg(&png)); + let raw = stored(&png); + assert_eq!(raw.dimensions(), (16, 32), "stored upright, not landscape"); + assert_portrait_pixels(&raw); + if let Some(e) = raw.meta.exif.as_deref() { + assert_eq!(exif_orientation(e), 1, "written EXIF orientation"); + } + assert_eq!(exif_orientation(&std::fs::read(&png).unwrap()), 1); + assert_eq!(decode(&std::fs::read(&png).unwrap()).unwrap().dimensions(), (16, 32), "no second turn on reopen"); + + // An explicit Image › Image Rotation › 90° Clockwise makes it landscape again. + let turned = d.join("turned.png"); + ok(bin().arg("run").arg(&jpg).args(["--cmd", "image.imageRotation.90cw", "--out"]).arg(&turned)); + let t = stored(&turned); + assert_eq!(t.dimensions(), (32, 16)); + if let Some(e) = t.meta.exif.as_deref() { + assert_eq!(exif_orientation(e), 1); + } + // The portrait's top-left (red) is now top-right; its bottom (blue) is on the left. + let t = t.convert(ChannelLayout::Rgb, SampleType::U8); + let at = |x: usize, y: usize| <[u8; 3]>::try_from(&t.data()[(y * 32 + x) * 3..][..3]).unwrap(); + let near = |a: [u8; 3], b: [u8; 3]| a.iter().zip(b).all(|(a, b)| (i32::from(*a) - i32::from(b)).abs() <= 12); + assert!(near(at(28, 3), upright_color(3, 4)), "{:?}", at(28, 3)); + assert!(near(at(28, 12), upright_color(12, 4)), "{:?}", at(28, 12)); + assert!(near(at(4, 8), upright_color(8, 28)), "{:?}", at(4, 8)); + + // .pcraft save, reopen and save again: identical document and pixels. + let (a, b) = (d.join("a.pcraft"), d.join("b.pcraft")); + ok(bin().arg("convert").arg(&jpg).arg(&a)); + ok(bin().arg("convert").arg(&a).arg(&b)); + // Everything but the file's own path must match. + let info = |p: &Path| { + let mut v = serde_json::from_str::(&ok(bin().arg("info").arg(p))).unwrap(); + v.as_object_mut().unwrap().remove("file"); + v + }; + let (ia, ib) = (info(&a), info(&b)); + assert_eq!((ia["width"].as_u64(), ia["height"].as_u64()), (Some(16), Some(32))); + assert_eq!(ia, ib); + let (pa, pb) = (d.join("a.png"), d.join("b.png")); + ok(bin().arg("convert").arg(&a).arg(&pa)); + ok(bin().arg("convert").arg(&b).arg(&pb)); + let (xa, xb) = (stored(&pa), stored(&pb)); + assert_eq!(xa.dimensions(), (16, 32)); + assert_eq!(xa.to_rgba8(), xb.to_rgba8()); + assert_eq!(xa.meta.exif, xb.meta.exif); + assert_eq!(xa.to_rgba8(), stored(&png).to_rgba8(), "the .pcraft round trip matches the direct conversion"); + std::fs::remove_dir_all(d).unwrap(); +} diff --git a/crates/codecs/README.md b/crates/codecs/README.md index e2109e6ee..5657795fd 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -79,9 +79,13 @@ the same. * **Orientation** (EXIF tag 274 in JPEG, PNG `eXIf` and WebP; the IFD0 tag in TIFF) is applied on decode, like Photoshop: the pixels come back upright and the EXIF/XMP orientation is rewritten to 1 (`DecodeOptions::keep_orientation` opts out). Encoders always write Orientation = 1 - (`upright_exif`, `upright_xmp`), so upright pixels are never rotated twice. Malformed or - out-of-range values read as 1. `Image::oriented` turns any layout and depth in parallel bands - (about 10 ms for 24 MP RGB8 in release). + (`upright_exif`, `upright_xmp`, which change only the tag's value, kept in its own type), so upright pixels + are never rotated twice. Parsing is strict: an IFD0 inside the 8-byte header or cut off before + its next-IFD pointer, an Orientation that is not exactly one SHORT or LONG, or an out-of-range value + reads as 1; with duplicate entries the first wins. `Limits` are checked again on the upright + size (5–8 swap width and height), and the rotated buffer is allocated fallibly. + `Image::oriented` turns any layout and depth in parallel bands (about 10 ms for 24 MP RGB8 in + release). * **JPEG** * 8-bit only. Neither decoder backend supports 12-bit. * CMYK is always written 4:4:4 (subsampled CMYK is not portable) as Adobe-inverted CMYK with an diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index cf0852b10..9b2fce25c 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -75,6 +75,11 @@ pub fn decode_as_with(format: Format, bytes: &[u8], opts: &DecodeOptions) -> Res Format::Tiff => exif_orientation(bytes), _ => img.meta.exif.as_deref().map_or(1, exif_orientation), }; + // Orientations 5–8 swap width and height: with asymmetric limits a stored landscape + // that fit can turn into a portrait that does not, so check the upright size too. + if (5..=8).contains(&o) { + l.check(img.height(), img.width(), img.layout(), img.sample_type())?; + } img.oriented(o) } diff --git a/crates/codecs/src/orientation.rs b/crates/codecs/src/orientation.rs index b8e35e2b3..5e31d96fa 100644 --- a/crates/codecs/src/orientation.rs +++ b/crates/codecs/src/orientation.rs @@ -9,7 +9,11 @@ //! Orientation values (TIFF 6.0, EXIF 2.3): 1 = upright, 2 = mirrored, 3 = //! rotated 180°, 4 = flipped vertically, 5 = transposed, 6 = needs a 90° //! clockwise turn, 7 = transverse, 8 = needs a 90° counter-clockwise turn. -//! Anything malformed, truncated or out of range reads as 1. +//! Anything malformed, truncated or out of range reads as 1: an IFD0 that +//! starts inside the 8-byte header or is cut off before its next-IFD pointer, +//! or an Orientation entry that is not exactly one SHORT or LONG (the spec says +//! SHORT, but some cameras and tools write LONG). When IFD0 holds +//! several Orientation entries the first one wins (like libexif). use std::borrow::Cow; @@ -18,11 +22,14 @@ use crate::image::Image; /// The TIFF/EXIF Orientation tag. const TAG_ORIENTATION: u16 = 274; -/// TIFF field types that can hold the orientation. +/// Field types accepted for the Orientation tag: SHORT (the spec) and LONG +/// (written by some cameras and tools). const TYPE_SHORT: u16 = 3; const TYPE_LONG: u16 = 4; -/// IFD0 entries we look at, at most (real files have a few dozen). -const MAX_ENTRIES: usize = 1024; +/// Bytes per IFD entry. +const ENTRY: usize = 12; +/// The TIFF header is 8 bytes, so no IFD can start before it. +const HEADER: usize = 8; /// Byte order of a TIFF structure. #[derive(Clone, Copy)] @@ -54,31 +61,77 @@ fn tiff_body(b: &[u8]) -> &[u8] { b.strip_prefix(b"Exif\0\0").unwrap_or(b) } -/// Locates the Orientation entry in IFD0: `(byte order, value offset, field type)`. -fn find_entry(b: &[u8]) -> Option<(Order, usize, u16)> { +/// IFD0 of a TIFF structure: its byte order and the offsets of its entries. +/// +/// The whole table must be present, up to and including the 4-byte next-IFD +/// pointer, and it must start after the 8-byte header; anything else is +/// treated as unparseable. +fn ifd0_entries(b: &[u8]) -> Option<(Order, impl Iterator)> { let order = match b.get(0..4)? { [b'I', b'I', 42, 0] => Order::Little, [b'M', b'M', 0, 42] => Order::Big, _ => return None, }; let ifd = usize::try_from(order.u32(b, 4)?).ok()?; - let count = usize::from(order.u16(b, ifd)?).min(MAX_ENTRIES); - let first = ifd.checked_add(2)?; - for i in 0..count { - let e = first.checked_add(i.checked_mul(12)?)?; - let tag = order.u16(b, e)?; - if tag != TAG_ORIENTATION { - continue; - } - let ty = order.u16(b, e.checked_add(2)?)?; - let n = order.u32(b, e.checked_add(4)?)?; - if n == 0 || !matches!(ty, TYPE_SHORT | TYPE_LONG) { - return None; - } - // One SHORT or LONG always fits in the 4-byte value field. - return Some((order, e.checked_add(8)?, ty)); + if ifd < HEADER { + return None; } - None + let count = usize::from(order.u16(b, ifd)?); + let first = ifd.checked_add(2)?; + let next_ifd = first.checked_add(count.checked_mul(ENTRY)?)?; + if next_ifd.checked_add(4)? > b.len() { + return None; + } + // In bounds: every entry ends at or before `next_ifd`. + Some((order, (0..count).map(move |i| first + i * ENTRY))) +} + +/// A well-formed Orientation entry's value: where it sits and its field type. +#[derive(Clone, Copy)] +struct Value { + at: usize, + ty: u16, +} + +impl Value { + /// The stored value (a SHORT or LONG, left-justified in the 4-byte field). + fn read(self, order: Order, b: &[u8]) -> Option { + match self.ty { + TYPE_SHORT => order.u16(b, self.at).map(u32::from), + _ => order.u32(b, self.at), + } + } + + /// 1 in this entry's own type, width and byte order. + fn one(self, order: Order) -> Vec { + match (self.ty, order) { + (TYPE_SHORT, Order::Little) => 1u16.to_le_bytes().to_vec(), + (TYPE_SHORT, Order::Big) => 1u16.to_be_bytes().to_vec(), + (_, Order::Little) => 1u32.to_le_bytes().to_vec(), + (_, Order::Big) => 1u32.to_be_bytes().to_vec(), + } + } +} + +/// The value of the entry at `e` if it is a well-formed Orientation entry: +/// exactly one SHORT or LONG. +fn orientation_value(order: Order, b: &[u8], e: usize) -> Option { + if order.u16(b, e)? != TAG_ORIENTATION || order.u32(b, e.checked_add(4)?)? != 1 { + return None; + } + let ty = order.u16(b, e.checked_add(2)?)?; + matches!(ty, TYPE_SHORT | TYPE_LONG).then_some(Value { at: e.checked_add(8)?, ty }) +} + +/// Locates the Orientation value in IFD0. +/// +/// The **first** Orientation entry wins (like libexif and Photoshop); if that +/// one is malformed (count ≠ 1, or neither SHORT nor LONG) the tag is ignored, +/// even when a later duplicate is well formed. +fn find_entry(b: &[u8]) -> Option<(Order, Value)> { + let (order, mut entries) = ifd0_entries(b)?; + let e = entries.find(|&e| order.u16(b, e) == Some(TAG_ORIENTATION))?; + Some((order, orientation_value(order, b, e)?)) } /// The orientation (1–8) recorded in a TIFF-structured block: an EXIF payload @@ -86,42 +139,36 @@ fn find_entry(b: &[u8]) -> Option<(Order, usize, u16)> { /// malformed or out-of-range values give 1. pub fn exif_orientation(exif: &[u8]) -> u16 { let b = tiff_body(exif); - let value = find_entry(b).and_then(|(order, at, ty)| match ty { - TYPE_SHORT => order.u16(b, at), - _ => order.u32(b, at).and_then(|v| u16::try_from(v).ok()), - }); - value.filter(|v| (1..=8).contains(v)).unwrap_or(1) + find_entry(b).and_then(|(order, v)| v.read(order, b)).and_then(|v| u16::try_from(v).ok()).filter(|v| (1..=8).contains(v)).unwrap_or(1) } /// `exif` with its Orientation rewritten to 1, borrowed when there is nothing -/// to change (no tag, already 1, or unparseable). +/// to change (no well-formed tag, already 1, or unparseable). +/// +/// Only the value bytes of each well-formed Orientation entry change, written +/// as 1 in the entry's existing type and width (SHORT or LONG); every other +/// byte (other tags, their offset-based values, sub-IFDs) is kept as is. +/// Well-formed duplicates are all set to 1, so a reader that lets a later +/// duplicate win cannot rotate the upright pixels either. pub fn upright_exif(exif: &[u8]) -> Cow<'_, [u8]> { - let prefix = exif.len() - tiff_body(exif).len(); - let Some((order, at, ty)) = find_entry(tiff_body(exif)) else { + let body = tiff_body(exif); + let prefix = exif.len() - body.len(); + let Some((order, entries)) = ifd0_entries(body) else { return Cow::Borrowed(exif); }; - let current = match ty { - TYPE_SHORT => order.u16(tiff_body(exif), at).map(u32::from), - _ => order.u32(tiff_body(exif), at), - }; - if current == Some(1) { + let values: Vec = entries.filter_map(|e| orientation_value(order, body, e)).collect(); + if values.iter().all(|v| v.read(order, body) == Some(1)) { return Cow::Borrowed(exif); } - let one: Vec = match (ty, order) { - (TYPE_SHORT, Order::Little) => 1u16.to_le_bytes().to_vec(), - (TYPE_SHORT, Order::Big) => 1u16.to_be_bytes().to_vec(), - (_, Order::Little) => 1u32.to_le_bytes().to_vec(), - (_, Order::Big) => 1u32.to_be_bytes().to_vec(), - }; let mut out = exif.to_vec(); - let Some(at) = prefix.checked_add(at) else { return Cow::Borrowed(exif) }; - match at.checked_add(one.len()).and_then(|end| out.get_mut(at..end)) { - Some(slot) => { + for v in values { + let one = v.one(order); + let slot = prefix.checked_add(v.at).and_then(|at| out.get_mut(at..at.checked_add(one.len())?)); + if let Some(slot) = slot { slot.copy_from_slice(&one); - Cow::Owned(out) } - None => Cow::Borrowed(exif), } + Cow::Owned(out) } /// `xmp` with any `tiff:Orientation` (attribute or element form) set to 1, @@ -200,8 +247,8 @@ fn band(src: &[[u8; N]], dst: &mut [[u8; N]], o: u16, (w, h): (u } /// Orients `src` (`N` bytes per pixel) into a new buffer, a band of rows per task. -fn orient_n(src: &[u8], o: u16, (w, h): (usize, usize), ow: usize) -> Vec { - let mut out = vec![0u8; src.len()]; +fn orient_n(src: &[u8], o: u16, (w, h): (usize, usize), ow: usize) -> Result, CodecError> { + let mut out = alloc_zeroed(src.len())?; let (s, _) = src.as_chunks::(); let (d, _) = out.as_chunks_mut::(); let per = (BAND * ow).max(1); @@ -214,11 +261,19 @@ fn orient_n(src: &[u8], o: u16, (w, h): (usize, usize), ow: usiz for (b, chunk) in d.chunks_mut(per).enumerate() { band(s, chunk, o, (w, h), ow, b * BAND); } - out + Ok(out) +} + +/// A zeroed buffer of `n` bytes, or an error (not an abort) when memory runs out. +fn alloc_zeroed(n: usize) -> Result, CodecError> { + let mut out = Vec::new(); + out.try_reserve_exact(n).map_err(|_| CodecError::LimitExceeded(format!("cannot allocate {n} bytes to orient the image")))?; + out.resize(n, 0); + Ok(out) } /// Pixel-format-agnostic orientation: any byte size per pixel. -fn orient_bytes(src: &[u8], bpp: usize, o: u16, (w, h): (usize, usize), ow: usize) -> Vec { +fn orient_bytes(src: &[u8], bpp: usize, o: u16, (w, h): (usize, usize), ow: usize) -> Result, CodecError> { match bpp { 1 => orient_n::<1>(src, o, (w, h), ow), 2 => orient_n::<2>(src, o, (w, h), ow), @@ -252,7 +307,7 @@ impl Image { if !matches!(bpp, 1..=6 | 8 | 10 | 12 | 16 | 20) || w == 0 || h == 0 { return Err(CodecError::InvalidImage(format!("cannot orient {layout:?} {sample:?} {w}x{h}"))); } - let data = orient_bytes(self.data(), bpp, o, (w, h), ow); + let data = orient_bytes(self.data(), bpp, o, (w, h), ow)?; let mut meta = self.meta.clone(); if let Some(e) = &mut meta.exif && let Cow::Owned(fixed) = upright_exif(e) @@ -271,3 +326,15 @@ impl Image { Ok(Image::from_raw(ow as u32, oh as u32, layout, sample, data)?.with_icc(icc).with_meta(meta)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_impossible_allocation_is_an_error_not_an_abort() { + assert!(matches!(alloc_zeroed(usize::MAX), Err(CodecError::LimitExceeded(_)))); + assert!(matches!(orient_n::<1>(&[], 6, (0, 0), 0), Ok(v) if v.is_empty())); + assert_eq!(alloc_zeroed(3).unwrap(), vec![0, 0, 0]); + } +} diff --git a/crates/codecs/tests/orientation.rs b/crates/codecs/tests/orientation.rs index 7010288fa..980833079 100644 --- a/crates/codecs/tests/orientation.rs +++ b/crates/codecs/tests/orientation.rs @@ -124,6 +124,8 @@ fn reads_orientation_in_every_encoding() { assert_eq!(exif_orientation(&fixed), 1); assert_eq!(fixed.len(), prefixed.len(), "rewritten in place"); assert_eq!(&fixed[..6], b"Exif\0\0"); + // Rewritten in the entry's own type and width: exactly `exif(1, ..)`. + assert_eq!(&fixed[6..], &exif(1, big, long)[..], "o={o} big={big} long={long}"); } } } @@ -139,10 +141,11 @@ fn malformed_or_out_of_range_orientation_reads_as_1() { assert_eq!(exif_orientation(&exif(bad, false, true)), 1, "{bad}"); } let good = exif(6, false, false); + assert_eq!(exif_orientation(&good), 6); for n in 0..good.len() { - let o = exif_orientation(&good[..n]); - assert!(o == 1 || o == 6, "truncated at {n}: {o}"); - let _ = upright_exif(&good[..n]); + // Any cut, even one that keeps the Orientation entry but loses the next-IFD pointer. + assert_eq!(exif_orientation(&good[..n]), 1, "truncated at {n}"); + assert!(matches!(upright_exif(&good[..n]), std::borrow::Cow::Borrowed(_)), "truncated at {n}"); } // IFD offset past the end, a huge entry count, a wrong field type. let mut far = good.clone(); @@ -150,7 +153,7 @@ fn malformed_or_out_of_range_orientation_reads_as_1() { assert_eq!(exif_orientation(&far), 1); let mut many = good.clone(); many[8..10].copy_from_slice(&u16::MAX.to_le_bytes()); - assert_eq!(exif_orientation(&many), 6, "entries past the end are ignored, the real one still found"); + assert_eq!(exif_orientation(&many), 1, "an entry count past the end is a truncated table"); let mut typed = good.clone(); typed[24..26].copy_from_slice(&2u16.to_le_bytes()); // ASCII assert_eq!(exif_orientation(&typed), 1); @@ -387,3 +390,167 @@ fn rotating_24_mp_is_fast() { assert!(best <= 50.0, "orientation {o} took {best:.1} ms"); } } + +/// A TIFF-structured EXIF block: IFD0 at offset 8 holding `entries` +/// (`tag, type, count, 4-byte value field`), a zero next-IFD pointer, then `tail`. +fn ifd(big: bool, entries: &[(u16, u16, u32, [u8; 4])], tail: &[u8]) -> Vec { + let u16b = |v: u16| if big { v.to_be_bytes() } else { v.to_le_bytes() }; + let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() }; + let mut v = if big { b"MM\0*".to_vec() } else { b"II*\0".to_vec() }; + v.extend_from_slice(&u32b(8)); + v.extend_from_slice(&u16b(entries.len() as u16)); + for &(tag, ty, count, value) in entries { + v.extend_from_slice(&u16b(tag)); + v.extend_from_slice(&u16b(ty)); + v.extend_from_slice(&u32b(count)); + v.extend_from_slice(&value); + } + v.extend_from_slice(&u32b(0)); + v.extend_from_slice(tail); + v +} + +/// The 4-byte value field of a SHORT. +fn short(v: u16, big: bool) -> [u8; 4] { + let b = if big { v.to_be_bytes() } else { v.to_le_bytes() }; + [b[0], b[1], 0, 0] +} + +#[test] +fn duplicate_orientation_entries_first_wins() { + for big in [false, true] { + let o = |v| (0x0112, 3, 1, short(v, big)); + assert_eq!(exif_orientation(&ifd(big, &[o(6), o(3)], &[])), 6); + assert_eq!(exif_orientation(&ifd(big, &[o(3), o(6)], &[])), 3); + assert_eq!(exif_orientation(&ifd(big, &[o(1), o(8)], &[])), 1); + // A malformed first entry is ignored as a whole: a later good one does not count. + let bad_first = ifd(big, &[(0x0112, 3, 2, short(6, big)), o(8)], &[]); + assert_eq!(exif_orientation(&bad_first), 1); + let long = |v: u32| (0x0112, 4, 1, if big { v.to_be_bytes() } else { v.to_le_bytes() }); + let long_first = ifd(big, &[long(6), o(8)], &[]); + assert_eq!(exif_orientation(&long_first), 6, "a LONG first entry counts"); + assert_eq!(&upright_exif(&long_first)[..], &ifd(big, &[long(1), o(1)], &[])[..], "each in its own width"); + let rational_first = ifd(big, &[(0x0112, 5, 1, short(6, big)), o(8)], &[]); + assert_eq!(exif_orientation(&rational_first), 1); + // Upright: every well-formed duplicate becomes 1, so no reader rotates twice. + let dup = ifd(big, &[o(6), o(3)], &[]); + let fixed = upright_exif(&dup); + assert_eq!(fixed.len(), dup.len()); + assert_eq!(&fixed[..], &ifd(big, &[o(1), o(1)], &[])[..]); + // Only a later duplicate disagrees: it is rewritten too. + assert_eq!(&upright_exif(&ifd(big, &[o(1), o(8)], &[]))[..], &ifd(big, &[o(1), o(1)], &[])[..]); + assert!(matches!(upright_exif(&ifd(big, &[o(1), o(1)], &[])), std::borrow::Cow::Borrowed(_))); + } +} + +#[test] +fn strict_ifd_parsing_ignores_the_tag() { + for big in [false, true] { + let o6 = (0x0112, 3, 1, short(6, big)); + let good = ifd(big, &[o6], &[]); + assert_eq!(exif_orientation(&good), 6); + // Truncated before (or inside) the next-IFD pointer. + for cut in 1..=4 { + assert_eq!(exif_orientation(&good[..good.len() - cut]), 1, "cut {cut}"); + } + // IFD offsets inside the 8-byte header. + for off in 0..8u32 { + let mut b = good.clone(); + b[4..8].copy_from_slice(&if big { off.to_be_bytes() } else { off.to_le_bytes() }); + assert_eq!(exif_orientation(&b), 1, "offset {off}"); + assert!(matches!(upright_exif(&b), std::borrow::Cow::Borrowed(_))); + } + // Count must be exactly 1, type must be SHORT or LONG. + let long6 = if big { 6u32.to_be_bytes() } else { 6u32.to_le_bytes() }; + for count in [0, 2, 3, u32::MAX] { + assert_eq!(exif_orientation(&ifd(big, &[(0x0112, 3, count, short(6, big))], &[])), 1, "count {count}"); + assert_eq!(exif_orientation(&ifd(big, &[(0x0112, 4, count, long6)], &[])), 1, "LONG count {count}"); + } + for ty in [1, 2, 5, 6, 7, 8, 9, 0, 0xFFFF] { + let b = ifd(big, &[(0x0112, ty, 1, short(6, big))], &[]); + assert_eq!(exif_orientation(&b), 1, "type {ty}"); + assert!(matches!(upright_exif(&b), std::borrow::Cow::Borrowed(_)), "type {ty}"); + } + } +} + +#[test] +fn upright_exif_keeps_every_other_byte() { + for big in [false, true] { + let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() }; + // IFD0: Make (12-byte ASCII stored after the IFD), Orientation 6, Software (inline ASCII), + // ExifIFD pointer to a sub-IFD with its own entries. + let ifd_end = 8 + 2 + 4 * 12 + 4; + let make_at = ifd_end as u32; + let sub_at = make_at + 12; + let mut tail = b"PhotoCraftCo".to_vec(); + // Sub-IFD: one DateTimeOriginal-ish SHORT entry, then next-IFD 0. + tail.extend_from_slice(&if big { 1u16.to_be_bytes() } else { 1u16.to_le_bytes() }); + tail.extend_from_slice(&if big { 0x9000u16.to_be_bytes() } else { 0x9000u16.to_le_bytes() }); + tail.extend_from_slice(&if big { 7u16.to_be_bytes() } else { 7u16.to_le_bytes() }); + tail.extend_from_slice(&u32b(4)); + tail.extend_from_slice(b"0232"); + tail.extend_from_slice(&u32b(0)); + let e = ifd(big, &[(0x010F, 2, 12, u32b(make_at)), (0x0112, 3, 1, short(6, big)), (0x0131, 2, 4, *b"PC1\0"), (0x8769, 4, 1, u32b(sub_at))], &tail); + let mut prefixed = b"Exif\0\0".to_vec(); + prefixed.extend_from_slice(&e); + let fixed = upright_exif(&prefixed).into_owned(); + assert_eq!(exif_orientation(&fixed), 1); + assert_eq!(fixed.len(), prefixed.len()); + // Only the Orientation value (2 bytes in the second entry) differs. + let value = 6 + 8 + 2 + 12 + 8; + let changed: Vec = (0..fixed.len()).filter(|&i| fixed[i] != prefixed[i]).collect(); + assert_eq!(changed, vec![if big { value + 1 } else { value }], "big={big}"); + assert_eq!(&fixed[..value], &prefixed[..value]); + assert_eq!(&fixed[value + 2..], &prefixed[value + 2..]); + // The offset-based Make string and the inline Software tag are intact. + assert_eq!(&fixed[6 + make_at as usize..][..12], b"PhotoCraftCo"); + assert!(fixed.windows(4).any(|w| w == b"PC1\0")); + } +} + +#[test] +fn limits_apply_to_the_upright_size() { + // Stored 32×16 landscape; Orientation 6 makes it a 16×32 portrait. + let jpeg = encode(&gray(32, 16, vec![90; 32 * 16]), Format::Jpeg, &EncodeOptions::default()).unwrap(); + let tall = jpeg_with_exif(&jpeg, &exif(6, false, false)); + let limits = Limits { max_width: 32, max_height: 16, ..Limits::default() }; + let opts = DecodeOptions { limits, ..Default::default() }; + assert!(matches!(decode_with(&tall, &opts), Err(CodecError::LimitExceeded(_))), "the upright portrait is too tall"); + // As stored it fits, and a 180° turn keeps the size. + let kept = decode_with(&tall, &DecodeOptions { keep_orientation: true, ..opts.clone() }).unwrap(); + assert_eq!(kept.dimensions(), (32, 16)); + let flipped = decode_with(&jpeg_with_exif(&jpeg, &exif(3, false, false)), &opts).unwrap(); + assert_eq!(flipped.dimensions(), (32, 16)); + // The same holds for a TIFF, whose orientation lives in its own IFD0. + let tiff = tiff_with_orientation(&[7; 32 * 16], 32, 16, 8); + assert!(matches!(decode_with(&tiff, &opts), Err(CodecError::LimitExceeded(_)))); + assert_eq!(decode_with(&tiff, &DecodeOptions::default()).unwrap().dimensions(), (16, 32)); +} + +#[test] +fn long_orientation_reads_and_rewrites_in_both_byte_orders() { + for big in [false, true] { + let u32b = |v: u32| if big { v.to_be_bytes() } else { v.to_le_bytes() }; + let long = |v: u32| (0x0112u16, 4u16, 1u32, u32b(v)); + for o in 1..=8u32 { + let e = ifd(big, &[(0x0131, 2, 4, *b"PC1\0"), long(o)], b"tail"); + assert_eq!(exif_orientation(&e), o as u16, "o={o} big={big}"); + let fixed = upright_exif(&e); + assert_eq!(exif_orientation(&fixed), 1); + // Still a LONG, all 4 value bytes set to 1 in the file's byte order; nothing else moved. + assert_eq!(&fixed[..], &ifd(big, &[(0x0131, 2, 4, *b"PC1\0"), long(1)], b"tail")[..], "o={o} big={big}"); + } + // Out of range, including values that only fit in a LONG, reads as 1 and is left alone. + for bad in [0, 9, 0x1_0006, u32::MAX] { + let e = ifd(big, &[long(bad)], &[]); + assert_eq!(exif_orientation(&e), 1, "{bad}"); + } + // A LONG 6 in a JPEG opens upright. + let jpeg = encode(&gray(32, 16, vec![90; 32 * 16]), Format::Jpeg, &EncodeOptions::default()).unwrap(); + let img = decode(&jpeg_with_exif(&jpeg, &ifd(big, &[long(6)], &[]))).unwrap(); + assert_eq!(img.dimensions(), (16, 32)); + let kept = img.meta.exif.as_deref().unwrap(); + assert_eq!(kept.strip_prefix(b"Exif\0\0").unwrap_or(kept), &ifd(big, &[long(1)], &[])[..]); + } +}