Skip to main content

strypt_core/formats/
tiff.rs

1//! TIFF, the format whose metadata *is* its file structure.
2//!
3//! Every other image handler in this crate removes metadata by dropping the container it sits
4//! in: a JPEG `APP1` segment, a PNG `eXIf` chunk, a WebP `EXIF` chunk. Each is a delimited
5//! region that can be excised whole, which is why [`super::exif`] only ever reads — as its
6//! header says, a TIFF is a graph of absolute file offsets and editing tags out of one means
7//! rewriting every offset that followed.
8//!
9//! A standalone TIFF has no block to drop. So this handler does not edit one. It **writes a new
10//! one** (ADR-0033):
11//!
12//! - **Every offset in the output is computed while writing it**, against the buffer being
13//!   built. No offset from the input is ever carried across, so the failure `exif.rs` warns
14//!   about — a file that still parses while pointing at the wrong bytes — has nothing to arise
15//!   from.
16//! - **Tags are written from an allow-list** of what is needed to decode the image, and
17//!   nothing else exists in the output because nothing else was ever written. The direction
18//!   matters: a deny-list carries an unknown tag through, and the tags that leak hardest here
19//!   are the ones no tag table has heard of — a vendor maker note, a scanner's private field
20//!   holding a serial number.
21//! - **The image data is copied byte for byte.** Strips and tiles are moved, never decoded and
22//!   never recompressed, so the output's pixels are bit-identical to the input's. mat2's
23//!   default TIFF path re-renders the image through `GdkPixbuf` instead, which reaches metadata
24//!   hidden inside the compressed data that a container rebuild cannot; where that is the
25//!   user's concern, mat2 is the better recommendation (`docs/THREAT_MODEL.md` §7.8).
26//!
27//! # What this means for the output
28//!
29//! **A stripped TIFF is never byte-identical to its input, even when the input carried no
30//! metadata at all** — a rebuild reorders the file by construction. Stripping an *already
31//! stripped* file is byte-identical, and that idempotence is what proves the writer's output is
32//! a fixed point of its own reader.
33//!
34//! # Hostility
35//!
36//! Everything read below — every offset, count, and length — was chosen by whoever made the
37//! file. Directory offsets are walked at most once each so a cycle terminates, entry counts are
38//! drawn from a shared budget so a wide directory cannot be traded for a deep one, and every
39//! strip is bounds-checked against the input before a byte of it is copied.
40
41use crate::bytes::{Reader, u32_to_usize};
42use crate::detect::Format;
43use crate::error::{MalformedDetail, ResourceLimit, Result, StryptError};
44use crate::formats::exif::{Endian, Ifd};
45use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, exif};
46
47pub(crate) mod tags;
48use crate::report::{Finding, InspectOptions, MetadataKind, MetadataReport, Note, StripReport};
49
50/// The TIFF handler.
51#[derive(Debug, Clone, Copy, Default)]
52#[non_exhaustive]
53pub struct TiffHandler;
54
55impl MetadataHandler for TiffHandler {
56    fn name(&self) -> &'static str {
57        Format::Tiff.id()
58    }
59
60    fn format(&self) -> Format {
61        Format::Tiff
62    }
63
64    fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
65        // The identical pass that stripping runs, with the output discarded — so "everything
66        // `strip` removes is something `inspect` can see" holds by construction rather than by
67        // two code paths agreeing to stay in step, which is what makes the pipeline's
68        // verification pass mean anything (`docs/ARCHITECTURE.md` §3).
69        let processed = process(input, options, &ParseLimits::default())?;
70        Ok(MetadataReport {
71            format: Format::Tiff,
72            findings: processed.findings,
73            notes: processed.notes,
74        })
75    }
76
77    fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
78        let processed = process(input, &options.inspect, &options.limits)?;
79        Ok(Stripped {
80            report: StripReport {
81                format: Format::Tiff,
82                removed: processed.findings,
83                retained: Vec::new(),
84                notes: processed.notes,
85                input_bytes: as_u64(input.len()),
86                output_bytes: as_u64(processed.output.len()),
87            },
88            bytes: processed.output,
89        })
90    }
91}
92
93/// One run of the shared inspect/strip pass.
94struct Processed {
95    output: Vec<u8>,
96    findings: Vec<Finding>,
97    notes: Vec<Note>,
98}
99
100/// Read `input`, name what is being dropped, and write the rebuilt file.
101fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
102    let (endian, first) = header(input)?;
103
104    let mut walk = Walk {
105        input,
106        endian,
107        options,
108        budget: limits.max_items,
109        visited: Vec::new(),
110        findings: Vec::new(),
111        notes: Vec::new(),
112    };
113
114    // The IFDs form a chain: each directory ends with the offset of the next. In a standalone
115    // TIFF a chained directory is another *page* — a scanned dossier is the case that matters
116    // here — unless it flags itself as a reduced-resolution copy, which is the thumbnail case
117    // and is dropped (`docs/THREAT_MODEL.md` §3).
118    let mut pages: Vec<Page<'_>> = Vec::new();
119    let mut next = first;
120    while next != 0 {
121        let start =
122            u32_to_usize(next).ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
123        if walk.visited.contains(&start) {
124            return Err(malformed(MalformedDetail::CyclicReference));
125        }
126        walk.visited.push(start);
127        let (page, following) = walk.directory(start)?;
128        if let Some(page) = page {
129            pages.push(page);
130        }
131        next = following;
132    }
133
134    if pages.is_empty() {
135        // Either the file had no directories at all, or every one of them was a
136        // reduced-resolution copy. Writing an image-less TIFF would be handing back something
137        // that is not the file the user gave us, presented as a clean version of it.
138        return Err(malformed(MalformedDetail::MissingMarker));
139    }
140
141    let output = write(endian, &pages)?;
142    Ok(Processed {
143        output,
144        findings: walk.findings,
145        notes: walk.notes,
146    })
147}
148
149/// Read the header, returning the byte order and the offset of the first directory.
150///
151/// `BigTIFF` — magic 43, with eight-byte offsets throughout — is refused by name rather than
152/// parsed badly. Its directory layout is not the one below, and a parser that reads it as
153/// though it were would produce confident nonsense (ADR-0033).
154fn header(input: &[u8]) -> Result<(Endian, u32)> {
155    let mut r = Reader::new(input);
156    let endian = match r.take(2) {
157        Some(b"II") => Endian::Little,
158        Some(b"MM") => Endian::Big,
159        _ => return Err(malformed(MalformedDetail::MissingMarker)),
160    };
161    // TIFF 6.0 §2: the magic number is 42 in the declared byte order, and it is the only thing
162    // distinguishing a real header from two bytes that happen to spell "MM".
163    match endian.u16(&mut r) {
164        Some(42) => {}
165        Some(43) => return Err(malformed(MalformedDetail::UnsupportedFeature)),
166        _ => return Err(malformed(MalformedDetail::MissingMarker)),
167    }
168    let first = endian
169        .u32(&mut r)
170        .ok_or_else(|| malformed(MalformedDetail::Truncated))?;
171    Ok((endian, first))
172}
173
174/// A directory that will be written to the output.
175struct Page<'a> {
176    /// Allow-listed tags, copied across with their values unchanged.
177    verbatim: Vec<Verbatim<'a>>,
178    /// The strips or tiles, in order, exactly as they appeared in the input.
179    data: Vec<&'a [u8]>,
180    /// Whether the image is tiled, which decides the pair of tags the geometry is written under.
181    tiled: bool,
182}
183
184/// One allow-listed tag and the value bytes it will be written with.
185struct Verbatim<'a> {
186    tag: u16,
187    field_type: u16,
188    count: u32,
189    value: &'a [u8],
190}
191
192/// State carried through the walk, so the budget and the visited set are shared across
193/// directories rather than reset per directory — which is what stops a file trading a legal
194/// number of directories against a legal number of entries in each.
195struct Walk<'a, 'o> {
196    input: &'a [u8],
197    endian: Endian,
198    options: &'o InspectOptions,
199    budget: u32,
200    visited: Vec<usize>,
201    findings: Vec<Finding>,
202    notes: Vec<Note>,
203}
204
205/// A directory entry as it appears on disk, before its value is resolved.
206struct RawEntry<'a> {
207    tag: u16,
208    field_type: u16,
209    count: u32,
210    /// The entry's own four value bytes: the value itself when it fits, otherwise its offset.
211    inline: &'a [u8],
212}
213
214impl<'a> Walk<'a, '_> {
215    /// Walk one directory, returning the page to write (if it is one) and the next offset.
216    fn directory(&mut self, start: usize) -> Result<(Option<Page<'a>>, u32)> {
217        let mut r = Reader::new(self.input);
218        r.seek(start)
219            .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
220        let count = self
221            .endian
222            .u16(&mut r)
223            .ok_or_else(|| malformed(MalformedDetail::Truncated))?;
224
225        let mut raw: Vec<RawEntry<'a>> = Vec::new();
226        for _ in 0..count {
227            if self.budget == 0 {
228                return Err(StryptError::LimitExceeded {
229                    format: Format::Tiff,
230                    limit: ResourceLimit::ItemCount,
231                });
232            }
233            self.budget = self.budget.saturating_sub(1);
234            raw.push(self.raw_entry(&mut r)?);
235        }
236        let following = self
237            .endian
238            .u32(&mut r)
239            .ok_or_else(|| malformed(MalformedDetail::Truncated))?;
240
241        // TIFF 6.0 §8, tag 0x00FE: bit 0 of NewSubfileType marks a reduced-resolution copy of
242        // another image in the same file. That is a thumbnail, and a thumbnail survives every
243        // crop and every redaction painted over the picture it was made from.
244        if self.is_reduced_resolution(&raw) {
245            self.findings.push(
246                Finding::new(MetadataKind::Thumbnail, "TIFF reduced-resolution IFD", 0)
247                    .with_field("reduced-resolution image"),
248            );
249            return Ok((None, following));
250        }
251
252        let page = self.page(&raw)?;
253        Ok((Some(page), following))
254    }
255
256    /// Read one 12-byte directory entry.
257    fn raw_entry(&mut self, r: &mut Reader<'a>) -> Result<RawEntry<'a>> {
258        let truncated = || malformed(MalformedDetail::Truncated);
259        let tag = self.endian.u16(r).ok_or_else(truncated)?;
260        let field_type = self.endian.u16(r).ok_or_else(truncated)?;
261        let count = self.endian.u32(r).ok_or_else(truncated)?;
262        let inline = r.take(4).ok_or_else(truncated)?;
263        Ok(RawEntry {
264            tag,
265            field_type,
266            count,
267            inline,
268        })
269    }
270
271    /// Whether this directory declares itself a reduced-resolution copy.
272    fn is_reduced_resolution(&self, raw: &[RawEntry<'a>]) -> bool {
273        raw.iter()
274            .find(|e| e.tag == tags::NEW_SUBFILE_TYPE)
275            .and_then(|e| self.value_of(e))
276            .and_then(|v| integer(self.endian, v, tags::LONG))
277            .is_some_and(|v| v & 1 == 1)
278    }
279
280    /// Resolve an entry's value, refusing one that runs past the end of the file.
281    ///
282    /// TIFF 6.0 §2: a value of four bytes or fewer is stored in the entry itself; anything
283    /// longer is stored elsewhere and those four bytes are its offset.
284    fn value_of(&self, entry: &RawEntry<'a>) -> Option<&'a [u8]> {
285        let size = exif::type_size(entry.field_type)?;
286        let length = u64::from(entry.count).checked_mul(u64::from(size))?;
287        let length = usize::try_from(length).ok()?;
288        if length <= 4 {
289            return entry.inline.get(0..length);
290        }
291        let mut w = Reader::new(entry.inline);
292        let at = u32_to_usize(self.endian.u32(&mut w)?)?;
293        let mut r = Reader::new(self.input);
294        r.seek(at)?;
295        r.take(length)
296    }
297
298    /// Split one directory into what is kept and what is reported as removed.
299    fn page(&mut self, raw: &[RawEntry<'a>]) -> Result<Page<'a>> {
300        let tiled = raw.iter().any(|e| e.tag == tags::TILE_OFFSETS);
301
302        let mut verbatim: Vec<Verbatim<'a>> = Vec::new();
303        let mut offsets: Option<Vec<u64>> = None;
304        let mut counts: Option<Vec<u64>> = None;
305
306        for entry in raw {
307            let value = self.value_of(entry);
308
309            // The four tags that carry the image data's position and size are not copied:
310            // they are regenerated from where the data lands in the output.
311            let (offsets_tag, counts_tag) = geometry_tags(tiled);
312            if entry.tag == offsets_tag {
313                offsets = Some(self.integers(entry, value)?);
314                continue;
315            }
316            if entry.tag == counts_tag {
317                counts = Some(self.integers(entry, value)?);
318                continue;
319            }
320            // The unused half of the strip/tile pair describes a geometry this image does not
321            // use. It is structural rather than identifying, and copying it across would
322            // contradict the geometry that is actually written.
323            if matches!(
324                entry.tag,
325                tags::STRIP_OFFSETS
326                    | tags::STRIP_BYTE_COUNTS
327                    | tags::TILE_OFFSETS
328                    | tags::TILE_BYTE_COUNTS
329            ) {
330                continue;
331            }
332
333            if tags::is_structural(entry.tag) {
334                // Duplicated tags are not an error in the wild; the first wins, and the second
335                // is dropped rather than written twice into a directory that must be sorted.
336                if verbatim.iter().any(|v| v.tag == entry.tag) {
337                    continue;
338                }
339                let value = value.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
340                verbatim.push(Verbatim {
341                    tag: entry.tag,
342                    field_type: entry.field_type,
343                    count: entry.count,
344                    value,
345                });
346                continue;
347            }
348
349            // Everything else is removed by never being written. A pointer tag is reported by
350            // its contents rather than by itself: the four bytes of the pointer identify
351            // nobody, and what they lead to identifies everybody.
352            if let Some(sub) = exif::sub_directory(Ifd::Primary, entry.tag) {
353                self.report_sub_directory(entry, value, sub);
354                continue;
355            }
356            self.report_removed(Ifd::Primary, entry, value);
357        }
358
359        let offsets = offsets.ok_or_else(|| malformed(MalformedDetail::MissingMarker))?;
360        let counts = counts.ok_or_else(|| malformed(MalformedDetail::MissingMarker))?;
361        let data = self.slice_data(&offsets, &counts)?;
362
363        // Without dimensions there is nothing to write a header about, and a reader given a
364        // directory missing them cannot decode what follows.
365        for required in [tags::IMAGE_WIDTH, tags::IMAGE_LENGTH] {
366            if !verbatim.iter().any(|v| v.tag == required) {
367                return Err(malformed(MalformedDetail::MissingMarker));
368            }
369        }
370
371        Ok(Page {
372            verbatim,
373            data,
374            tiled,
375        })
376    }
377
378    /// Read a SHORT or LONG array, which is what the strip and tile geometry is written as.
379    fn integers(&self, entry: &RawEntry<'a>, value: Option<&'a [u8]>) -> Result<Vec<u64>> {
380        let value = value.ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
381        let width = match entry.field_type {
382            tags::SHORT => 2usize,
383            tags::LONG => 4usize,
384            // TIFF 6.0 permits only SHORT or LONG here. Anything else is a file strypt does not
385            // understand well enough to rebuild, and guessing is how a strip offset ends up
386            // pointing at the wrong bytes.
387            _ => return Err(malformed(MalformedDetail::UnsupportedFeature)),
388        };
389        let mut out = Vec::new();
390        let mut r = Reader::new(value);
391        while r.remaining() >= width {
392            let item = if width == 2 {
393                self.endian.u16(&mut r).map(u64::from)
394            } else {
395                self.endian.u32(&mut r).map(u64::from)
396            };
397            out.push(item.ok_or_else(|| malformed(MalformedDetail::Truncated))?);
398        }
399        Ok(out)
400    }
401
402    /// Bounds-check the strip or tile geometry and take the data.
403    fn slice_data(&self, offsets: &[u64], counts: &[u64]) -> Result<Vec<&'a [u8]>> {
404        if offsets.is_empty() || offsets.len() != counts.len() {
405            return Err(malformed(MalformedDetail::BrokenIndex));
406        }
407        let mut data = Vec::new();
408        for (offset, count) in offsets.iter().zip(counts.iter()) {
409            let start = usize::try_from(*offset)
410                .map_err(|_| malformed(MalformedDetail::LengthOutOfRange))?;
411            let len = usize::try_from(*count)
412                .map_err(|_| malformed(MalformedDetail::LengthOutOfRange))?;
413            let mut r = Reader::new(self.input);
414            r.seek(start)
415                .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
416            let slice = r
417                .take(len)
418                .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
419            data.push(slice);
420        }
421        Ok(data)
422    }
423
424    /// Report every tag inside a sub-directory that is about to vanish with it.
425    fn report_sub_directory(&mut self, entry: &RawEntry<'a>, value: Option<&'a [u8]>, sub: Ifd) {
426        let Some(target) = value
427            .or(Some(entry.inline))
428            .and_then(|v| {
429                let mut r = Reader::new(v);
430                self.endian.u32(&mut r)
431            })
432            .and_then(u32_to_usize)
433        else {
434            return;
435        };
436        if self.visited.contains(&target) {
437            return;
438        }
439        self.visited.push(target);
440
441        let mut r = Reader::new(self.input);
442        if r.seek(target).is_none() {
443            return;
444        }
445        let Some(count) = self.endian.u16(&mut r) else {
446            return;
447        };
448        for _ in 0..count {
449            if self.budget == 0 {
450                self.notes.push(Note::UnparsedRegion {
451                    location: format!("TIFF {}", label(sub)),
452                    bytes: as_u64(r.remaining()),
453                });
454                return;
455            }
456            self.budget = self.budget.saturating_sub(1);
457            let Ok(sub_entry) = self.raw_entry(&mut r) else {
458                return;
459            };
460            let sub_value = self.value_of(&sub_entry);
461            if let Some(deeper) = exif::sub_directory(sub, sub_entry.tag) {
462                self.report_sub_directory(&sub_entry, sub_value, deeper);
463                continue;
464            }
465            self.report_removed(sub, &sub_entry, sub_value);
466        }
467    }
468
469    /// Record one tag as removed, named from the shared Exif tag table.
470    fn report_removed(&mut self, ifd: Ifd, entry: &RawEntry<'a>, value: Option<&'a [u8]>) {
471        let (name, kind) = exif::describe(ifd, entry.tag);
472        let bytes = value.map_or(0, |v| as_u64(v.len()));
473        let field_type = entry.field_type;
474        self.findings.push(
475            Finding::new(kind, format!("TIFF {}", label(ifd)), bytes)
476                .with_field(name)
477                .with_value(self.options, || exif::render(value, field_type)),
478        );
479    }
480}
481
482/// The location label a finding carries.
483const fn label(ifd: Ifd) -> &'static str {
484    match ifd {
485        Ifd::Primary => "IFD0",
486        Ifd::Exif => "Exif IFD",
487        Ifd::Gps => "GPS IFD",
488        Ifd::Interop => "Interop IFD",
489        Ifd::Thumbnail => "IFD1 (thumbnail)",
490    }
491}
492
493/// The offsets/byte-counts tag pair an image's geometry is written under.
494const fn geometry_tags(tiled: bool) -> (u16, u16) {
495    if tiled {
496        (tags::TILE_OFFSETS, tags::TILE_BYTE_COUNTS)
497    } else {
498        (tags::STRIP_OFFSETS, tags::STRIP_BYTE_COUNTS)
499    }
500}
501
502/// Read a SHORT or LONG scalar.
503fn integer(endian: Endian, value: &[u8], field_type: u16) -> Option<u64> {
504    let mut r = Reader::new(value);
505    match field_type {
506        tags::SHORT => endian.u16(&mut r).map(u64::from),
507        _ => endian.u32(&mut r).map(u64::from),
508    }
509}
510
511// ---------------------------------------------------------------------------------------
512// Writing
513// ---------------------------------------------------------------------------------------
514
515/// An entry as it will be written, before its value bytes exist.
516struct OutEntry {
517    tag: u16,
518    field_type: u16,
519    count: u32,
520    /// How many bytes the value occupies. Known from the count and the type alone, which is
521    /// what lets the whole layout be computed before any value is produced — and therefore
522    /// what lets every offset be written once, correctly, rather than patched afterwards.
523    len: usize,
524    source: Source,
525}
526
527/// Where an entry's value bytes come from.
528enum Source {
529    /// Copied from the input unchanged.
530    Verbatim(usize),
531    /// The offsets the image data landed at in the output.
532    DataOffsets,
533    /// The lengths of that data.
534    DataCounts,
535}
536
537/// Where one page's pieces were placed in the output.
538struct Placed {
539    entries: Vec<OutEntry>,
540    ifd_at: u32,
541    /// Offset of each entry's out-of-line value, parallel to `entries`. Zero for inline values.
542    value_at: Vec<u32>,
543    data_at: Vec<u32>,
544}
545
546/// Build the output file.
547///
548/// Layout is computed in full before a byte is written: header, then for each page its
549/// directory, then that page's out-of-line values, then its image data. Every offset therefore
550/// has a known value at the moment it is written, and nothing is revisited.
551fn write(endian: Endian, pages: &[Page<'_>]) -> Result<Vec<u8>> {
552    let mut cursor: u64 = 8;
553    let mut placed: Vec<Placed> = Vec::new();
554
555    for page in pages {
556        let entries = out_entries(page);
557        let ifd_at = to_u32(cursor)?;
558        // TIFF 6.0 §2: a directory is a two-byte count, twelve bytes per entry, and a four-byte
559        // offset to the next.
560        let ifd_size = u64::try_from(entries.len())
561            .ok()
562            .and_then(|n| n.checked_mul(12))
563            .and_then(|n| n.checked_add(6))
564            .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))?;
565        cursor = advance(cursor, ifd_size)?;
566
567        let mut value_at = Vec::with_capacity(entries.len());
568        for entry in &entries {
569            if entry.len <= 4 {
570                value_at.push(0);
571                continue;
572            }
573            value_at.push(to_u32(cursor)?);
574            cursor = advance(cursor, pad_even(width_of(entry.len)?))?;
575        }
576
577        let mut data_at = Vec::with_capacity(page.data.len());
578        for blob in &page.data {
579            data_at.push(to_u32(cursor)?);
580            cursor = advance(cursor, pad_even(width_of(blob.len())?))?;
581        }
582
583        placed.push(Placed {
584            entries,
585            ifd_at,
586            value_at,
587            data_at,
588        });
589    }
590
591    let mut out: Vec<u8> = Vec::new();
592    out.extend_from_slice(match endian {
593        Endian::Little => b"II",
594        Endian::Big => b"MM",
595    });
596    put_u16(&mut out, endian, 42);
597    put_u32(
598        &mut out,
599        endian,
600        placed.first().map_or(0, |first| first.ifd_at),
601    );
602
603    for (index, page) in pages.iter().enumerate() {
604        let Some(spot) = placed.get(index) else {
605            return Err(malformed(MalformedDetail::NotRoundTrippable));
606        };
607        // The chain ends at zero; every other directory points at the one after it.
608        let next = placed.get(index.saturating_add(1)).map_or(0, |p| p.ifd_at);
609        write_page(&mut out, endian, page, spot, next)?;
610    }
611    Ok(out)
612}
613
614/// Decide the entries one page will be written with, in the ascending tag order TIFF requires.
615fn out_entries(page: &Page<'_>) -> Vec<OutEntry> {
616    let mut entries: Vec<OutEntry> = page
617        .verbatim
618        .iter()
619        .enumerate()
620        .map(|(index, v)| OutEntry {
621            tag: v.tag,
622            field_type: v.field_type,
623            count: v.count,
624            len: v.value.len(),
625            source: Source::Verbatim(index),
626        })
627        .collect();
628
629    let (offsets_tag, counts_tag) = geometry_tags(page.tiled);
630    let n = page.data.len();
631    // A page cannot have more strips than the entry count ceiling already allowed through, so
632    // this saturation is unreachable; it is written rather than asserted because an assertion
633    // in the parsing path is a panic (ADR-0006).
634    let n32 = u32::try_from(n).unwrap_or(u32::MAX);
635    // Written as LONG whatever the input used. A rebuilt file's offsets are not the input's,
636    // and a SHORT that fitted before need not fit now; promoting once here is simpler to reason
637    // about than a width that depends on where the data happened to land.
638    entries.push(OutEntry {
639        tag: offsets_tag,
640        field_type: tags::LONG,
641        count: n32,
642        len: n.saturating_mul(4),
643        source: Source::DataOffsets,
644    });
645    entries.push(OutEntry {
646        tag: counts_tag,
647        field_type: tags::LONG,
648        count: n32,
649        len: n.saturating_mul(4),
650        source: Source::DataCounts,
651    });
652
653    // TIFF 6.0 §2 requires directory entries in ascending tag order.
654    entries.sort_by_key(|e| e.tag);
655    entries
656}
657
658/// Write one directory, its out-of-line values, and its image data.
659fn write_page(
660    out: &mut Vec<u8>,
661    endian: Endian,
662    page: &Page<'_>,
663    spot: &Placed,
664    next: u32,
665) -> Result<()> {
666    let entry_count =
667        u16::try_from(spot.entries.len()).map_err(|_| malformed(MalformedDetail::BrokenIndex))?;
668    put_u16(out, endian, entry_count);
669    for (index, entry) in spot.entries.iter().enumerate() {
670        put_u16(out, endian, entry.tag);
671        put_u16(out, endian, entry.field_type);
672        put_u32(out, endian, entry.count);
673        if entry.len <= 4 {
674            let mut inline = value_bytes(endian, page, spot, entry)?;
675            inline.resize(4, 0);
676            out.extend_from_slice(&inline);
677        } else {
678            let at = spot
679                .value_at
680                .get(index)
681                .copied()
682                .ok_or_else(|| malformed(MalformedDetail::NotRoundTrippable))?;
683            put_u32(out, endian, at);
684        }
685    }
686    put_u32(out, endian, next);
687
688    for entry in &spot.entries {
689        if entry.len <= 4 {
690            continue;
691        }
692        let bytes = value_bytes(endian, page, spot, entry)?;
693        out.extend_from_slice(&bytes);
694        if !bytes.len().is_multiple_of(2) {
695            out.push(0);
696        }
697    }
698
699    for blob in &page.data {
700        out.extend_from_slice(blob);
701        if !blob.len().is_multiple_of(2) {
702            out.push(0);
703        }
704    }
705    Ok(())
706}
707
708/// Produce one entry's value bytes.
709fn value_bytes(
710    endian: Endian,
711    page: &Page<'_>,
712    spot: &Placed,
713    entry: &OutEntry,
714) -> Result<Vec<u8>> {
715    match entry.source {
716        Source::Verbatim(index) => page
717            .verbatim
718            .get(index)
719            .map(|v| v.value.to_vec())
720            .ok_or_else(|| malformed(MalformedDetail::NotRoundTrippable)),
721        Source::DataOffsets => {
722            let mut bytes = Vec::new();
723            for at in &spot.data_at {
724                put_u32(&mut bytes, endian, *at);
725            }
726            Ok(bytes)
727        }
728        Source::DataCounts => {
729            let mut bytes = Vec::new();
730            for blob in &page.data {
731                put_u32(&mut bytes, endian, to_u32(width_of(blob.len())?)?);
732            }
733            Ok(bytes)
734        }
735    }
736}
737
738/// Round a length up to a two-byte boundary, which is where TIFF values must start.
739const fn pad_even(len: u64) -> u64 {
740    if len.is_multiple_of(2) {
741        len
742    } else {
743        len.saturating_add(1)
744    }
745}
746
747/// Move the write cursor, refusing an output that would not be addressable.
748fn advance(cursor: u64, by: u64) -> Result<u64> {
749    cursor
750        .checked_add(by)
751        .filter(|next| u32::try_from(*next).is_ok())
752        .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange))
753}
754
755/// Widen a length for offset arithmetic, refusing one this platform cannot represent.
756fn width_of(len: usize) -> Result<u64> {
757    u64::try_from(len).map_err(|_| malformed(MalformedDetail::LengthOutOfRange))
758}
759
760/// Narrow an offset to the four bytes a TIFF offset is written in.
761fn to_u32(value: u64) -> Result<u32> {
762    u32::try_from(value).map_err(|_| malformed(MalformedDetail::LengthOutOfRange))
763}
764
765fn put_u16(out: &mut Vec<u8>, endian: Endian, value: u16) {
766    match endian {
767        Endian::Little => out.extend_from_slice(&value.to_le_bytes()),
768        Endian::Big => out.extend_from_slice(&value.to_be_bytes()),
769    }
770}
771
772fn put_u32(out: &mut Vec<u8>, endian: Endian, value: u32) {
773    match endian {
774        Endian::Little => out.extend_from_slice(&value.to_le_bytes()),
775        Endian::Big => out.extend_from_slice(&value.to_be_bytes()),
776    }
777}
778
779fn malformed(detail: MalformedDetail) -> StryptError {
780    StryptError::Malformed {
781        format: Format::Tiff,
782        offset: None,
783        detail,
784    }
785}
786
787fn as_u64(value: usize) -> u64 {
788    u64::try_from(value).unwrap_or(u64::MAX)
789}
790
791#[cfg(test)]
792mod tests {
793    // Test code is never reachable from untrusted bytes, which is the boundary the
794    // panic-freedom lints exist to police (ADR-0006).
795    #![allow(
796        clippy::unwrap_used,
797        clippy::expect_used,
798        clippy::indexing_slicing,
799        clippy::arithmetic_side_effects,
800        clippy::struct_field_names,
801        clippy::too_many_lines
802    )]
803
804    use super::*;
805    use crate::formats::StripOptions;
806
807    /// A tag as the builder below writes it: the value bytes in full, inline or not.
808    struct Tag {
809        tag: u16,
810        field_type: u16,
811        count: u32,
812        value: Vec<u8>,
813    }
814
815    fn short(tag: u16, value: u16) -> Tag {
816        Tag {
817            tag,
818            field_type: tags::SHORT,
819            count: 1,
820            value: value.to_le_bytes().to_vec(),
821        }
822    }
823
824    fn ascii(tag: u16, text: &str) -> Tag {
825        let mut value = text.as_bytes().to_vec();
826        value.push(0);
827        Tag {
828            tag,
829            field_type: 2,
830            count: u32::try_from(value.len()).expect("short string"),
831            value,
832        }
833    }
834
835    fn long(tag: u16, value: u32) -> Tag {
836        Tag {
837            tag,
838            field_type: tags::LONG,
839            count: 1,
840            value: value.to_le_bytes().to_vec(),
841        }
842    }
843
844    /// The tags every page below needs to be a decodable image.
845    fn structural() -> Vec<Tag> {
846        vec![
847            short(tags::IMAGE_WIDTH, 4),
848            short(tags::IMAGE_LENGTH, 1),
849            short(0x0102, 8), // BitsPerSample
850            short(0x0103, 1), // Compression: none
851            short(0x0106, 1), // PhotometricInterpretation: black is zero
852            short(0x0115, 1), // SamplesPerPixel
853            short(0x0116, 1), // RowsPerStrip
854            short(0x011C, 1), // PlanarConfiguration
855        ]
856    }
857
858    /// One directory to be written by [`build`].
859    struct Dir {
860        tags: Vec<Tag>,
861        strips: Vec<Vec<u8>>,
862    }
863
864    fn page(tags: Vec<Tag>, strip: &[u8]) -> Dir {
865        Dir {
866            tags,
867            strips: vec![strip.to_vec()],
868        }
869    }
870
871    /// One entry as the builder places it.
872    struct Slot {
873        tag: u16,
874        field_type: u16,
875        count: u32,
876        value: Vec<u8>,
877        /// `Some(true)` for the strip offsets, `Some(false)` for the byte counts: both are
878        /// filled in once the layout is known.
879        geometry: Option<bool>,
880    }
881
882    /// Write a little-endian TIFF: header, then each directory followed by its values and its
883    /// strips. Deliberately arranged differently from the handler's own writer, so a passing
884    /// test cannot be an artefact of the writer reading back its own layout.
885    fn build(dirs: &[Dir]) -> Vec<u8> {
886        let mut per_dir: Vec<Vec<Slot>> = Vec::new();
887        for dir in dirs {
888            let n = u32::try_from(dir.strips.len()).unwrap();
889            let mut entries: Vec<Slot> = dir
890                .tags
891                .iter()
892                .map(|t| Slot {
893                    tag: t.tag,
894                    field_type: t.field_type,
895                    count: t.count,
896                    value: t.value.clone(),
897                    geometry: None,
898                })
899                .collect();
900            entries.push(Slot {
901                tag: tags::STRIP_OFFSETS,
902                field_type: tags::LONG,
903                count: n,
904                value: vec![0; 4 * dir.strips.len()],
905                geometry: Some(true),
906            });
907            entries.push(Slot {
908                tag: tags::STRIP_BYTE_COUNTS,
909                field_type: tags::LONG,
910                count: n,
911                value: vec![0; 4 * dir.strips.len()],
912                geometry: Some(false),
913            });
914            entries.sort_by_key(|e| e.tag);
915            per_dir.push(entries);
916        }
917
918        let mut cursor = 8usize;
919        let mut starts: Vec<usize> = Vec::new();
920        let mut value_offs: Vec<Vec<usize>> = Vec::new();
921        let mut data_offs: Vec<Vec<usize>> = Vec::new();
922        for (index, entries) in per_dir.iter().enumerate() {
923            starts.push(cursor);
924            cursor += 2 + 12 * entries.len() + 4;
925            let mut offs = Vec::new();
926            for entry in entries {
927                if entry.value.len() > 4 {
928                    offs.push(cursor);
929                    cursor += entry.value.len() + entry.value.len() % 2;
930                } else {
931                    offs.push(0);
932                }
933            }
934            value_offs.push(offs);
935            let mut data = Vec::new();
936            for strip in &dirs[index].strips {
937                data.push(cursor);
938                cursor += strip.len() + strip.len() % 2;
939            }
940            data_offs.push(data);
941        }
942
943        for (index, entries) in per_dir.iter_mut().enumerate() {
944            for entry in entries.iter_mut() {
945                match entry.geometry {
946                    Some(true) => {
947                        entry.value = data_offs[index]
948                            .iter()
949                            .flat_map(|at| u32::try_from(*at).unwrap().to_le_bytes())
950                            .collect();
951                    }
952                    Some(false) => {
953                        entry.value = dirs[index]
954                            .strips
955                            .iter()
956                            .flat_map(|s| u32::try_from(s.len()).unwrap().to_le_bytes())
957                            .collect();
958                    }
959                    None => {}
960                }
961            }
962        }
963
964        let mut out: Vec<u8> = vec![b'I', b'I', 0x2A, 0x00];
965        out.extend_from_slice(&u32::try_from(starts[0]).unwrap().to_le_bytes());
966        for (index, entries) in per_dir.iter().enumerate() {
967            out.extend_from_slice(&u16::try_from(entries.len()).unwrap().to_le_bytes());
968            for (slot, entry) in entries.iter().enumerate() {
969                out.extend_from_slice(&entry.tag.to_le_bytes());
970                out.extend_from_slice(&entry.field_type.to_le_bytes());
971                out.extend_from_slice(&entry.count.to_le_bytes());
972                if entry.value.len() <= 4 {
973                    let mut inline = entry.value.clone();
974                    inline.resize(4, 0);
975                    out.extend_from_slice(&inline);
976                } else {
977                    out.extend_from_slice(
978                        &u32::try_from(value_offs[index][slot])
979                            .unwrap()
980                            .to_le_bytes(),
981                    );
982                }
983            }
984            let next = starts
985                .get(index + 1)
986                .map_or(0u32, |s| u32::try_from(*s).unwrap());
987            out.extend_from_slice(&next.to_le_bytes());
988            for entry in entries {
989                if entry.value.len() > 4 {
990                    out.extend_from_slice(&entry.value);
991                    if entry.value.len() % 2 == 1 {
992                        out.push(0);
993                    }
994                }
995            }
996            for strip in &dirs[index].strips {
997                out.extend_from_slice(strip);
998                if strip.len() % 2 == 1 {
999                    out.push(0);
1000                }
1001            }
1002        }
1003        out
1004    }
1005
1006    fn strip_ok(data: &[u8]) -> Stripped {
1007        TiffHandler
1008            .strip(data, &StripOptions::default())
1009            .expect("strip failed")
1010    }
1011
1012    fn fields(data: &[u8]) -> Vec<String> {
1013        TiffHandler
1014            .inspect(data, &InspectOptions::names_only())
1015            .expect("inspect failed")
1016            .findings
1017            .into_iter()
1018            .filter_map(|f| f.field)
1019            .collect()
1020    }
1021
1022    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
1023        haystack.windows(needle.len()).any(|w| w == needle)
1024    }
1025
1026    #[test]
1027    fn the_picture_is_copied_byte_for_byte() {
1028        let mut t = structural();
1029        t.push(ascii(0x013B, "SYNTHETIC-ARTIST"));
1030        let input = build(&[page(t, b"SYNTHETIC-PIXELS")]);
1031        let output = strip_ok(&input).bytes;
1032        assert!(
1033            contains(&output, b"SYNTHETIC-PIXELS"),
1034            "the image data did not survive byte for byte"
1035        );
1036    }
1037
1038    #[test]
1039    fn identifying_tags_are_reported_and_gone_from_the_output() {
1040        let mut t = structural();
1041        t.push(ascii(0x010F, "SYNTHETIC-MAKE"));
1042        t.push(ascii(0x0110, "SYNTHETIC-MODEL"));
1043        t.push(ascii(0x0131, "SYNTHETIC-SOFTWARE"));
1044        t.push(ascii(0x013B, "SYNTHETIC-ARTIST"));
1045        t.push(ascii(0x0132, "2026:08:25 11:00:00"));
1046        let input = build(&[page(t, b"SYNTHETIC-PIXELS")]);
1047
1048        let named = fields(&input);
1049        for expected in ["Make", "Model", "Software", "Artist", "DateTime"] {
1050            assert!(
1051                named.iter().any(|f| f == expected),
1052                "{expected} not reported"
1053            );
1054        }
1055
1056        let output = strip_ok(&input).bytes;
1057        for secret in [
1058            &b"SYNTHETIC-MAKE"[..],
1059            b"SYNTHETIC-MODEL",
1060            b"SYNTHETIC-SOFTWARE",
1061            b"SYNTHETIC-ARTIST",
1062            b"2026:08:25",
1063        ] {
1064            assert!(
1065                !contains(&output, secret),
1066                "a removed value survived into the output"
1067            );
1068        }
1069    }
1070
1071    #[test]
1072    fn an_unknown_vendor_tag_does_not_survive_by_being_unknown() {
1073        // The allow-list's whole reason for running in this direction (ADR-0033).
1074        let mut t = structural();
1075        t.push(ascii(0xC5D9, "SYNTHETIC-SERIAL-0001"));
1076        let input = build(&[page(t, b"SYNTHETIC-PIXELS")]);
1077        let output = strip_ok(&input).bytes;
1078        assert!(!contains(&output, b"SYNTHETIC-SERIAL-0001"));
1079        assert!(!fields(&input).is_empty(), "the tag was not reported");
1080    }
1081
1082    #[test]
1083    fn the_output_reads_back_clean() {
1084        let mut t = structural();
1085        t.push(ascii(0x013B, "SYNTHETIC-ARTIST"));
1086        let input = build(&[page(t, b"SYNTHETIC-PIXELS")]);
1087        let output = strip_ok(&input).bytes;
1088        // Re-inspecting the output is what the pipeline's verification pass does; nothing may
1089        // remain for it to find.
1090        assert!(fields(&output).is_empty(), "metadata survived the rebuild");
1091    }
1092
1093    #[test]
1094    fn stripping_twice_changes_nothing() {
1095        let mut t = structural();
1096        t.push(ascii(0x013B, "SYNTHETIC-ARTIST"));
1097        let input = build(&[page(t, b"SYNTHETIC-PIXELS")]);
1098        let once = strip_ok(&input).bytes;
1099        let twice = strip_ok(&once).bytes;
1100        assert_eq!(once, twice, "strip is not idempotent");
1101    }
1102
1103    #[test]
1104    fn a_reduced_resolution_directory_is_dropped() {
1105        // A thumbnail is a complete second image that survives any crop or redaction applied
1106        // to the first (`docs/THREAT_MODEL.md` §3).
1107        let main = structural();
1108        let mut thumb = structural();
1109        thumb.push(long(tags::NEW_SUBFILE_TYPE, 1));
1110        let input = build(&[
1111            page(main, b"SYNTHETIC-PIXELS"),
1112            page(thumb, b"SYNTHETIC-THUMBNAIL"),
1113        ]);
1114        let output = strip_ok(&input).bytes;
1115        assert!(contains(&output, b"SYNTHETIC-PIXELS"));
1116        assert!(
1117            !contains(&output, b"SYNTHETIC-THUMBNAIL"),
1118            "the reduced-resolution copy survived"
1119        );
1120    }
1121
1122    #[test]
1123    fn the_pages_of_a_multi_page_scan_are_all_kept() {
1124        // The case that matters for this project's users: a scanned dossier is one TIFF with a
1125        // directory per page, and dropping pages would be losing the document.
1126        let input = build(&[
1127            page(structural(), b"SYNTHETIC-PAGE-ONE"),
1128            page(structural(), b"SYNTHETIC-PAGE-TWO"),
1129            page(structural(), b"SYNTHETIC-PAGE-THREE"),
1130        ]);
1131        let output = strip_ok(&input).bytes;
1132        for pixels in [
1133            &b"SYNTHETIC-PAGE-ONE"[..],
1134            b"SYNTHETIC-PAGE-TWO",
1135            b"SYNTHETIC-PAGE-THREE",
1136        ] {
1137            assert!(contains(&output, pixels), "a page was lost");
1138        }
1139    }
1140
1141    #[test]
1142    fn bigtiff_is_refused_rather_than_parsed_as_tiff() {
1143        let mut input = build(&[page(structural(), b"SYNTHETIC-PIXELS")]);
1144        input[2] = 0x2B;
1145        assert!(matches!(
1146            TiffHandler.strip(&input, &StripOptions::default()),
1147            Err(StryptError::Malformed {
1148                detail: MalformedDetail::UnsupportedFeature,
1149                ..
1150            })
1151        ));
1152    }
1153
1154    #[test]
1155    fn a_directory_chain_that_loops_is_refused() {
1156        let mut input = build(&[page(structural(), b"SYNTHETIC-PIXELS")]);
1157        // Point the first directory's "next" field back at itself.
1158        let first = u32::from_le_bytes([input[4], input[5], input[6], input[7]]) as usize;
1159        let count = u16::from_le_bytes([input[first], input[first + 1]]) as usize;
1160        let next_at = first + 2 + 12 * count;
1161        let self_ref = u32::try_from(first).unwrap().to_le_bytes();
1162        input[next_at..next_at + 4].copy_from_slice(&self_ref);
1163        assert!(matches!(
1164            TiffHandler.strip(&input, &StripOptions::default()),
1165            Err(StryptError::Malformed {
1166                detail: MalformedDetail::CyclicReference,
1167                ..
1168            })
1169        ));
1170    }
1171
1172    #[test]
1173    fn a_strip_pointing_outside_the_file_is_refused() {
1174        // Fail closed: an out-of-range strip means the image data cannot be copied, and
1175        // emitting a TIFF without it would be handing back something that is not the file.
1176        let mut input = build(&[page(structural(), b"SYNTHETIC-PIXELS")]);
1177        let pixels_at = input
1178            .windows(16)
1179            .position(|w| w == b"SYNTHETIC-PIXELS")
1180            .expect("pixels not found");
1181        let needle = u32::try_from(pixels_at).unwrap().to_le_bytes();
1182        let at = input
1183            .windows(4)
1184            .position(|w| w == needle)
1185            .expect("strip offset not found");
1186        input[at..at + 4].copy_from_slice(&0xFFFF_0000u32.to_le_bytes());
1187        assert!(TiffHandler.strip(&input, &StripOptions::default()).is_err());
1188    }
1189
1190    #[test]
1191    fn a_truncated_file_is_refused_rather_than_completed() {
1192        let input = build(&[page(structural(), b"SYNTHETIC-PIXELS")]);
1193        for cut in [4, 8, 12, 30, input.len() / 2] {
1194            let _ = TiffHandler.strip(&input[..cut], &StripOptions::default());
1195        }
1196    }
1197}