use alloc::borrow::Cow;
use alloc::vec::Vec;
use zenpixels::Orientation;
const TAG_MAKE: u16 = 0x010F;
const TAG_MODEL: u16 = 0x0110;
const TAG_ORIENTATION: u16 = 0x0112;
const TAG_SOFTWARE: u16 = 0x0131;
const TAG_DATETIME: u16 = 0x0132; const TAG_ARTIST: u16 = 0x013B;
const TAG_HOST_COMPUTER: u16 = 0x013C;
const TAG_COPYRIGHT: u16 = 0x8298;
const TAG_EXIF_IFD: u16 = 0x8769;
const TAG_GPS_IFD: u16 = 0x8825;
const TAG_INTEROP_IFD: u16 = 0xA005;
const TAG_THUMB_OFFSET: u16 = 0x0201; const TAG_THUMB_LENGTH: u16 = 0x0202; const TAG_SUBIFDS: u16 = 0x014A;
const TAG_STRIP_OFFSETS: u16 = 0x0111;
const TAG_STRIP_BYTE_COUNTS: u16 = 0x0117;
const TAG_TILE_OFFSETS: u16 = 0x0144;
const TAG_TILE_BYTE_COUNTS: u16 = 0x0145;
const TAG_DATETIME_ORIGINAL: u16 = 0x9003;
const TAG_DATETIME_DIGITIZED: u16 = 0x9004;
const TAG_OFFSET_TIME: u16 = 0x9010; const TAG_OFFSET_TIME_ORIGINAL: u16 = 0x9011; const TAG_OFFSET_TIME_DIGITIZED: u16 = 0x9012; const TAG_SUBSEC_TIME: u16 = 0x9290;
const TAG_SUBSEC_TIME_ORIGINAL: u16 = 0x9291;
const TAG_SUBSEC_TIME_DIGITIZED: u16 = 0x9292;
const TAG_MAKER_NOTE: u16 = 0x927C;
const TAG_IMAGE_UNIQUE_ID: u16 = 0xA420;
const TAG_BODY_SERIAL_NUMBER: u16 = 0xA431;
const TAG_LENS_SPECIFICATION: u16 = 0xA432;
const TAG_LENS_MAKE: u16 = 0xA433;
const TAG_LENS_MODEL: u16 = 0xA434;
const TAG_LENS_SERIAL_NUMBER: u16 = 0xA435;
const TAG_CAMERA_OWNER_NAME: u16 = 0xA430;
const TAG_PHOTOGRAPHER: u16 = 0xA437; const TAG_IMAGE_EDITOR: u16 = 0xA438;
const TAG_CAMERA_FIRMWARE: u16 = 0xA439; const TAG_RAW_DEVELOPING_SOFTWARE: u16 = 0xA43A; const TAG_IMAGE_EDITING_SOFTWARE: u16 = 0xA43B; const TAG_METADATA_EDITING_SOFTWARE: u16 = 0xA43C;
const TIFF_BYTE: u16 = 1;
const TIFF_ASCII: u16 = 2;
const TIFF_SHORT: u16 = 3;
const TIFF_LONG: u16 = 4;
const TIFF_RATIONAL: u16 = 5;
const TIFF_SBYTE: u16 = 6;
const TIFF_UNDEFINED: u16 = 7;
const TIFF_SSHORT: u16 = 8;
const TIFF_SLONG: u16 = 9;
const TIFF_SRATIONAL: u16 = 10;
const TIFF_FLOAT: u16 = 11;
const TIFF_DOUBLE: u16 = 12;
const TIFF_IFD: u16 = 13;
const TIFF_UTF8: u16 = 129;
const TIFF_HEADER_SIZE: usize = 8;
const MAX_IFD_ENTRIES: u16 = 1000;
const EXIF_PREFIX: &[u8] = b"Exif\0\0";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ByteOrder {
Little,
Big,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TextEncoding {
Ascii,
Utf8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Category {
Orientation,
Rights,
Datetimes,
Camera,
Other,
}
fn classify(tag: u16) -> Category {
match tag {
TAG_ORIENTATION => Category::Orientation,
TAG_COPYRIGHT
| TAG_ARTIST
| TAG_CAMERA_OWNER_NAME
| TAG_PHOTOGRAPHER
| TAG_IMAGE_EDITOR => Category::Rights,
TAG_DATETIME
| TAG_DATETIME_ORIGINAL
| TAG_DATETIME_DIGITIZED
| TAG_OFFSET_TIME
| TAG_OFFSET_TIME_ORIGINAL
| TAG_OFFSET_TIME_DIGITIZED
| TAG_SUBSEC_TIME
| TAG_SUBSEC_TIME_ORIGINAL
| TAG_SUBSEC_TIME_DIGITIZED => Category::Datetimes,
TAG_MAKE
| TAG_MODEL
| TAG_SOFTWARE
| TAG_HOST_COMPUTER
| TAG_MAKER_NOTE
| TAG_IMAGE_UNIQUE_ID
| TAG_BODY_SERIAL_NUMBER
| TAG_LENS_SPECIFICATION
| TAG_LENS_MAKE
| TAG_LENS_MODEL
| TAG_LENS_SERIAL_NUMBER
| TAG_CAMERA_FIRMWARE
| TAG_RAW_DEVELOPING_SOFTWARE
| TAG_IMAGE_EDITING_SOFTWARE
| TAG_METADATA_EDITING_SOFTWARE => Category::Camera,
_ => Category::Other,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Entry<'a> {
tag: u16,
kind: u16,
count: u32,
value: Cow<'a, [u8]>,
value_offset: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Exif<'a> {
order: ByteOrder,
had_prefix: bool,
ifd0: Vec<Entry<'a>>,
exif_ifd: Option<Vec<Entry<'a>>>,
gps_ifd: Option<Vec<Entry<'a>>>,
ifd1: Option<Vec<Entry<'a>>>,
thumbnail: Option<&'a [u8]>,
text_encoding: TextEncoding,
}
fn type_size(kind: u16) -> Option<usize> {
Some(match kind {
TIFF_BYTE | TIFF_ASCII | TIFF_SBYTE | TIFF_UNDEFINED | TIFF_UTF8 => 1,
TIFF_SHORT | TIFF_SSHORT => 2,
TIFF_LONG | TIFF_SLONG | TIFF_FLOAT | TIFF_IFD => 4,
TIFF_RATIONAL | TIFF_SRATIONAL | TIFF_DOUBLE => 8,
_ => return None,
})
}
fn rd16(d: &[u8], o: usize, order: ByteOrder) -> Option<u16> {
let b = d.get(o..o + 2)?;
Some(match order {
ByteOrder::Big => u16::from_be_bytes([b[0], b[1]]),
ByteOrder::Little => u16::from_le_bytes([b[0], b[1]]),
})
}
fn rd32(d: &[u8], o: usize, order: ByteOrder) -> Option<u32> {
let b = d.get(o..o + 4)?;
Some(match order {
ByteOrder::Big => u32::from_be_bytes([b[0], b[1], b[2], b[3]]),
ByteOrder::Little => u32::from_le_bytes([b[0], b[1], b[2], b[3]]),
})
}
fn parse_ifd(tiff: &[u8], off: usize, order: ByteOrder) -> Option<(Vec<Entry<'_>>, u32)> {
let count = rd16(tiff, off, order)?;
if count > MAX_IFD_ENTRIES {
return None; }
let entries_start = off.checked_add(2)?;
let mut entries = Vec::new();
for i in 0..count as usize {
let Some(e) = i.checked_mul(12).and_then(|o| entries_start.checked_add(o)) else {
break;
};
if e.checked_add(12).is_none_or(|end| end > tiff.len()) {
break; }
if let Some(entry) = resolve_entry(tiff, e, order) {
entries.push(entry);
}
}
let next = rd32(tiff, entries_start.checked_add(count as usize * 12)?, order).unwrap_or(0);
Some((entries, next))
}
fn resolve_entry(tiff: &[u8], e: usize, order: ByteOrder) -> Option<Entry<'_>> {
let tag = rd16(tiff, e, order)?;
let kind = rd16(tiff, e + 2, order)?;
let cnt = rd32(tiff, e + 4, order)?;
let tsize = type_size(kind)?;
let byte_len = (cnt as usize).checked_mul(tsize)?;
let (value, value_offset) = if byte_len <= 4 {
(tiff.get(e + 8..e + 8 + byte_len)?, e + 8)
} else {
let voff = rd32(tiff, e + 8, order)? as usize;
(tiff.get(voff..voff.checked_add(byte_len)?)?, voff)
};
Some(Entry {
tag,
kind,
count: cnt,
value: Cow::Borrowed(value),
value_offset,
})
}
fn take_pointer(entries: &mut Vec<Entry<'_>>, tag: u16, order: ByteOrder) -> Option<usize> {
let pos = entries.iter().position(|e| e.tag == tag)?;
let b = entries[pos].value.get(0..4)?;
let off = match order {
ByteOrder::Big => u32::from_be_bytes([b[0], b[1], b[2], b[3]]),
ByteOrder::Little => u32::from_le_bytes([b[0], b[1], b[2], b[3]]),
} as usize;
entries.remove(pos);
Some(off)
}
impl<'a> Default for Exif<'a> {
fn default() -> Self {
Self::new(TextEncoding::Ascii)
}
}
impl<'a> Exif<'a> {
pub fn new(text_encoding: TextEncoding) -> Self {
Exif {
order: ByteOrder::Little,
had_prefix: false,
ifd0: Vec::new(),
exif_ifd: None,
gps_ifd: None,
ifd1: None,
thumbnail: None,
text_encoding,
}
}
pub fn parse(data: &'a [u8]) -> Option<Self> {
let had_prefix =
data.len() >= EXIF_PREFIX.len() && data[..EXIF_PREFIX.len()] == *EXIF_PREFIX;
let tiff = if had_prefix {
&data[EXIF_PREFIX.len()..]
} else {
data
};
if tiff.len() < TIFF_HEADER_SIZE {
return None;
}
let order = match [tiff[0], tiff[1]] {
[b'M', b'M'] => ByteOrder::Big,
[b'I', b'I'] => ByteOrder::Little,
_ => return None,
};
if rd16(tiff, 2, order)? != 42 {
return None;
}
let ifd0_off = rd32(tiff, 4, order)? as usize;
let (mut ifd0, next) = parse_ifd(tiff, ifd0_off, order)?;
let exif_ifd = take_pointer(&mut ifd0, TAG_EXIF_IFD, order).and_then(|o| {
parse_ifd(tiff, o, order).map(|(mut e, _)| {
take_pointer(&mut e, TAG_INTEROP_IFD, order);
e
})
});
let gps_ifd = take_pointer(&mut ifd0, TAG_GPS_IFD, order)
.and_then(|o| parse_ifd(tiff, o, order).map(|(e, _)| e));
let (mut ifd1, mut thumbnail) = (None, None);
if next != 0
&& let Some((mut entries, _)) = parse_ifd(tiff, next as usize, order)
{
let toff = entries
.iter()
.find(|e| e.tag == TAG_THUMB_OFFSET)
.and_then(|e| read_uint(e, order));
let tlen = entries
.iter()
.find(|e| e.tag == TAG_THUMB_LENGTH)
.and_then(|e| read_uint(e, order));
if let (Some(o), Some(l)) = (toff, tlen)
&& let Some(t) = (o as usize)
.checked_add(l as usize)
.and_then(|end| tiff.get(o as usize..end))
{
thumbnail = Some(t);
}
entries.retain(|e| e.tag != TAG_THUMB_OFFSET && e.tag != TAG_THUMB_LENGTH);
ifd1 = Some(entries);
}
if exif_ifd.is_some() {
ifd0.retain(|e| e.tag != TAG_EXIF_IFD);
}
if gps_ifd.is_some() {
ifd0.retain(|e| e.tag != TAG_GPS_IFD);
}
Some(Exif {
order,
had_prefix,
ifd0,
exif_ifd,
gps_ifd,
ifd1,
thumbnail,
text_encoding: TextEncoding::Ascii,
})
}
pub fn byte_order(&self) -> ByteOrder {
self.order
}
pub fn orientation(&self) -> Option<Orientation> {
let e = self.ifd0.iter().find(|e| e.tag == TAG_ORIENTATION)?;
let raw = read_uint(e, self.order)?;
Orientation::from_exif(u8::try_from(raw).ok()?)
}
pub fn copyright(&self) -> Option<Cow<'_, str>> {
ascii_value(&self.ifd0, TAG_COPYRIGHT)
}
pub fn artist(&self) -> Option<Cow<'_, str>> {
ascii_value(&self.ifd0, TAG_ARTIST)
}
pub fn copyright_bytes(&self) -> Option<&[u8]> {
ascii_bytes(&self.ifd0, TAG_COPYRIGHT)
}
pub fn artist_bytes(&self) -> Option<&[u8]> {
ascii_bytes(&self.ifd0, TAG_ARTIST)
}
pub fn has_thumbnail(&self) -> bool {
self.thumbnail.is_some()
}
pub fn has_gps(&self) -> bool {
self.gps_ifd.is_some()
}
pub fn has_camera(&self) -> bool {
self.has_category(Category::Camera)
}
pub fn has_datetimes(&self) -> bool {
self.has_category(Category::Datetimes)
}
fn has_category(&self, cat: Category) -> bool {
let any = |ifd: &[Entry<'_>]| ifd.iter().any(|e| classify(e.tag) == cat);
any(&self.ifd0) || self.exif_ifd.as_deref().is_some_and(any)
}
pub fn set_copyright(&mut self, text: &str) {
set_ifd0_string(&mut self.ifd0, TAG_COPYRIGHT, text, self.text_encoding);
}
pub fn set_artist(&mut self, text: &str) {
set_ifd0_string(&mut self.ifd0, TAG_ARTIST, text, self.text_encoding);
}
pub fn set_orientation(&mut self, o: Orientation) {
let order = self.order;
let v = u32::from(o.to_exif());
match self.ifd0.iter_mut().find(|e| e.tag == TAG_ORIENTATION) {
Some(entry) => match int_bytes(entry.kind, v, order) {
Some(value) => {
entry.count = 1;
entry.value = value;
}
None => *entry = orientation_entry(o, order),
},
None => self.ifd0.push(orientation_entry(o, order)),
}
}
pub fn filtered(&self, policy: &ExifPolicy) -> Exif<'a> {
let keep = |e: &&Entry<'a>| match e.tag {
TAG_SUBIFDS
| TAG_EXIF_IFD
| TAG_GPS_IFD
| TAG_INTEROP_IFD
| TAG_THUMB_OFFSET
| TAG_THUMB_LENGTH
| TAG_STRIP_OFFSETS
| TAG_STRIP_BYTE_COUNTS
| TAG_TILE_OFFSETS
| TAG_TILE_BYTE_COUNTS => false,
TAG_MAKER_NOTE => false,
tag => policy.keeps(classify(tag)),
};
let ifd0 = self.ifd0.iter().filter(keep).cloned().collect();
let exif_ifd = self
.exif_ifd
.as_ref()
.map(|d| d.iter().filter(keep).cloned().collect::<Vec<_>>())
.filter(|d: &Vec<_>| !d.is_empty());
let gps_ifd = match policy.gps {
Retention::Keep => self.gps_ifd.clone(),
Retention::Discard => None,
};
let (ifd1, thumbnail) = match policy.thumbnail {
Retention::Keep => (
self.ifd1
.as_ref()
.map(|d| d.iter().filter(keep).cloned().collect::<Vec<_>>()),
self.thumbnail,
),
Retention::Discard => (None, None),
};
Exif {
order: self.order,
had_prefix: self.had_prefix,
ifd0,
exif_ifd,
gps_ifd,
ifd1,
thumbnail,
text_encoding: self.text_encoding,
}
}
fn set_orientation_tag(&mut self, o: Orientation) {
let order = self.order;
let Some(entry) = self.ifd0.iter_mut().find(|e| e.tag == TAG_ORIENTATION) else {
return;
};
if let Some(value) = int_bytes(entry.kind, u32::from(o.to_exif()), order) {
entry.value = value;
entry.count = 1;
}
}
pub(crate) fn serialized_len(&self) -> usize {
let ifd0_nptr = self.exif_ifd.is_some() as usize + self.gps_ifd.is_some() as usize;
let ifd1_nptr = if self.thumbnail.is_some() { 2 } else { 0 };
let block = |entries: &[Entry<'a>], nptr: usize| -> usize {
2 + 12 * (entries.len() + nptr) + 4 + ext_size(entries)
};
let mut total = if self.had_prefix {
EXIF_PREFIX.len()
} else {
0
};
total += TIFF_HEADER_SIZE + block(&self.ifd0, ifd0_nptr);
if let Some(d) = &self.exif_ifd {
total += block(d, 0);
}
if let Some(d) = &self.gps_ifd {
total += block(d, 0);
}
if let Some(d) = &self.ifd1 {
total += block(d, ifd1_nptr);
}
if let Some(t) = self.thumbnail {
total += t.len();
}
total
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(self.serialized_len());
if self.had_prefix {
out.extend_from_slice(EXIF_PREFIX);
}
let ifd0_ptrs = {
let mut v = Vec::new();
if self.exif_ifd.is_some() {
v.push(TAG_EXIF_IFD);
}
if self.gps_ifd.is_some() {
v.push(TAG_GPS_IFD);
}
v
};
let ifd1_ptrs: &[u16] = if self.thumbnail.is_some() {
&[TAG_THUMB_OFFSET, TAG_THUMB_LENGTH]
} else {
&[]
};
let sz = |entries: &[Entry<'a>], nptr: usize| -> (usize, usize) {
let table = 2 + 12 * (entries.len() + nptr) + 4;
(table, ext_size(entries))
};
let (t0, x0) = sz(&self.ifd0, ifd0_ptrs.len());
let mut cursor = TIFF_HEADER_SIZE + t0 + x0;
let exif_off = self.exif_ifd.as_ref().map(|d| {
let o = cursor;
let (t, x) = sz(d, 0);
cursor += t + x;
o
});
let gps_off = self.gps_ifd.as_ref().map(|d| {
let o = cursor;
let (t, x) = sz(d, 0);
cursor += t + x;
o
});
let ifd1_off = self.ifd1.as_ref().map(|d| {
let o = cursor;
let (t, x) = sz(d, ifd1_ptrs.len());
cursor += t + x;
o
});
let thumb_off = self.thumbnail.map(|t| {
let o = cursor;
cursor += t.len();
o
});
match self.order {
ByteOrder::Little => out.extend_from_slice(b"II"),
ByteOrder::Big => out.extend_from_slice(b"MM"),
}
self.put16(&mut out, 42);
self.put32(&mut out, (TIFF_HEADER_SIZE) as u32);
let mut ifd0_ptr_vals = Vec::new();
if let Some(o) = exif_off {
ifd0_ptr_vals.push((TAG_EXIF_IFD, o as u32));
}
if let Some(o) = gps_off {
ifd0_ptr_vals.push((TAG_GPS_IFD, o as u32));
}
self.write_ifd(
&mut out,
&self.ifd0,
&ifd0_ptr_vals,
TIFF_HEADER_SIZE + t0,
ifd1_off.unwrap_or(0) as u32,
);
if let Some(d) = &self.exif_ifd {
let eb = exif_off.unwrap() + 2 + 12 * d.len() + 4;
self.write_ifd(&mut out, d, &[], eb, 0);
}
if let Some(d) = &self.gps_ifd {
let eb = gps_off.unwrap() + 2 + 12 * d.len() + 4;
self.write_ifd(&mut out, d, &[], eb, 0);
}
if let Some(d) = &self.ifd1 {
let mut pv = Vec::new();
if let (Some(to), Some(t)) = (thumb_off, self.thumbnail) {
pv.push((TAG_THUMB_OFFSET, to as u32));
pv.push((TAG_THUMB_LENGTH, t.len() as u32));
}
let eb = ifd1_off.unwrap() + 2 + 12 * (d.len() + ifd1_ptrs.len()) + 4;
self.write_ifd(&mut out, d, &pv, eb, 0);
}
if let Some(t) = self.thumbnail {
out.extend_from_slice(t);
}
out
}
fn put16(&self, out: &mut Vec<u8>, v: u16) {
match self.order {
ByteOrder::Big => out.extend_from_slice(&v.to_be_bytes()),
ByteOrder::Little => out.extend_from_slice(&v.to_le_bytes()),
}
}
fn put32(&self, out: &mut Vec<u8>, v: u32) {
match self.order {
ByteOrder::Big => out.extend_from_slice(&v.to_be_bytes()),
ByteOrder::Little => out.extend_from_slice(&v.to_le_bytes()),
}
}
fn write_ifd(
&self,
out: &mut Vec<u8>,
entries: &[Entry<'a>],
ptr_vals: &[(u16, u32)],
ext_base: usize,
next: u32,
) {
enum Item<'b, 'a> {
Real(&'b Entry<'a>),
Ptr(u16, u32),
}
let mut items: Vec<Item> = entries
.iter()
.map(Item::Real)
.chain(ptr_vals.iter().map(|&(t, v)| Item::Ptr(t, v)))
.collect();
items.sort_by_key(|it| match it {
Item::Real(e) => e.tag,
Item::Ptr(t, _) => *t,
});
self.put16(out, items.len() as u16);
let mut ext = Vec::new();
let mut placed: Vec<(usize, usize, usize)> = Vec::new(); for it in &items {
match it {
Item::Ptr(tag, val) => {
self.put16(out, *tag);
self.put16(out, TIFF_LONG);
self.put32(out, 1);
self.put32(out, *val);
}
Item::Real(e) => {
self.put16(out, e.tag);
self.put16(out, e.kind);
self.put32(out, e.count);
if e.value.len() <= 4 {
let mut v = [0u8; 4];
v[..e.value.len()].copy_from_slice(&e.value);
out.extend_from_slice(&v);
} else {
let (ptr, len) = (e.value.as_ptr() as usize, e.value.len());
let off = if let Some(&(.., o)) =
placed.iter().find(|&&(p, l, _)| p == ptr && l == len)
{
o
} else {
let o = ext.len();
ext.extend_from_slice(&e.value);
if ext.len() % 2 == 1 {
ext.push(0);
}
placed.push((ptr, len, o));
o
};
self.put32(out, (ext_base + off) as u32);
}
}
}
}
self.put32(out, next);
out.extend_from_slice(&ext);
}
}
fn ext_size(entries: &[Entry<'_>]) -> usize {
let mut total = 0usize;
let mut placed: Vec<(usize, usize)> = Vec::new(); for e in entries {
if e.value.len() <= 4 {
continue;
}
let key = (e.value.as_ptr() as usize, e.value.len());
if !placed.contains(&key) {
total += align2(e.value.len());
placed.push(key);
}
}
total
}
fn align2(n: usize) -> usize {
n + (n & 1)
}
fn read_uint(e: &Entry<'_>, order: ByteOrder) -> Option<u32> {
match e.kind {
TIFF_SHORT => rd16(&e.value, 0, order).map(u32::from),
TIFF_LONG => rd32(&e.value, 0, order),
_ => None,
}
}
pub(crate) fn set_orientation(data: &[u8], value: Orientation) -> Option<Vec<u8>> {
let exif = Exif::parse(data)?;
set_orientation_with(data, &exif, value)
}
fn set_orientation_with(data: &[u8], exif: &Exif<'_>, value: Orientation) -> Option<Vec<u8>> {
let entry = exif.ifd0.iter().find(|e| e.tag == TAG_ORIENTATION)?;
let size = match entry.kind {
TIFF_SHORT => 2usize,
TIFF_LONG => 4,
_ => return None, };
let base = if exif.had_prefix {
EXIF_PREFIX.len()
} else {
0
};
let off = base.checked_add(entry.value_offset)?;
let v = u32::from(value as u8);
let mut out = data.to_vec();
let dst = out.get_mut(off..off.checked_add(size)?)?;
match exif.order {
ByteOrder::Big => dst.copy_from_slice(&v.to_be_bytes()[4 - size..]),
ByteOrder::Little => dst.copy_from_slice(&v.to_le_bytes()[..size]),
}
Some(out)
}
fn ascii_bytes<'e>(entries: &'e [Entry<'_>], tag: u16) -> Option<&'e [u8]> {
let e = entries.iter().find(|e| e.tag == tag)?;
if e.kind != TIFF_ASCII && e.kind != TIFF_UTF8 {
return None;
}
let value: &[u8] = &e.value;
let end = value.iter().position(|&b| b == 0).unwrap_or(value.len());
let bytes = &value[..end];
if bytes.is_empty() { None } else { Some(bytes) }
}
fn ascii_value<'e>(entries: &'e [Entry<'_>], tag: u16) -> Option<Cow<'e, str>> {
let bytes = ascii_bytes(entries, tag)?;
Some(match core::str::from_utf8(bytes) {
Ok(s) => Cow::Borrowed(s),
Err(_) => Cow::Owned(alloc::string::String::from_utf8_lossy(bytes).into_owned()),
})
}
fn set_ifd0_string<'a>(entries: &mut Vec<Entry<'a>>, tag: u16, text: &str, encoding: TextEncoding) {
let kind = match encoding {
TextEncoding::Ascii => TIFF_ASCII,
TextEncoding::Utf8 => TIFF_UTF8,
};
let mut bytes = text.as_bytes().to_vec();
bytes.push(0); let entry = Entry {
tag,
kind,
count: bytes.len() as u32,
value: Cow::Owned(bytes),
value_offset: 0, };
match entries.iter_mut().find(|e| e.tag == tag) {
Some(slot) => *slot = entry,
None => entries.push(entry),
}
}
fn int_bytes(kind: u16, v: u32, order: ByteOrder) -> Option<Cow<'static, [u8]>> {
Some(Cow::Owned(match (kind, order) {
(TIFF_SHORT, ByteOrder::Little) => (v as u16).to_le_bytes().to_vec(),
(TIFF_SHORT, ByteOrder::Big) => (v as u16).to_be_bytes().to_vec(),
(TIFF_LONG, ByteOrder::Little) => v.to_le_bytes().to_vec(),
(TIFF_LONG, ByteOrder::Big) => v.to_be_bytes().to_vec(),
_ => return None,
}))
}
fn orientation_entry<'a>(o: Orientation, order: ByteOrder) -> Entry<'a> {
let v = u16::from(o.to_exif());
Entry {
tag: TAG_ORIENTATION,
kind: TIFF_SHORT,
count: 1,
value: Cow::Owned(match order {
ByteOrder::Little => v.to_le_bytes().to_vec(),
ByteOrder::Big => v.to_be_bytes().to_vec(),
}),
value_offset: 0, }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Retention {
Keep,
Discard,
}
impl Retention {
#[inline]
#[must_use]
pub const fn keeps(self) -> bool {
matches!(self, Retention::Keep)
}
#[inline]
#[must_use]
pub const fn discards(self) -> bool {
matches!(self, Retention::Discard)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct ExifPolicy {
pub orientation: Retention,
pub rights: Retention,
pub thumbnail: Retention,
pub gps: Retention,
pub datetimes: Retention,
pub camera: Retention,
pub other: Retention,
}
impl ExifPolicy {
pub const KEEP_ALL: Self = Self {
orientation: Retention::Keep,
rights: Retention::Keep,
thumbnail: Retention::Keep,
gps: Retention::Keep,
datetimes: Retention::Keep,
camera: Retention::Keep,
other: Retention::Keep,
};
pub const DISCARD_ALL: Self = Self {
orientation: Retention::Discard,
rights: Retention::Discard,
thumbnail: Retention::Discard,
gps: Retention::Discard,
datetimes: Retention::Discard,
camera: Retention::Discard,
other: Retention::Discard,
};
pub const ATTRIBUTED_ORIENTATION: Self = Self {
orientation: Retention::Keep,
rights: Retention::Keep,
..Self::DISCARD_ALL
};
pub const ORIENTATION_ONLY: Self = Self {
orientation: Retention::Keep,
..Self::DISCARD_ALL
};
#[must_use]
pub const fn with_orientation(mut self, r: Retention) -> Self {
self.orientation = r;
self
}
#[must_use]
pub const fn with_rights(mut self, r: Retention) -> Self {
self.rights = r;
self
}
#[must_use]
pub const fn with_thumbnail(mut self, r: Retention) -> Self {
self.thumbnail = r;
self
}
#[must_use]
pub const fn with_gps(mut self, r: Retention) -> Self {
self.gps = r;
self
}
#[must_use]
pub const fn with_datetimes(mut self, r: Retention) -> Self {
self.datetimes = r;
self
}
#[must_use]
pub const fn with_camera(mut self, r: Retention) -> Self {
self.camera = r;
self
}
#[must_use]
pub const fn with_other(mut self, r: Retention) -> Self {
self.other = r;
self
}
fn keeps(&self, c: Category) -> bool {
match c {
Category::Orientation => self.orientation.keeps(),
Category::Rights => self.rights.keeps(),
Category::Datetimes => self.datetimes.keeps(),
Category::Camera => self.camera.keeps(),
Category::Other => self.other.keeps(),
}
}
pub fn keeps_everything(&self) -> bool {
self.orientation.keeps()
&& self.rights.keeps()
&& self.thumbnail.keeps()
&& self.gps.keeps()
&& self.datetimes.keeps()
&& self.camera.keeps()
&& self.other.keeps()
}
pub fn discards_everything(&self) -> bool {
*self == Self::DISCARD_ALL
}
}
pub fn retain<'a>(src: &'a [u8], policy: &ExifPolicy) -> Option<Cow<'a, [u8]>> {
retain_reconciled(src, policy, None)
}
pub(crate) fn retain_reconciled<'a>(
src: &'a [u8],
policy: &ExifPolicy,
want: Option<Orientation>,
) -> Option<Cow<'a, [u8]>> {
if policy.discards_everything() {
return None;
}
let keeps_all = policy.keeps_everything();
if keeps_all && want.is_none() {
return Some(Cow::Borrowed(src));
}
if src.len() > u32::MAX as usize {
return if keeps_all {
Some(Cow::Borrowed(src))
} else {
None
};
}
let Some(exif) = Exif::parse(src) else {
return if keeps_all {
Some(Cow::Borrowed(src))
} else {
None
};
};
if keeps_all {
match want {
Some(o) if exif.orientation() != Some(o) => match set_orientation_with(src, &exif, o) {
Some(v) => Some(Cow::Owned(v)),
None => Some(Cow::Borrowed(src)), },
_ => Some(Cow::Borrowed(src)), }
} else {
let mut pruned = exif.filtered(policy);
if let Some(o) = want {
pruned.set_orientation_tag(o);
}
if pruned.ifd0.is_empty()
&& pruned.exif_ifd.is_none()
&& pruned.gps_ifd.is_none()
&& pruned.ifd1.is_none()
{
None
} else {
let cap = src
.len()
.saturating_mul(2)
.saturating_add(1024)
.min(u32::MAX as usize);
if pruned.serialized_len() > cap {
return None;
}
Some(Cow::Owned(pruned.to_bytes()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::vec;
fn e(tag: u16, kind: u16, count: u32, value: &[u8]) -> Entry<'_> {
Entry {
tag,
kind,
count,
value: Cow::Borrowed(value),
value_offset: 0, }
}
fn sample(order: ByteOrder, had_prefix: bool) -> alloc::vec::Vec<u8> {
let ori: alloc::vec::Vec<u8> = match order {
ByteOrder::Little => 6u16.to_le_bytes().to_vec(),
ByteOrder::Big => 6u16.to_be_bytes().to_vec(),
};
let exif = Exif {
order,
had_prefix,
ifd0: vec![
e(TAG_MAKE, TIFF_ASCII, 4, b"Cam\0"), e(TAG_ORIENTATION, TIFF_SHORT, 1, &ori), e(TAG_COPYRIGHT, TIFF_ASCII, 7, b"(c) Me\0"), ],
exif_ifd: Some(vec![e(TAG_DATETIME_ORIGINAL, TIFF_ASCII, 5, b"2020\0")]),
gps_ifd: Some(vec![e(0x0001, TIFF_ASCII, 2, b"N\0")]), ifd1: Some(vec![]),
thumbnail: Some(&[0xFF, 0xD8, 0xFF, 0xD9]),
text_encoding: TextEncoding::Ascii,
};
exif.to_bytes()
}
#[test]
fn round_trip_full_tree_little_endian() {
let bytes = sample(ByteOrder::Little, false);
let x = Exif::parse(&bytes).expect("parses");
assert_eq!(x.order, ByteOrder::Little);
assert_eq!(x.orientation(), Some(Orientation::Rotate90));
assert_eq!(x.copyright().unwrap(), "(c) Me");
assert!(x.has_gps());
assert!(x.has_thumbnail());
let bytes2 = x.to_bytes();
let x2 = Exif::parse(&bytes2).expect("re-parses");
assert_eq!(x2.orientation(), Some(Orientation::Rotate90));
assert_eq!(x2.copyright().unwrap(), "(c) Me");
assert!(x2.has_gps() && x2.has_thumbnail());
}
#[test]
fn round_trip_big_endian() {
let bytes = sample(ByteOrder::Big, false);
let x = Exif::parse(&bytes).expect("parses");
assert_eq!(x.order, ByteOrder::Big);
assert_eq!(x.orientation(), Some(Orientation::Rotate90));
assert_eq!(x.copyright().unwrap(), "(c) Me");
assert!(x.has_gps() && x.has_thumbnail());
}
#[test]
fn round_trip_with_exif_prefix_and_subifds() {
let bytes = sample(ByteOrder::Little, true);
assert_eq!(&bytes[..6], b"Exif\0\0");
let x = Exif::parse(&bytes).expect("parses");
assert_eq!(x.orientation(), Some(Orientation::Rotate90));
assert_eq!(x.copyright().unwrap(), "(c) Me");
assert!(x.has_gps());
assert!(x.has_thumbnail());
}
#[test]
fn drop_gps_keeps_everything_else() {
let bytes = sample(ByteOrder::Little, false);
let x = Exif::parse(&bytes).unwrap();
let p = ExifPolicy {
gps: Retention::Discard,
..ExifPolicy::KEEP_ALL
};
let out = x.filtered(&p).to_bytes();
let y = Exif::parse(&out).unwrap();
assert!(!y.has_gps());
assert!(y.has_thumbnail());
assert_eq!(y.orientation(), Some(Orientation::Rotate90));
assert_eq!(y.copyright().unwrap(), "(c) Me");
}
#[test]
fn drop_thumbnail_keeps_everything_else() {
let bytes = sample(ByteOrder::Little, false);
let x = Exif::parse(&bytes).unwrap();
let p = ExifPolicy {
thumbnail: Retention::Discard,
..ExifPolicy::KEEP_ALL
};
let out = x.filtered(&p).to_bytes();
let y = Exif::parse(&out).unwrap();
assert!(!y.has_thumbnail());
assert!(y.has_gps());
assert_eq!(y.orientation(), Some(Orientation::Rotate90));
assert_eq!(y.copyright().unwrap(), "(c) Me");
}
#[test]
fn attributed_orientation_drops_camera_datetime_gps_thumbnail() {
let bytes = sample(ByteOrder::Little, false);
let x = Exif::parse(&bytes).unwrap();
let out = x.filtered(&ExifPolicy::ATTRIBUTED_ORIENTATION).to_bytes();
let y = Exif::parse(&out).unwrap();
assert_eq!(y.orientation(), Some(Orientation::Rotate90));
assert_eq!(y.copyright().unwrap(), "(c) Me");
assert!(!y.has_gps());
assert!(!y.has_thumbnail());
assert!(!out.windows(2).any(|w| w == TAG_MAKE.to_le_bytes()));
assert!(
!out.windows(2)
.any(|w| w == TAG_DATETIME_ORIGINAL.to_le_bytes())
);
}
#[test]
fn retain_cow_behaviour() {
let bytes = sample(ByteOrder::Little, false);
assert!(matches!(
retain(&bytes, &ExifPolicy::KEEP_ALL),
Some(Cow::Borrowed(_))
));
let p = ExifPolicy {
gps: Retention::Discard,
..ExifPolicy::KEEP_ALL
};
assert!(matches!(retain(&bytes, &p), Some(Cow::Owned(_))));
assert!(retain(&bytes, &ExifPolicy::DISCARD_ALL).is_none());
}
#[test]
fn out_of_line_copyright_relocates_correctly() {
let long = b"Copyright 2026 Lilith, all rights reserved worldwide.\0";
let exif = Exif {
order: ByteOrder::Little,
had_prefix: false,
ifd0: vec![
e(0x010F, 2, 4, b"Cam\0"),
e(TAG_COPYRIGHT, 2, long.len() as u32, long),
],
exif_ifd: None,
gps_ifd: None,
ifd1: None,
thumbnail: None,
text_encoding: TextEncoding::Ascii,
};
let pruned = exif.filtered(&ExifPolicy {
camera: Retention::Discard,
..ExifPolicy::KEEP_ALL
});
let out = pruned.to_bytes();
let y = Exif::parse(&out).unwrap();
assert_eq!(
y.copyright().unwrap(),
"Copyright 2026 Lilith, all rights reserved worldwide."
);
}
#[test]
fn malformed_inputs_return_none() {
assert!(Exif::parse(b"").is_none());
assert!(Exif::parse(b"garbage").is_none());
assert!(Exif::parse(&[0u8; 7]).is_none());
let mut bad = b"II".to_vec();
bad.extend_from_slice(&42u16.to_le_bytes());
bad.extend_from_slice(&9999u32.to_le_bytes());
assert!(Exif::parse(&bad).is_none());
}
#[test]
fn excessive_entry_count_rejected() {
let mut bad = b"II".to_vec();
bad.extend_from_slice(&42u16.to_le_bytes());
bad.extend_from_slice(&8u32.to_le_bytes());
bad.extend_from_slice(&60000u16.to_le_bytes()); assert!(Exif::parse(&bad).is_none());
}
fn le_ifd0(entries: &[[u8; 12]], next: u32, tail: &[u8]) -> alloc::vec::Vec<u8> {
let mut v = vec![b'I', b'I', 0x2A, 0x00];
v.extend_from_slice(&8u32.to_le_bytes());
v.extend_from_slice(&(entries.len() as u16).to_le_bytes());
for e in entries {
v.extend_from_slice(e);
}
v.extend_from_slice(&next.to_le_bytes());
v.extend_from_slice(tail);
v
}
fn entry_inline(tag: u16, kind: u16, count: u32, val: [u8; 4]) -> [u8; 12] {
let mut e = [0u8; 12];
e[0..2].copy_from_slice(&tag.to_le_bytes());
e[2..4].copy_from_slice(&kind.to_le_bytes());
e[4..8].copy_from_slice(&count.to_le_bytes());
e[8..12].copy_from_slice(&val);
e
}
fn entry_offset(tag: u16, kind: u16, count: u32, offset: u32) -> [u8; 12] {
entry_inline(tag, kind, count, offset.to_le_bytes())
}
#[test]
fn orientation_as_rational_is_rejected() {
let e = entry_offset(TAG_ORIENTATION, 5, 1, 26); let blob = le_ifd0(&[e], 0, &[1, 0, 0, 0, 1, 0, 0, 0]);
let x = Exif::parse(&blob).expect("parses");
assert_eq!(x.orientation(), None);
}
#[test]
fn ascii_no_nul_terminator_uses_whole_value() {
let e = entry_inline(TAG_COPYRIGHT, TIFF_ASCII, 4, *b"abcd"); let blob = le_ifd0(&[e], 0, &[]);
assert_eq!(Exif::parse(&blob).unwrap().copyright().unwrap(), "abcd");
}
#[test]
fn ascii_embedded_nul_truncates() {
let e = entry_offset(TAG_COPYRIGHT, TIFF_ASCII, 6, 26);
let blob = le_ifd0(&[e], 0, b"ab\0cd\0");
assert_eq!(Exif::parse(&blob).unwrap().copyright().unwrap(), "ab");
}
#[test]
fn ascii_leading_nul_is_none() {
let e = entry_inline(TAG_COPYRIGHT, TIFF_ASCII, 1, [0, 0, 0, 0]);
let blob = le_ifd0(&[e], 0, &[]);
assert!(Exif::parse(&blob).unwrap().copyright().is_none());
}
#[test]
fn latin1_copyright_decodes_lossy() {
let e = entry_offset(TAG_COPYRIGHT, TIFF_ASCII, 5, 26);
let blob = le_ifd0(&[e], 0, b"\xA9 Me\0");
assert_eq!(
Exif::parse(&blob).unwrap().copyright().unwrap(),
"\u{FFFD} Me"
);
}
#[test]
fn utf8_copyright_decodes_borrowed() {
let e = entry_offset(TAG_COPYRIGHT, TIFF_ASCII, 6, 26);
let blob = le_ifd0(&[e], 0, b"\xC2\xA9 Me\0");
let x = Exif::parse(&blob).unwrap();
assert_eq!(x.copyright().unwrap(), "© Me");
assert!(matches!(x.copyright().unwrap(), Cow::Borrowed(_)));
}
#[test]
fn copyright_wrong_type_is_ignored() {
let e = entry_inline(TAG_COPYRIGHT, TIFF_SHORT, 1, [6, 0, 0, 0]);
let blob = le_ifd0(&[e], 0, &[]);
assert!(Exif::parse(&blob).unwrap().copyright().is_none());
}
#[test]
fn count_type_size_overflow_entry_skipped_others_survive() {
let ori = entry_inline(TAG_ORIENTATION, TIFF_SHORT, 1, [6, 0, 0, 0]);
let bad = entry_offset(0x0111, 5, 0x8000_0000, 100); let blob = le_ifd0(&[ori, bad], 0, &[]);
let x = Exif::parse(&blob).expect("salvages the good entry");
assert_eq!(x.orientation(), Some(Orientation::Rotate90));
}
#[test]
fn unknown_tiff_type_entry_skipped() {
let ori = entry_inline(TAG_ORIENTATION, TIFF_SHORT, 1, [6, 0, 0, 0]);
let weird = entry_inline(0x9999, 99, 1, [1, 2, 3, 4]);
let blob = le_ifd0(&[ori, weird], 0, &[]);
let x = Exif::parse(&blob).expect("salvages the good entry");
assert_eq!(x.orientation(), Some(Orientation::Rotate90));
}
#[test]
fn truncated_entry_table_salvages_prior_entries() {
let mut v = vec![b'I', b'I', 0x2A, 0x00];
v.extend_from_slice(&8u32.to_le_bytes());
v.extend_from_slice(&2u16.to_le_bytes()); v.extend_from_slice(&entry_inline(TAG_ORIENTATION, TIFF_SHORT, 1, [6, 0, 0, 0]));
v.extend_from_slice(&[0xAA, 0xBB]); let x = Exif::parse(&v).expect("salvages the readable entry");
assert_eq!(x.orientation(), Some(Orientation::Rotate90));
}
#[test]
fn unparseable_exif_under_stripping_policy_drops_fail_safe() {
assert!(
retain(
b"not a valid tiff blob",
&ExifPolicy::ATTRIBUTED_ORIENTATION
)
.is_none()
);
let garbage = b"not a valid tiff blob";
assert!(matches!(
retain(garbage, &ExifPolicy::KEEP_ALL),
Some(Cow::Borrowed(_))
));
}
#[test]
fn exif_pointer_to_invalid_offset_is_swallowed() {
let ori = entry_inline(TAG_ORIENTATION, TIFF_SHORT, 1, [6, 0, 0, 0]);
let ptr = entry_inline(TAG_EXIF_IFD, TIFF_LONG, 1, 0xFFFFu32.to_le_bytes());
let blob = le_ifd0(&[ori, ptr], 0, &[]);
let x = Exif::parse(&blob).expect("parses");
assert_eq!(x.orientation(), Some(Orientation::Rotate90));
assert!(!x.has_gps());
}
#[test]
fn exif_pointer_cycle_to_ifd0_terminates() {
let ori = entry_inline(TAG_ORIENTATION, TIFF_SHORT, 1, [6, 0, 0, 0]);
let ptr = entry_inline(TAG_EXIF_IFD, TIFF_LONG, 1, 8u32.to_le_bytes());
let blob = le_ifd0(&[ori, ptr], 0, &[]);
let x = Exif::parse(&blob).expect("parses + terminates");
assert_eq!(x.orientation(), Some(Orientation::Rotate90));
}
#[test]
fn huge_thumbnail_1mb_round_trips_and_borrows() {
let big = vec![0xABu8; 1 << 20]; let exif = Exif {
order: ByteOrder::Little,
had_prefix: false,
ifd0: vec![e(TAG_ORIENTATION, TIFF_SHORT, 1, &[6, 0])],
exif_ifd: None,
gps_ifd: None,
ifd1: Some(vec![]),
thumbnail: Some(&big),
text_encoding: TextEncoding::Ascii,
};
let bytes = exif.to_bytes();
let y = Exif::parse(&bytes).expect("parses");
assert!(y.has_thumbnail());
assert_eq!(y.thumbnail.unwrap().len(), 1 << 20);
assert_eq!(y.thumbnail.unwrap(), &big[..]);
assert!(matches!(
retain(&bytes, &ExifPolicy::KEEP_ALL),
Some(Cow::Borrowed(_))
));
}
#[test]
fn thumbnail_length_as_short_is_recognized() {
let mut v = vec![b'I', b'I', 0x2A, 0x00];
v.extend_from_slice(&8u32.to_le_bytes());
v.extend_from_slice(&1u16.to_le_bytes());
v.extend_from_slice(&entry_inline(TAG_ORIENTATION, TIFF_SHORT, 1, [6, 0, 0, 0]));
v.extend_from_slice(&26u32.to_le_bytes());
v.extend_from_slice(&2u16.to_le_bytes());
v.extend_from_slice(&entry_inline(
TAG_THUMB_OFFSET,
TIFF_LONG,
1,
56u32.to_le_bytes(),
));
v.extend_from_slice(&entry_inline(TAG_THUMB_LENGTH, TIFF_SHORT, 1, [4, 0, 0, 0])); v.extend_from_slice(&0u32.to_le_bytes());
v.extend_from_slice(&[0xFF, 0xD8, 0xFF, 0xD9]);
let x = Exif::parse(&v).expect("parses");
assert!(
x.has_thumbnail(),
"SHORT-length thumbnail must be recognized"
);
assert_eq!(x.thumbnail.unwrap(), &[0xFF, 0xD8, 0xFF, 0xD9]);
assert_eq!(x.orientation(), Some(Orientation::Rotate90));
}
#[test]
fn aliased_out_of_line_values_do_not_amplify() {
let n = 40u32;
let blob_len = 100u32;
let tail_off = 8 + 2 + 12 * n + 4;
let entries: Vec<[u8; 12]> = (0..n)
.map(|i| entry_offset(0x1000 + i as u16, TIFF_ASCII, blob_len, tail_off))
.collect();
let src = le_ifd0(&entries, 0, &vec![0x41u8; blob_len as usize]);
let x = Exif::parse(&src).expect("parses");
let policy = ExifPolicy::KEEP_ALL.with_gps(Retention::Discard);
let out = x.filtered(&policy).to_bytes();
assert!(
out.len() < src.len() + 64,
"amplification: {} vs src {}",
out.len(),
src.len()
);
assert!(Exif::parse(&out).is_some(), "rewritten output re-parses");
}
#[test]
fn overlapping_window_values_rejected_not_amplified() {
let k: u32 = 16;
let win: u32 = 1024; let vstart: u32 = 8 + 2 + 12 * k + 4; let entries: Vec<[u8; 12]> = (0..k)
.map(|i| entry_offset(0xC000 + i as u16, TIFF_BYTE, win, vstart + i))
.collect();
let tail = vec![0xABu8; (k - 1 + win + 16) as usize];
let src = le_ifd0(&entries, 0, &tail);
let x = Exif::parse(&src).expect("malicious-but-parseable blob");
assert_eq!(x.ifd0.len(), k as usize, "all overlapping entries parsed");
assert!(
x.serialized_len() > src.len() * 3,
"expected amplification: serialized_len {} vs src {}",
x.serialized_len(),
src.len()
);
let policy = ExifPolicy::KEEP_ALL.with_gps(Retention::Discard);
assert_eq!(
retain(&src, &policy),
None,
"amplifying blob must fail safe"
);
}
#[test]
fn serialized_len_equals_to_bytes_len() {
for prefix in [false, true] {
let bytes = sample(ByteOrder::Little, prefix);
let x = Exif::parse(&bytes).expect("parses");
assert_eq!(
x.serialized_len(),
x.to_bytes().len(),
"full tree (prefix={prefix})"
);
let pruned = x.filtered(&ExifPolicy::KEEP_ALL.with_gps(Retention::Discard));
assert_eq!(
pruned.serialized_len(),
pruned.to_bytes().len(),
"pruned (prefix={prefix})"
);
}
let be_bytes = sample(ByteOrder::Big, false);
let be = Exif::parse(&be_bytes).expect("parses");
assert_eq!(
be.serialized_len(),
be.to_bytes().len(),
"big-endian full tree"
);
}
#[test]
fn has_camera_and_datetimes_classify_and_filter() {
let bytes = sample(ByteOrder::Little, false);
let x = Exif::parse(&bytes).expect("parses");
assert!(x.has_camera(), "Make is the camera category");
assert!(
x.has_datetimes(),
"DateTimeOriginal is the datetimes category"
);
let stripped = x.filtered(
&ExifPolicy::KEEP_ALL
.with_camera(Retention::Discard)
.with_datetimes(Retention::Discard),
);
assert!(!stripped.has_camera(), "camera category dropped");
assert!(!stripped.has_datetimes(), "datetimes category dropped");
}
#[test]
fn short_subifd_pointer_is_preserved() {
let ori = entry_inline(TAG_ORIENTATION, TIFF_SHORT, 1, [6, 0, 0, 0]);
let bad_ptr = entry_inline(TAG_EXIF_IFD, 1, 1, [1, 0, 0, 0]);
let src = le_ifd0(&[ori, bad_ptr], 0, &[]);
let x = Exif::parse(&src).expect("parses");
assert_eq!(x.orientation(), Some(Orientation::Rotate90));
let out = x.to_bytes();
assert!(out.windows(2).any(|w| w == TAG_EXIF_IFD.to_le_bytes()));
}
#[test]
fn stray_structural_pointer_dropped_on_filtering_rewrite() {
let ori = entry_inline(TAG_ORIENTATION, TIFF_SHORT, 1, [6, 0, 0, 0]);
let bad_gps = entry_inline(TAG_GPS_IFD, TIFF_SHORT, 1, [1, 0, 0, 0]);
let src = le_ifd0(&[ori, bad_gps], 0, &[]);
let x = Exif::parse(&src).expect("parses");
let policy = ExifPolicy::KEEP_ALL.with_gps(Retention::Discard);
let out = x.filtered(&policy).to_bytes();
assert!(
!out.windows(2).any(|w| w == TAG_GPS_IFD.to_le_bytes()),
"stray GPS pointer must be dropped on a filtering rewrite, not left dangling"
);
assert_eq!(
Exif::parse(&out).unwrap().orientation(),
Some(Orientation::Rotate90),
"real metadata still round-trips"
);
}
#[test]
fn retain_reconciled_reconciles_in_one_pass() {
let ori = entry_inline(TAG_ORIENTATION, TIFF_SHORT, 1, [6, 0, 0, 0]); let cr = entry_inline(TAG_COPYRIGHT, TIFF_ASCII, 3, [b'M', b'e', 0, 0]);
let src = le_ifd0(&[ori, cr], 0, &[]);
let policy = ExifPolicy::KEEP_ALL.with_gps(Retention::Discard);
let out = retain_reconciled(&src, &policy, Some(Orientation::Identity)).expect("some");
let x = Exif::parse(&out).unwrap();
assert_eq!(
x.orientation(),
Some(Orientation::Identity),
"tag reconciled"
);
assert_eq!(x.copyright().unwrap(), "Me", "rights preserved");
assert!(matches!(
retain_reconciled(&src, &ExifPolicy::KEEP_ALL, Some(Orientation::Rotate90)),
Some(Cow::Borrowed(_))
));
let fixed =
retain_reconciled(&src, &ExifPolicy::KEEP_ALL, Some(Orientation::Rotate180)).unwrap();
assert_eq!(
Exif::parse(&fixed).unwrap().orientation(),
Some(Orientation::Rotate180)
);
assert!(matches!(
retain_reconciled(&src, &ExifPolicy::KEEP_ALL, None),
Some(Cow::Borrowed(_))
));
}
#[test]
fn non_ascii_copyright_preserved_byte_exact() {
let e = entry_offset(TAG_COPYRIGHT, TIFF_ASCII, 5, 26);
let src = le_ifd0(&[e], 0, b"\xA9 Me\0");
let x = Exif::parse(&src).expect("parses");
assert_eq!(x.copyright_bytes(), Some(&b"\xA9 Me"[..])); assert_eq!(x.copyright().unwrap(), "\u{FFFD} Me");
let policy = ExifPolicy::KEEP_ALL.with_gps(Retention::Discard);
let out = x.filtered(&policy).to_bytes();
let y = Exif::parse(&out).expect("re-parses");
assert_eq!(
y.copyright_bytes(),
Some(&b"\xA9 Me"[..]),
"Latin-1 copyright must round-trip byte-exact, not transcode"
);
}
#[test]
fn to_bytes_is_canonical_fixpoint() {
let va = [0xAAu8; 10];
let vb = [0xBBu8; 10];
let x = Exif {
order: ByteOrder::Little,
had_prefix: false,
ifd0: vec![
e(0x0200, TIFF_ASCII, 10, &va),
e(0x0100, TIFF_ASCII, 10, &vb),
],
exif_ifd: None,
gps_ifd: None,
ifd1: None,
thumbnail: None,
text_encoding: TextEncoding::Ascii,
};
let b1 = x.to_bytes();
let b2 = Exif::parse(&b1).expect("re-parses").to_bytes();
assert_eq!(b1, b2, "to_bytes must be a byte-exact fixpoint (canonical)");
}
#[test]
fn utf8_typed_copyright_parses_and_round_trips() {
let e = entry_offset(TAG_COPYRIGHT, TIFF_UTF8, 6, 26);
let blob = le_ifd0(&[e], 0, b"\xC2\xA9 Me\0");
let x = Exif::parse(&blob).expect("parses UTF-8-typed copyright");
assert_eq!(x.copyright().unwrap(), "© Me");
assert!(matches!(x.copyright().unwrap(), Cow::Borrowed(_))); let out = x.to_bytes();
let y = Exif::parse(&out).expect("re-parses");
assert_eq!(y.copyright().unwrap(), "© Me");
assert_eq!(y.copyright_bytes(), Some(&b"\xC2\xA9 Me"[..]));
assert!(out.windows(2).any(|w| w == TIFF_UTF8.to_le_bytes()));
}
#[test]
fn filtering_is_idempotent() {
let src = sample(ByteOrder::Little, false);
let policy = ExifPolicy::KEEP_ALL.with_gps(Retention::Discard);
let once = match retain(&src, &policy) {
Some(c) => c.into_owned(),
None => return,
};
let twice = retain(&once, &policy).map(|c| c.into_owned());
assert_eq!(Some(once), twice, "filtering must be idempotent");
}
#[test]
fn attribution_tags_kept_device_tags_dropped() {
let exif = Exif {
order: ByteOrder::Little,
had_prefix: false,
ifd0: vec![e(TAG_ORIENTATION, TIFF_SHORT, 1, &[6, 0])],
exif_ifd: Some(vec![
e(TAG_BODY_SERIAL_NUMBER, TIFF_ASCII, 4, b"SN1\0"), e(TAG_PHOTOGRAPHER, TIFF_ASCII, 4, b"Me\0\0"), ]),
gps_ifd: None,
ifd1: None,
thumbnail: None,
text_encoding: TextEncoding::Ascii,
};
let out = exif
.filtered(&ExifPolicy::ATTRIBUTED_ORIENTATION)
.to_bytes();
assert!(
out.windows(2).any(|w| w == TAG_PHOTOGRAPHER.to_le_bytes()),
"Photographer (attribution) must survive a rights policy"
);
assert!(
!out.windows(2)
.any(|w| w == TAG_BODY_SERIAL_NUMBER.to_le_bytes()),
"BodySerialNumber (device identity) must be stripped"
);
}
fn orientation_only() -> alloc::vec::Vec<u8> {
le_ifd0(
&[entry_inline(TAG_ORIENTATION, TIFF_SHORT, 1, [6, 0, 0, 0])],
0,
&[],
)
}
#[test]
fn set_copyright_inserts_ascii_type2() {
let blob = orientation_only();
let mut x = Exif::parse(&blob).unwrap();
assert!(x.copyright().is_none());
x.set_copyright("(c) 2026 Lilith");
let out = x.to_bytes();
let y = Exif::parse(&out).unwrap();
assert_eq!(y.copyright().unwrap(), "(c) 2026 Lilith");
assert_eq!(y.copyright_bytes(), Some(&b"(c) 2026 Lilith"[..]));
assert_eq!(y.orientation(), Some(Orientation::Rotate90)); assert!(out.windows(2).any(|w| w == TIFF_ASCII.to_le_bytes()));
}
#[test]
fn set_copyright_utf8_writes_type129() {
let mut x = Exif::new(TextEncoding::Utf8);
x.set_copyright("© 2026 Lilith");
let out = x.to_bytes();
let y = Exif::parse(&out).unwrap();
assert_eq!(y.copyright().unwrap(), "© 2026 Lilith");
assert!(out.windows(2).any(|w| w == TIFF_UTF8.to_le_bytes()));
}
#[test]
fn set_copyright_replaces_existing() {
let src = sample(ByteOrder::Little, false); let mut x = Exif::parse(&src).unwrap();
assert_eq!(x.copyright().unwrap(), "(c) Me");
x.set_copyright("(c) New Owner");
let out = x.to_bytes();
let y = Exif::parse(&out).unwrap();
assert_eq!(y.copyright().unwrap(), "(c) New Owner");
let n = y.ifd0.iter().filter(|e| e.tag == TAG_COPYRIGHT).count();
assert_eq!(n, 1, "must replace, not duplicate");
}
#[test]
fn set_copyright_ascii_carries_utf8_bytes_defacto() {
let blob = orientation_only();
let mut x = Exif::parse(&blob).unwrap();
x.set_copyright("© Лилит"); let out = x.to_bytes();
let y = Exif::parse(&out).unwrap();
assert_eq!(y.copyright_bytes(), Some("© Лилит".as_bytes()));
assert_eq!(y.copyright().unwrap(), "© Лилит"); assert!(out.windows(2).any(|w| w == TIFF_ASCII.to_le_bytes()));
}
#[test]
fn set_artist_round_trips_and_is_rights() {
let blob = orientation_only();
let mut x = Exif::parse(&blob).unwrap();
x.set_artist("Lilith");
let out = x.to_bytes();
let y = Exif::parse(&out).unwrap();
assert_eq!(y.artist().unwrap(), "Lilith");
let kept = y.filtered(&ExifPolicy::ATTRIBUTED_ORIENTATION).to_bytes();
assert!(Exif::parse(&kept).unwrap().artist().is_some());
let dropped = y
.filtered(&ExifPolicy::KEEP_ALL.with_rights(Retention::Discard))
.to_bytes();
assert!(Exif::parse(&dropped).unwrap().artist().is_none());
}
#[test]
fn edited_copyright_survives_filter_rewrite() {
let src = sample(ByteOrder::Little, false);
let mut x = Exif::parse(&src).unwrap();
let long = "Copyright 2026 Lilith River — all rights reserved worldwide.";
x.set_copyright(long); let out = x
.filtered(&ExifPolicy::KEEP_ALL.with_gps(Retention::Discard))
.to_bytes();
let y = Exif::parse(&out).unwrap();
assert_eq!(y.copyright().unwrap(), long);
assert!(!y.has_gps());
assert_eq!(y.orientation(), Some(Orientation::Rotate90));
}
#[test]
fn edited_to_bytes_is_canonical_fixpoint() {
let blob = orientation_only();
let mut x = Exif::parse(&blob).unwrap();
x.set_copyright("(c) Me");
let b1 = x.to_bytes();
let b2 = Exif::parse(&b1).unwrap().to_bytes();
assert_eq!(b1, b2, "edited output must be a canonical fixpoint");
}
#[test]
fn makernote_dropped_when_gps_stripped_even_if_camera_kept() {
let exif = Exif {
order: ByteOrder::Little,
had_prefix: false,
ifd0: vec![
e(TAG_ORIENTATION, TIFF_SHORT, 1, &[6, 0]),
e(TAG_MAKER_NOTE, TIFF_UNDEFINED, 8, b"MAKER\0\0\0"),
],
exif_ifd: None,
gps_ifd: None,
ifd1: None,
thumbnail: None,
text_encoding: TextEncoding::Ascii,
};
let stripped = exif
.filtered(&ExifPolicy::KEEP_ALL.with_gps(Retention::Discard))
.to_bytes();
assert!(
!stripped
.windows(2)
.any(|w| w == TAG_MAKER_NOTE.to_le_bytes()),
"MakerNote must be stripped when GPS is dropped"
);
let pruned = exif
.filtered(&ExifPolicy::KEEP_ALL.with_other(Retention::Discard))
.to_bytes();
assert!(
!pruned.windows(2).any(|w| w == TAG_MAKER_NOTE.to_le_bytes()),
"MakerNote dropped on a pruning rewrite (can't safely relocate)"
);
let blob = exif.to_bytes();
let kept = retain(&blob, &ExifPolicy::KEEP_ALL).expect("keep-all");
assert!(
matches!(kept, Cow::Borrowed(_))
&& kept.windows(2).any(|w| w == TAG_MAKER_NOTE.to_le_bytes()),
"MakerNote preserved byte-exact under keep-everything (no rewrite)"
);
}
#[test]
fn subifds_pointer_dropped_on_rewrite() {
let exif = Exif {
order: ByteOrder::Little,
had_prefix: false,
ifd0: vec![
e(TAG_ORIENTATION, TIFF_SHORT, 1, &[6, 0]),
e(TAG_SUBIFDS, TIFF_LONG, 1, &[0x40, 0, 0, 0]),
],
exif_ifd: None,
gps_ifd: None,
ifd1: None,
thumbnail: None,
text_encoding: TextEncoding::Ascii,
};
let out = exif
.filtered(&ExifPolicy::KEEP_ALL.with_gps(Retention::Discard))
.to_bytes();
assert!(
!out.windows(2).any(|w| w == TAG_SUBIFDS.to_le_bytes()),
"SubIFDs pointer must be dropped on rewrite (would dangle)"
);
assert_eq!(
Exif::parse(&out).unwrap().orientation(),
Some(Orientation::Rotate90)
);
}
#[test]
fn ifd1_entries_filtered_by_category() {
let exif = Exif {
order: ByteOrder::Little,
had_prefix: false,
ifd0: vec![e(TAG_ORIENTATION, TIFF_SHORT, 1, &[6, 0])],
exif_ifd: None,
gps_ifd: None,
ifd1: Some(vec![e(TAG_MAKE, TIFF_ASCII, 4, b"Cam\0")]), thumbnail: Some(&[0xFF, 0xD8, 0xFF, 0xD9]),
text_encoding: TextEncoding::Ascii,
};
let out = exif
.filtered(&ExifPolicy::KEEP_ALL.with_camera(Retention::Discard))
.to_bytes();
let y = Exif::parse(&out).unwrap();
assert!(y.has_thumbnail(), "thumbnail image must be kept");
assert!(
!out.windows(2).any(|w| w == TAG_MAKE.to_le_bytes()),
"IFD1 camera tag (Make) must be stripped"
);
}
#[test]
fn new_from_scratch_copyright_round_trips() {
let mut exif = Exif::new(TextEncoding::Ascii);
assert!(exif.copyright().is_none());
exif.set_copyright("(c) 2026 Lilith");
let blob = exif.to_bytes();
let y = Exif::parse(&blob).expect("fresh blob parses");
assert_eq!(y.copyright().unwrap(), "(c) 2026 Lilith");
assert!(!y.has_gps() && !y.has_thumbnail());
let kept = y.filtered(&ExifPolicy::ATTRIBUTED_ORIENTATION).to_bytes();
assert_eq!(
Exif::parse(&kept).unwrap().copyright().unwrap(),
"(c) 2026 Lilith"
);
}
#[test]
fn new_default_empty_round_trips() {
let blob = Exif::default().to_bytes();
let y = Exif::parse(&blob).expect("empty blob parses");
assert!(y.copyright().is_none() && !y.has_gps() && !y.has_thumbnail());
}
#[test]
fn set_orientation_adds_entry_from_scratch() {
let mut exif = Exif::new(TextEncoding::Ascii);
assert!(exif.orientation().is_none());
exif.set_copyright("(c) Me");
exif.set_orientation(Orientation::Rotate90);
let blob = exif.to_bytes();
let y = Exif::parse(&blob).expect("fresh blob parses");
assert_eq!(y.orientation(), Some(Orientation::Rotate90));
assert_eq!(y.copyright().unwrap(), "(c) Me");
let en = y.ifd0.iter().find(|e| e.tag == TAG_ORIENTATION).unwrap();
assert_eq!((en.kind, en.count), (TIFF_SHORT, 1));
assert_eq!(blob, y.to_bytes());
}
#[test]
fn set_orientation_replaces_existing_preserving_kind() {
let bytes = sample(ByteOrder::Big, false); let mut x = Exif::parse(&bytes).unwrap();
x.set_orientation(Orientation::Identity);
let out = x.to_bytes();
let y = Exif::parse(&out).unwrap();
assert_eq!(y.orientation(), Some(Orientation::Identity));
let en = y.ifd0.iter().find(|e| e.tag == TAG_ORIENTATION).unwrap();
assert_eq!((en.kind, en.count), (TIFF_SHORT, 1));
let mut nb = y.filtered(&ExifPolicy::KEEP_ALL.with_orientation(Retention::Discard));
assert!(nb.orientation().is_none());
nb.set_orientation(Orientation::Rotate180);
let re = nb.to_bytes();
assert_eq!(
Exif::parse(&re).unwrap().orientation(),
Some(Orientation::Rotate180)
);
let mut long = Exif {
order: ByteOrder::Little,
had_prefix: false,
ifd0: vec![e(TAG_ORIENTATION, TIFF_LONG, 1, &[3, 0, 0, 0])],
exif_ifd: None,
gps_ifd: None,
ifd1: None,
thumbnail: None,
text_encoding: TextEncoding::Ascii,
};
long.set_orientation(Orientation::Rotate180);
let lb = long.to_bytes();
let z = Exif::parse(&lb).unwrap();
assert_eq!(z.orientation(), Some(Orientation::Rotate180));
let en = z.ifd0.iter().find(|e| e.tag == TAG_ORIENTATION).unwrap();
assert_eq!((en.kind, en.count), (TIFF_LONG, 1));
}
#[test]
fn set_orientation_replaces_non_integer_carrier() {
let mut exif = Exif {
order: ByteOrder::Little,
had_prefix: false,
ifd0: vec![e(TAG_ORIENTATION, TIFF_ASCII, 2, b"6\0")],
exif_ifd: None,
gps_ifd: None,
ifd1: None,
thumbnail: None,
text_encoding: TextEncoding::Ascii,
};
assert!(exif.orientation().is_none(), "ASCII carrier is unreadable");
exif.set_orientation(Orientation::Rotate270);
let blob = exif.to_bytes();
let y = Exif::parse(&blob).unwrap();
assert_eq!(y.orientation(), Some(Orientation::Rotate270));
let en = y.ifd0.iter().find(|e| e.tag == TAG_ORIENTATION).unwrap();
assert_eq!((en.kind, en.count), (TIFF_SHORT, 1));
}
#[test]
fn reconcile_orientation_canonicalizes_count_97() {
let mut x = Exif::new(TextEncoding::Ascii);
x.ifd0.push(e(TAG_ORIENTATION, TIFF_SHORT, 20, &[0, 1]));
x.set_orientation_tag(Orientation::Rotate90);
let en = x.ifd0.iter().find(|e| e.tag == TAG_ORIENTATION).unwrap();
assert_eq!(en.count, 1, "reconcile must canonicalize orientation count");
let b1 = x.to_bytes();
let y = Exif::parse(&b1).expect("must re-parse");
assert_eq!(y.orientation(), Some(Orientation::Rotate90));
assert_eq!(b1, y.to_bytes(), "must be a serializer fixpoint");
}
#[test]
fn duplicate_gps_pointer_stripped_on_parse_30() {
let mut t = vec![b'M', b'M', 0, 0x2a, 0, 0, 0, 8];
t.extend_from_slice(&[0, 2]); t.extend_from_slice(&[0x88, 0x25, 0, 4, 0, 0, 0, 1, 0, 0, 0, 0x26]); t.extend_from_slice(&[0x88, 0x25, 0, 4, 0, 0, 0, 1, 0, 0, 0, 0x2c]); t.extend_from_slice(&[0, 0, 0, 0]); t.extend_from_slice(&[0, 0, 0, 0, 0, 0]); t.extend_from_slice(&[0, 0, 0, 0, 0, 0]); let x = Exif::parse(&t).expect("fixture must parse");
assert!(x.has_gps(), "first GPS pointer should be extracted");
assert!(
!x.ifd0.iter().any(|e| e.tag == TAG_GPS_IFD),
"duplicate GPS structural tag leaked into ifd0"
);
let b1 = x.to_bytes();
let y = Exif::parse(&b1).expect("must re-parse");
assert_eq!(x.has_gps(), y.has_gps(), "gps presence must round-trip");
assert_eq!(b1, y.to_bytes(), "must be a serializer fixpoint");
}
#[test]
fn dangling_thumbnail_pointer_does_not_survive_96() {
let mut t = vec![b'M', b'M', 0, 0x2a, 0, 0, 0, 8];
t.extend_from_slice(&[0, 1]); t.extend_from_slice(&[0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, 1, 0, 0]); t.extend_from_slice(&[0, 0, 0, 0x1a]); t.extend_from_slice(&[0, 2]); t.extend_from_slice(&[0x02, 0x01, 0, 4, 0, 0, 0, 1, 0, 0, 0xFF, 0x00]); t.extend_from_slice(&[0x02, 0x02, 0, 4, 0, 0, 0, 1, 0, 0, 0, 0x10]); t.extend_from_slice(&[0, 0, 0, 0]); let x = Exif::parse(&t).expect("fixture must parse");
assert!(!x.has_thumbnail(), "OOB thumbnail must not be captured");
if let Some(ifd1) = &x.ifd1 {
assert!(
!ifd1
.iter()
.any(|e| e.tag == TAG_THUMB_OFFSET || e.tag == TAG_THUMB_LENGTH),
"dangling thumbnail tags leaked into ifd1 as data"
);
}
let b1 = x.to_bytes();
let y = Exif::parse(&b1).expect("must re-parse");
assert!(
!y.has_thumbnail(),
"dangling thumbnail pointer resurrected after round-trip"
);
assert_eq!(b1, y.to_bytes(), "must be a serializer fixpoint");
}
}