use crate::bytes::{Reader, u32_to_usize};
use crate::detect::Format;
use crate::error::{MalformedDetail, ResourceLimit, Result, StryptError};
use crate::formats::exif::{Endian, Ifd};
use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, exif};
pub(crate) mod tags;
use crate::report::{Finding, InspectOptions, MetadataKind, MetadataReport, Note, StripReport};
#[derive(Debug, Clone, Copy, Default)]
#[non_exhaustive]
pub struct TiffHandler;
impl MetadataHandler for TiffHandler {
fn name(&self) -> &'static str {
Format::Tiff.id()
}
fn format(&self) -> Format {
Format::Tiff
}
fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
let processed = process(input, options, &ParseLimits::default())?;
Ok(MetadataReport {
format: Format::Tiff,
findings: processed.findings,
notes: processed.notes,
})
}
fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
let processed = process(input, &options.inspect, &options.limits)?;
Ok(Stripped {
report: StripReport {
format: Format::Tiff,
removed: processed.findings,
retained: Vec::new(),
notes: processed.notes,
input_bytes: as_u64(input.len()),
output_bytes: as_u64(processed.output.len()),
},
bytes: processed.output,
})
}
}
struct Processed {
output: Vec<u8>,
findings: Vec<Finding>,
notes: Vec<Note>,
}
fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
let (endian, first) = header(input)?;
let mut walk = Walk {
input,
endian,
options,
budget: limits.max_items,
visited: Vec::new(),
findings: Vec::new(),
notes: Vec::new(),
};
let mut pages: Vec<Page<'_>> = Vec::new();
let mut next = first;
while next != 0 {
let start =
u32_to_usize(next).ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
if walk.visited.contains(&start) {
return Err(malformed(MalformedDetail::CyclicReference));
}
walk.visited.push(start);
let (page, following) = walk.directory(start)?;
if let Some(page) = page {
pages.push(page);
}
next = following;
}
if pages.is_empty() {
return Err(malformed(MalformedDetail::MissingMarker));
}
let output = write(endian, &pages)?;
Ok(Processed {
output,
findings: walk.findings,
notes: walk.notes,
})
}
fn header(input: &[u8]) -> Result<(Endian, u32)> {
let mut r = Reader::new(input);
let endian = match r.take(2) {
Some(b"II") => Endian::Little,
Some(b"MM") => Endian::Big,
_ => return Err(malformed(MalformedDetail::MissingMarker)),
};
match endian.u16(&mut r) {
Some(42) => {}
Some(43) => return Err(malformed(MalformedDetail::UnsupportedFeature)),
_ => return Err(malformed(MalformedDetail::MissingMarker)),
}
let first = endian
.u32(&mut r)
.ok_or_else(|| malformed(MalformedDetail::Truncated))?;
Ok((endian, first))
}
struct Page<'a> {
verbatim: Vec<Verbatim<'a>>,
data: Vec<&'a [u8]>,
tiled: bool,
}
struct Verbatim<'a> {
tag: u16,
field_type: u16,
count: u32,
value: &'a [u8],
}
struct Walk<'a, 'o> {
input: &'a [u8],
endian: Endian,
options: &'o InspectOptions,
budget: u32,
visited: Vec<usize>,
findings: Vec<Finding>,
notes: Vec<Note>,
}
struct RawEntry<'a> {
tag: u16,
field_type: u16,
count: u32,
inline: &'a [u8],
}
impl<'a> Walk<'a, '_> {
fn directory(&mut self, start: usize) -> Result<(Option<Page<'a>>, u32)> {
let mut r = Reader::new(self.input);
r.seek(start)
.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
let count = self
.endian
.u16(&mut r)
.ok_or_else(|| malformed(MalformedDetail::Truncated))?;
let mut raw: Vec<RawEntry<'a>> = Vec::new();
for _ in 0..count {
if self.budget == 0 {
return Err(StryptError::LimitExceeded {
format: Format::Tiff,
limit: ResourceLimit::ItemCount,
});
}
self.budget = self.budget.saturating_sub(1);
raw.push(self.raw_entry(&mut r)?);
}
let following = self
.endian
.u32(&mut r)
.ok_or_else(|| malformed(MalformedDetail::Truncated))?;
if self.is_reduced_resolution(&raw) {
self.findings.push(
Finding::new(MetadataKind::Thumbnail, "TIFF reduced-resolution IFD", 0)
.with_field("reduced-resolution image"),
);
return Ok((None, following));
}
let page = self.page(&raw)?;
Ok((Some(page), following))
}
fn raw_entry(&mut self, r: &mut Reader<'a>) -> Result<RawEntry<'a>> {
let truncated = || malformed(MalformedDetail::Truncated);
let tag = self.endian.u16(r).ok_or_else(truncated)?;
let field_type = self.endian.u16(r).ok_or_else(truncated)?;
let count = self.endian.u32(r).ok_or_else(truncated)?;
let inline = r.take(4).ok_or_else(truncated)?;
Ok(RawEntry {
tag,
field_type,
count,
inline,
})
}
fn is_reduced_resolution(&self, raw: &[RawEntry<'a>]) -> bool {
raw.iter()
.find(|e| e.tag == tags::NEW_SUBFILE_TYPE)
.and_then(|e| self.value_of(e))
.and_then(|v| integer(self.endian, v, tags::LONG))
.is_some_and(|v| v & 1 == 1)
}
fn value_of(&self, entry: &RawEntry<'a>) -> Option<&'a [u8]> {
let size = exif::type_size(entry.field_type)?;
let length = u64::from(entry.count).checked_mul(u64::from(size))?;
let length = usize::try_from(length).ok()?;
if length <= 4 {
return entry.inline.get(0..length);
}
let mut w = Reader::new(entry.inline);
let at = u32_to_usize(self.endian.u32(&mut w)?)?;
let mut r = Reader::new(self.input);
r.seek(at)?;
r.take(length)
}
fn page(&mut self, raw: &[RawEntry<'a>]) -> Result<Page<'a>> {
let tiled = raw.iter().any(|e| e.tag == tags::TILE_OFFSETS);
let mut verbatim: Vec<Verbatim<'a>> = Vec::new();
let mut offsets: Option<Vec<u64>> = None;
let mut counts: Option<Vec<u64>> = None;
for entry in raw {
let value = self.value_of(entry);
let (offsets_tag, counts_tag) = geometry_tags(tiled);
if entry.tag == offsets_tag {
offsets = Some(self.integers(entry, value)?);
continue;
}
if entry.tag == counts_tag {
counts = Some(self.integers(entry, value)?);
continue;
}
if matches!(
entry.tag,
tags::STRIP_OFFSETS
| tags::STRIP_BYTE_COUNTS
| tags::TILE_OFFSETS
| tags::TILE_BYTE_COUNTS
) {
continue;
}
if tags::is_structural(entry.tag) {
if verbatim.iter().any(|v| v.tag == entry.tag) {
continue;
}
let value = value.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
verbatim.push(Verbatim {
tag: entry.tag,
field_type: entry.field_type,
count: entry.count,
value,
});
continue;
}
if let Some(sub) = exif::sub_directory(Ifd::Primary, entry.tag) {
self.report_sub_directory(entry, value, sub);
continue;
}
self.report_removed(Ifd::Primary, entry, value);
}
let offsets = offsets.ok_or_else(|| malformed(MalformedDetail::MissingMarker))?;
let counts = counts.ok_or_else(|| malformed(MalformedDetail::MissingMarker))?;
let data = self.slice_data(&offsets, &counts)?;
for required in [tags::IMAGE_WIDTH, tags::IMAGE_LENGTH] {
if !verbatim.iter().any(|v| v.tag == required) {
return Err(malformed(MalformedDetail::MissingMarker));
}
}
Ok(Page {
verbatim,
data,
tiled,
})
}
fn integers(&self, entry: &RawEntry<'a>, value: Option<&'a [u8]>) -> Result<Vec<u64>> {
let value = value.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
let width = match entry.field_type {
tags::SHORT => 2usize,
tags::LONG => 4usize,
_ => return Err(malformed(MalformedDetail::UnsupportedFeature)),
};
let mut out = Vec::new();
let mut r = Reader::new(value);
while r.remaining() >= width {
let item = if width == 2 {
self.endian.u16(&mut r).map(u64::from)
} else {
self.endian.u32(&mut r).map(u64::from)
};
out.push(item.ok_or_else(|| malformed(MalformedDetail::Truncated))?);
}
Ok(out)
}
fn slice_data(&self, offsets: &[u64], counts: &[u64]) -> Result<Vec<&'a [u8]>> {
if offsets.is_empty() || offsets.len() != counts.len() {
return Err(malformed(MalformedDetail::BrokenIndex));
}
let mut data = Vec::new();
for (offset, count) in offsets.iter().zip(counts.iter()) {
let start = usize::try_from(*offset)
.map_err(|_| malformed(MalformedDetail::LengthOutOfRange))?;
let len = usize::try_from(*count)
.map_err(|_| malformed(MalformedDetail::LengthOutOfRange))?;
let mut r = Reader::new(self.input);
r.seek(start)
.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
let slice = r
.take(len)
.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
data.push(slice);
}
Ok(data)
}
fn report_sub_directory(&mut self, entry: &RawEntry<'a>, value: Option<&'a [u8]>, sub: Ifd) {
let Some(target) = value
.or(Some(entry.inline))
.and_then(|v| {
let mut r = Reader::new(v);
self.endian.u32(&mut r)
})
.and_then(u32_to_usize)
else {
return;
};
if self.visited.contains(&target) {
return;
}
self.visited.push(target);
let mut r = Reader::new(self.input);
if r.seek(target).is_none() {
return;
}
let Some(count) = self.endian.u16(&mut r) else {
return;
};
for _ in 0..count {
if self.budget == 0 {
self.notes.push(Note::UnparsedRegion {
location: format!("TIFF {}", label(sub)),
bytes: as_u64(r.remaining()),
});
return;
}
self.budget = self.budget.saturating_sub(1);
let Ok(sub_entry) = self.raw_entry(&mut r) else {
return;
};
let sub_value = self.value_of(&sub_entry);
if let Some(deeper) = exif::sub_directory(sub, sub_entry.tag) {
self.report_sub_directory(&sub_entry, sub_value, deeper);
continue;
}
self.report_removed(sub, &sub_entry, sub_value);
}
}
fn report_removed(&mut self, ifd: Ifd, entry: &RawEntry<'a>, value: Option<&'a [u8]>) {
let (name, kind) = exif::describe(ifd, entry.tag);
let bytes = value.map_or(0, |v| as_u64(v.len()));
let field_type = entry.field_type;
self.findings.push(
Finding::new(kind, format!("TIFF {}", label(ifd)), bytes)
.with_field(name)
.with_value(self.options, || exif::render(value, field_type)),
);
}
}
const fn label(ifd: Ifd) -> &'static str {
match ifd {
Ifd::Primary => "IFD0",
Ifd::Exif => "Exif IFD",
Ifd::Gps => "GPS IFD",
Ifd::Interop => "Interop IFD",
Ifd::Thumbnail => "IFD1 (thumbnail)",
}
}
const fn geometry_tags(tiled: bool) -> (u16, u16) {
if tiled {
(tags::TILE_OFFSETS, tags::TILE_BYTE_COUNTS)
} else {
(tags::STRIP_OFFSETS, tags::STRIP_BYTE_COUNTS)
}
}
fn integer(endian: Endian, value: &[u8], field_type: u16) -> Option<u64> {
let mut r = Reader::new(value);
match field_type {
tags::SHORT => endian.u16(&mut r).map(u64::from),
_ => endian.u32(&mut r).map(u64::from),
}
}
struct OutEntry {
tag: u16,
field_type: u16,
count: u32,
len: usize,
source: Source,
}
enum Source {
Verbatim(usize),
DataOffsets,
DataCounts,
}
struct Placed {
entries: Vec<OutEntry>,
ifd_at: u32,
value_at: Vec<u32>,
data_at: Vec<u32>,
}
fn write(endian: Endian, pages: &[Page<'_>]) -> Result<Vec<u8>> {
let mut cursor: u64 = 8;
let mut placed: Vec<Placed> = Vec::new();
for page in pages {
let entries = out_entries(page);
let ifd_at = to_u32(cursor)?;
let ifd_size = u64::try_from(entries.len())
.ok()
.and_then(|n| n.checked_mul(12))
.and_then(|n| n.checked_add(6))
.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
cursor = advance(cursor, ifd_size)?;
let mut value_at = Vec::with_capacity(entries.len());
for entry in &entries {
if entry.len <= 4 {
value_at.push(0);
continue;
}
value_at.push(to_u32(cursor)?);
cursor = advance(cursor, pad_even(width_of(entry.len)?))?;
}
let mut data_at = Vec::with_capacity(page.data.len());
for blob in &page.data {
data_at.push(to_u32(cursor)?);
cursor = advance(cursor, pad_even(width_of(blob.len())?))?;
}
placed.push(Placed {
entries,
ifd_at,
value_at,
data_at,
});
}
let mut out: Vec<u8> = Vec::new();
out.extend_from_slice(match endian {
Endian::Little => b"II",
Endian::Big => b"MM",
});
put_u16(&mut out, endian, 42);
put_u32(
&mut out,
endian,
placed.first().map_or(0, |first| first.ifd_at),
);
for (index, page) in pages.iter().enumerate() {
let Some(spot) = placed.get(index) else {
return Err(malformed(MalformedDetail::NotRoundTrippable));
};
let next = placed.get(index.saturating_add(1)).map_or(0, |p| p.ifd_at);
write_page(&mut out, endian, page, spot, next)?;
}
Ok(out)
}
fn out_entries(page: &Page<'_>) -> Vec<OutEntry> {
let mut entries: Vec<OutEntry> = page
.verbatim
.iter()
.enumerate()
.map(|(index, v)| OutEntry {
tag: v.tag,
field_type: v.field_type,
count: v.count,
len: v.value.len(),
source: Source::Verbatim(index),
})
.collect();
let (offsets_tag, counts_tag) = geometry_tags(page.tiled);
let n = page.data.len();
let n32 = u32::try_from(n).unwrap_or(u32::MAX);
entries.push(OutEntry {
tag: offsets_tag,
field_type: tags::LONG,
count: n32,
len: n.saturating_mul(4),
source: Source::DataOffsets,
});
entries.push(OutEntry {
tag: counts_tag,
field_type: tags::LONG,
count: n32,
len: n.saturating_mul(4),
source: Source::DataCounts,
});
entries.sort_by_key(|e| e.tag);
entries
}
fn write_page(
out: &mut Vec<u8>,
endian: Endian,
page: &Page<'_>,
spot: &Placed,
next: u32,
) -> Result<()> {
let entry_count =
u16::try_from(spot.entries.len()).map_err(|_| malformed(MalformedDetail::BrokenIndex))?;
put_u16(out, endian, entry_count);
for (index, entry) in spot.entries.iter().enumerate() {
put_u16(out, endian, entry.tag);
put_u16(out, endian, entry.field_type);
put_u32(out, endian, entry.count);
if entry.len <= 4 {
let mut inline = value_bytes(endian, page, spot, entry)?;
inline.resize(4, 0);
out.extend_from_slice(&inline);
} else {
let at = spot
.value_at
.get(index)
.copied()
.ok_or_else(|| malformed(MalformedDetail::NotRoundTrippable))?;
put_u32(out, endian, at);
}
}
put_u32(out, endian, next);
for entry in &spot.entries {
if entry.len <= 4 {
continue;
}
let bytes = value_bytes(endian, page, spot, entry)?;
out.extend_from_slice(&bytes);
if !bytes.len().is_multiple_of(2) {
out.push(0);
}
}
for blob in &page.data {
out.extend_from_slice(blob);
if !blob.len().is_multiple_of(2) {
out.push(0);
}
}
Ok(())
}
fn value_bytes(
endian: Endian,
page: &Page<'_>,
spot: &Placed,
entry: &OutEntry,
) -> Result<Vec<u8>> {
match entry.source {
Source::Verbatim(index) => page
.verbatim
.get(index)
.map(|v| v.value.to_vec())
.ok_or_else(|| malformed(MalformedDetail::NotRoundTrippable)),
Source::DataOffsets => {
let mut bytes = Vec::new();
for at in &spot.data_at {
put_u32(&mut bytes, endian, *at);
}
Ok(bytes)
}
Source::DataCounts => {
let mut bytes = Vec::new();
for blob in &page.data {
put_u32(&mut bytes, endian, to_u32(width_of(blob.len())?)?);
}
Ok(bytes)
}
}
}
const fn pad_even(len: u64) -> u64 {
if len.is_multiple_of(2) {
len
} else {
len.saturating_add(1)
}
}
fn advance(cursor: u64, by: u64) -> Result<u64> {
cursor
.checked_add(by)
.filter(|next| u32::try_from(*next).is_ok())
.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))
}
fn width_of(len: usize) -> Result<u64> {
u64::try_from(len).map_err(|_| malformed(MalformedDetail::LengthOutOfRange))
}
fn to_u32(value: u64) -> Result<u32> {
u32::try_from(value).map_err(|_| malformed(MalformedDetail::LengthOutOfRange))
}
fn put_u16(out: &mut Vec<u8>, endian: Endian, value: u16) {
match endian {
Endian::Little => out.extend_from_slice(&value.to_le_bytes()),
Endian::Big => out.extend_from_slice(&value.to_be_bytes()),
}
}
fn put_u32(out: &mut Vec<u8>, endian: Endian, value: u32) {
match endian {
Endian::Little => out.extend_from_slice(&value.to_le_bytes()),
Endian::Big => out.extend_from_slice(&value.to_be_bytes()),
}
}
fn malformed(detail: MalformedDetail) -> StryptError {
StryptError::Malformed {
format: Format::Tiff,
offset: None,
detail,
}
}
fn as_u64(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::arithmetic_side_effects,
clippy::struct_field_names,
clippy::too_many_lines
)]
use super::*;
use crate::formats::StripOptions;
struct Tag {
tag: u16,
field_type: u16,
count: u32,
value: Vec<u8>,
}
fn short(tag: u16, value: u16) -> Tag {
Tag {
tag,
field_type: tags::SHORT,
count: 1,
value: value.to_le_bytes().to_vec(),
}
}
fn ascii(tag: u16, text: &str) -> Tag {
let mut value = text.as_bytes().to_vec();
value.push(0);
Tag {
tag,
field_type: 2,
count: u32::try_from(value.len()).expect("short string"),
value,
}
}
fn long(tag: u16, value: u32) -> Tag {
Tag {
tag,
field_type: tags::LONG,
count: 1,
value: value.to_le_bytes().to_vec(),
}
}
fn structural() -> Vec<Tag> {
vec![
short(tags::IMAGE_WIDTH, 4),
short(tags::IMAGE_LENGTH, 1),
short(0x0102, 8), short(0x0103, 1), short(0x0106, 1), short(0x0115, 1), short(0x0116, 1), short(0x011C, 1), ]
}
struct Dir {
tags: Vec<Tag>,
strips: Vec<Vec<u8>>,
}
fn page(tags: Vec<Tag>, strip: &[u8]) -> Dir {
Dir {
tags,
strips: vec![strip.to_vec()],
}
}
struct Slot {
tag: u16,
field_type: u16,
count: u32,
value: Vec<u8>,
geometry: Option<bool>,
}
fn build(dirs: &[Dir]) -> Vec<u8> {
let mut per_dir: Vec<Vec<Slot>> = Vec::new();
for dir in dirs {
let n = u32::try_from(dir.strips.len()).unwrap();
let mut entries: Vec<Slot> = dir
.tags
.iter()
.map(|t| Slot {
tag: t.tag,
field_type: t.field_type,
count: t.count,
value: t.value.clone(),
geometry: None,
})
.collect();
entries.push(Slot {
tag: tags::STRIP_OFFSETS,
field_type: tags::LONG,
count: n,
value: vec![0; 4 * dir.strips.len()],
geometry: Some(true),
});
entries.push(Slot {
tag: tags::STRIP_BYTE_COUNTS,
field_type: tags::LONG,
count: n,
value: vec![0; 4 * dir.strips.len()],
geometry: Some(false),
});
entries.sort_by_key(|e| e.tag);
per_dir.push(entries);
}
let mut cursor = 8usize;
let mut starts: Vec<usize> = Vec::new();
let mut value_offs: Vec<Vec<usize>> = Vec::new();
let mut data_offs: Vec<Vec<usize>> = Vec::new();
for (index, entries) in per_dir.iter().enumerate() {
starts.push(cursor);
cursor += 2 + 12 * entries.len() + 4;
let mut offs = Vec::new();
for entry in entries {
if entry.value.len() > 4 {
offs.push(cursor);
cursor += entry.value.len() + entry.value.len() % 2;
} else {
offs.push(0);
}
}
value_offs.push(offs);
let mut data = Vec::new();
for strip in &dirs[index].strips {
data.push(cursor);
cursor += strip.len() + strip.len() % 2;
}
data_offs.push(data);
}
for (index, entries) in per_dir.iter_mut().enumerate() {
for entry in entries.iter_mut() {
match entry.geometry {
Some(true) => {
entry.value = data_offs[index]
.iter()
.flat_map(|at| u32::try_from(*at).unwrap().to_le_bytes())
.collect();
}
Some(false) => {
entry.value = dirs[index]
.strips
.iter()
.flat_map(|s| u32::try_from(s.len()).unwrap().to_le_bytes())
.collect();
}
None => {}
}
}
}
let mut out: Vec<u8> = vec![b'I', b'I', 0x2A, 0x00];
out.extend_from_slice(&u32::try_from(starts[0]).unwrap().to_le_bytes());
for (index, entries) in per_dir.iter().enumerate() {
out.extend_from_slice(&u16::try_from(entries.len()).unwrap().to_le_bytes());
for (slot, entry) in entries.iter().enumerate() {
out.extend_from_slice(&entry.tag.to_le_bytes());
out.extend_from_slice(&entry.field_type.to_le_bytes());
out.extend_from_slice(&entry.count.to_le_bytes());
if entry.value.len() <= 4 {
let mut inline = entry.value.clone();
inline.resize(4, 0);
out.extend_from_slice(&inline);
} else {
out.extend_from_slice(
&u32::try_from(value_offs[index][slot])
.unwrap()
.to_le_bytes(),
);
}
}
let next = starts
.get(index + 1)
.map_or(0u32, |s| u32::try_from(*s).unwrap());
out.extend_from_slice(&next.to_le_bytes());
for entry in entries {
if entry.value.len() > 4 {
out.extend_from_slice(&entry.value);
if entry.value.len() % 2 == 1 {
out.push(0);
}
}
}
for strip in &dirs[index].strips {
out.extend_from_slice(strip);
if strip.len() % 2 == 1 {
out.push(0);
}
}
}
out
}
fn strip_ok(data: &[u8]) -> Stripped {
TiffHandler
.strip(data, &StripOptions::default())
.expect("strip failed")
}
fn fields(data: &[u8]) -> Vec<String> {
TiffHandler
.inspect(data, &InspectOptions::names_only())
.expect("inspect failed")
.findings
.into_iter()
.filter_map(|f| f.field)
.collect()
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
#[test]
fn the_picture_is_copied_byte_for_byte() {
let mut t = structural();
t.push(ascii(0x013B, "SYNTHETIC-ARTIST"));
let input = build(&[page(t, b"SYNTHETIC-PIXELS")]);
let output = strip_ok(&input).bytes;
assert!(
contains(&output, b"SYNTHETIC-PIXELS"),
"the image data did not survive byte for byte"
);
}
#[test]
fn identifying_tags_are_reported_and_gone_from_the_output() {
let mut t = structural();
t.push(ascii(0x010F, "SYNTHETIC-MAKE"));
t.push(ascii(0x0110, "SYNTHETIC-MODEL"));
t.push(ascii(0x0131, "SYNTHETIC-SOFTWARE"));
t.push(ascii(0x013B, "SYNTHETIC-ARTIST"));
t.push(ascii(0x0132, "2026:08:25 11:00:00"));
let input = build(&[page(t, b"SYNTHETIC-PIXELS")]);
let named = fields(&input);
for expected in ["Make", "Model", "Software", "Artist", "DateTime"] {
assert!(
named.iter().any(|f| f == expected),
"{expected} not reported"
);
}
let output = strip_ok(&input).bytes;
for secret in [
&b"SYNTHETIC-MAKE"[..],
b"SYNTHETIC-MODEL",
b"SYNTHETIC-SOFTWARE",
b"SYNTHETIC-ARTIST",
b"2026:08:25",
] {
assert!(
!contains(&output, secret),
"a removed value survived into the output"
);
}
}
#[test]
fn an_unknown_vendor_tag_does_not_survive_by_being_unknown() {
let mut t = structural();
t.push(ascii(0xC5D9, "SYNTHETIC-SERIAL-0001"));
let input = build(&[page(t, b"SYNTHETIC-PIXELS")]);
let output = strip_ok(&input).bytes;
assert!(!contains(&output, b"SYNTHETIC-SERIAL-0001"));
assert!(!fields(&input).is_empty(), "the tag was not reported");
}
#[test]
fn the_output_reads_back_clean() {
let mut t = structural();
t.push(ascii(0x013B, "SYNTHETIC-ARTIST"));
let input = build(&[page(t, b"SYNTHETIC-PIXELS")]);
let output = strip_ok(&input).bytes;
assert!(fields(&output).is_empty(), "metadata survived the rebuild");
}
#[test]
fn stripping_twice_changes_nothing() {
let mut t = structural();
t.push(ascii(0x013B, "SYNTHETIC-ARTIST"));
let input = build(&[page(t, b"SYNTHETIC-PIXELS")]);
let once = strip_ok(&input).bytes;
let twice = strip_ok(&once).bytes;
assert_eq!(once, twice, "strip is not idempotent");
}
#[test]
fn a_reduced_resolution_directory_is_dropped() {
let main = structural();
let mut thumb = structural();
thumb.push(long(tags::NEW_SUBFILE_TYPE, 1));
let input = build(&[
page(main, b"SYNTHETIC-PIXELS"),
page(thumb, b"SYNTHETIC-THUMBNAIL"),
]);
let output = strip_ok(&input).bytes;
assert!(contains(&output, b"SYNTHETIC-PIXELS"));
assert!(
!contains(&output, b"SYNTHETIC-THUMBNAIL"),
"the reduced-resolution copy survived"
);
}
#[test]
fn the_pages_of_a_multi_page_scan_are_all_kept() {
let input = build(&[
page(structural(), b"SYNTHETIC-PAGE-ONE"),
page(structural(), b"SYNTHETIC-PAGE-TWO"),
page(structural(), b"SYNTHETIC-PAGE-THREE"),
]);
let output = strip_ok(&input).bytes;
for pixels in [
&b"SYNTHETIC-PAGE-ONE"[..],
b"SYNTHETIC-PAGE-TWO",
b"SYNTHETIC-PAGE-THREE",
] {
assert!(contains(&output, pixels), "a page was lost");
}
}
#[test]
fn bigtiff_is_refused_rather_than_parsed_as_tiff() {
let mut input = build(&[page(structural(), b"SYNTHETIC-PIXELS")]);
input[2] = 0x2B;
assert!(matches!(
TiffHandler.strip(&input, &StripOptions::default()),
Err(StryptError::Malformed {
detail: MalformedDetail::UnsupportedFeature,
..
})
));
}
#[test]
fn a_directory_chain_that_loops_is_refused() {
let mut input = build(&[page(structural(), b"SYNTHETIC-PIXELS")]);
let first = u32::from_le_bytes([input[4], input[5], input[6], input[7]]) as usize;
let count = u16::from_le_bytes([input[first], input[first + 1]]) as usize;
let next_at = first + 2 + 12 * count;
let self_ref = u32::try_from(first).unwrap().to_le_bytes();
input[next_at..next_at + 4].copy_from_slice(&self_ref);
assert!(matches!(
TiffHandler.strip(&input, &StripOptions::default()),
Err(StryptError::Malformed {
detail: MalformedDetail::CyclicReference,
..
})
));
}
#[test]
fn a_strip_pointing_outside_the_file_is_refused() {
let mut input = build(&[page(structural(), b"SYNTHETIC-PIXELS")]);
let pixels_at = input
.windows(16)
.position(|w| w == b"SYNTHETIC-PIXELS")
.expect("pixels not found");
let needle = u32::try_from(pixels_at).unwrap().to_le_bytes();
let at = input
.windows(4)
.position(|w| w == needle)
.expect("strip offset not found");
input[at..at + 4].copy_from_slice(&0xFFFF_0000u32.to_le_bytes());
assert!(TiffHandler.strip(&input, &StripOptions::default()).is_err());
}
#[test]
fn a_truncated_file_is_refused_rather_than_completed() {
let input = build(&[page(structural(), b"SYNTHETIC-PIXELS")]);
for cut in [4, 8, 12, 30, input.len() / 2] {
let _ = TiffHandler.strip(&input[..cut], &StripOptions::default());
}
}
}