Harden EXIF orientation: limits after rotation, fallible allocation, strict IFD parsing (#345) (#345)

This commit is contained in:
Brandon Thomas 2026-10-06 18:21:33 -04:00 committed by GitHub
parent 909efc0f6d
commit 0349b0c0f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 447 additions and 58 deletions

View File

@ -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<u8> {
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<u8> {
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::<Value>(&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();
}

View File

@ -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

View File

@ -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)
}

View File

@ -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<Item = usize>)> {
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<u32> {
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<u8> {
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<Value> {
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<Value> = 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<u8> = 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<const N: usize>(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<const N: usize>(src: &[u8], o: u16, (w, h): (usize, usize), ow: usize) -> Vec<u8> {
let mut out = vec![0u8; src.len()];
fn orient_n<const N: usize>(src: &[u8], o: u16, (w, h): (usize, usize), ow: usize) -> Result<Vec<u8>, CodecError> {
let mut out = alloc_zeroed(src.len())?;
let (s, _) = src.as_chunks::<N>();
let (d, _) = out.as_chunks_mut::<N>();
let per = (BAND * ow).max(1);
@ -214,11 +261,19 @@ fn orient_n<const N: usize>(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<Vec<u8>, 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<u8> {
fn orient_bytes(src: &[u8], bpp: usize, o: u16, (w, h): (usize, usize), ow: usize) -> Result<Vec<u8>, 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]);
}
}

View File

@ -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<u8> {
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<usize> = (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)], &[])[..]);
}
}