Skip to main content

strypt_core/formats/
gif.rs

1//! GIF.
2//!
3//! The format a screen recording, a leaked chat clip, or a reaction image arrives in — and the
4//! one whose metadata people are least likely to suspect exists, because a GIF looks like a toy.
5//! It is not: `ImageMagick` writes whole 8BIM and IPTC blocks into GIFs as application
6//! extensions, Adobe writes XMP packets into them, and a comment extension will hold whatever the
7//! producing tool felt like putting there, including a filename or an author.
8//!
9//! # Block surgery, never re-encoding
10//!
11//! A GIF is a fixed header, a logical screen descriptor, an optional global colour table, and
12//! then a flat sequence of blocks terminated by a single `0x3B` byte (`GIF89a` §17–§27). The
13//! picture lives in image blocks; everything identifying lives in extension blocks beside them.
14//! This handler walks that sequence, drops the extensions that carry metadata, and copies
15//! everything else through **as raw bytes**. A clean file therefore strips to a byte-identical
16//! copy of itself, and idempotence follows from the design rather than from a test passing —
17//! the same property PNG has, and the opposite of TIFF, which is rebuilt because its metadata
18//! *is* its structure (ADR-0033).
19//!
20//! The LZW-compressed image data is never decoded. Nothing here needs to know what the picture
21//! looks like in order to know that a comment extension is not part of it.
22//!
23//! # The application extension is an allow-list, and that direction is deliberate
24//!
25//! An application extension declares an eleven-byte identifier and then carries arbitrary
26//! payload (§26). Two of those identifiers are not metadata at all: `NETSCAPE2.0` and its older
27//! spelling `ANIMEXTS1.0` carry the **loop count**, which is why an animation repeats instead of
28//! playing once. Dropping them would silently change what the user's file *does* — a visible
29//! payload change, which `docs/PRD.md` §8.1 forbids.
30//!
31//! So those two are copied through and every other application extension is removed, including
32//! ones this code has never heard of. Running the rule the other way — deny a list of known-bad
33//! identifiers — would carry an unknown vendor block through precisely because it was unknown,
34//! which is the failure the TIFF allow-list exists to prevent and is no less a failure here.
35//!
36//! # A plain-text extension takes its graphic control block with it
37//!
38//! A graphic control extension applies to *the next graphic-rendering block* (§23), setting its
39//! delay, its disposal method, and its transparent colour index. Removing a plain-text extension
40//! while leaving the control block in front of it would hand that block's timing to the next
41//! image instead, which is a rendering change nobody asked for. The pair is removed together.
42
43use crate::bytes::Reader;
44use crate::detect::Format;
45use crate::error::{MalformedDetail, ResourceLimit, Result, StryptError};
46use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, xmp};
47use crate::report::{
48    Finding, InspectOptions, MetadataKind, MetadataReport, MetadataValue, Note, Retained,
49    RetentionReason, StripReport,
50};
51
52/// Removal of metadata from GIF images.
53#[derive(Debug, Clone, Copy, Default)]
54pub struct GifHandler;
55
56impl MetadataHandler for GifHandler {
57    fn name(&self) -> &'static str {
58        Format::Gif.id()
59    }
60
61    fn format(&self) -> Format {
62        Format::Gif
63    }
64
65    fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
66        // Inspection runs the identical pass that stripping does and discards the output, so
67        // "everything `strip` removes is something `inspect` can see" holds by construction
68        // rather than by two code paths agreeing to stay in step — which is what makes the
69        // pipeline's verification pass mean anything (`docs/ARCHITECTURE.md` §3).
70        let processed = process(input, options, &ParseLimits::default())?;
71        Ok(MetadataReport {
72            format: Format::Gif,
73            findings: processed.findings,
74            notes: processed.notes,
75        })
76    }
77
78    fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
79        let processed = process(input, &options.inspect, &options.limits)?;
80        Ok(Stripped {
81            report: StripReport {
82                format: Format::Gif,
83                removed: processed.findings,
84                retained: processed.retained,
85                notes: processed.notes,
86                input_bytes: as_u64(input.len()),
87                output_bytes: as_u64(processed.output.len()),
88            },
89            bytes: processed.output,
90        })
91    }
92}
93
94/// The two signatures §17 permits. `87a` predates extensions entirely; files spelling it while
95/// carrying them are common, and are handled the same way rather than refused on a version
96/// string no decoder enforces either.
97const SIGNATURES: [&[u8; 6]; 2] = [b"GIF87a", b"GIF89a"];
98
99/// End of the block sequence (§27).
100const TRAILER: u8 = 0x3B;
101/// Introduces an extension block (§23–§26).
102const EXTENSION_INTRODUCER: u8 = 0x21;
103/// Introduces an image descriptor (§20).
104const IMAGE_SEPARATOR: u8 = 0x2C;
105
106/// Extension labels §23–§26 define. Everything else is unrecognised and is removed.
107const LABEL_PLAIN_TEXT: u8 = 0x01;
108const LABEL_GRAPHIC_CONTROL: u8 = 0xF9;
109const LABEL_COMMENT: u8 = 0xFE;
110const LABEL_APPLICATION: u8 = 0xFF;
111
112/// Length of the eleven-byte application identifier and authentication code (§26).
113const APPLICATION_IDENTIFIER_LEN: usize = 11;
114
115/// The application extensions that are kept, because they are rendering instructions rather than
116/// metadata.
117///
118/// Both carry a loop count and nothing else. `NETSCAPE2.0` is what every tool writes today;
119/// `ANIMEXTS1.0` is the earlier spelling of the same thing, still found in older files. Neither
120/// names a person, a device, a place, or a time, and neither differs between two files that loop
121/// — so there is nothing in them to identify anyone with, and removing them would stop an
122/// animation looping.
123const LOOP_EXTENSIONS: [&[u8; APPLICATION_IDENTIFIER_LEN]; 2] = [b"NETSCAPE2.0", b"ANIMEXTS1.0"];
124
125/// The identifier under which XMP is carried, per the XMP specification part 3 §1.1.2.
126///
127/// Its payload is **not** really a sub-block chain: the packet's own bytes are laid down raw and
128/// a 258-byte magic trailer makes the length bytes fall where the chain needs them. Walking it as
129/// an ordinary chain still finds the end, which is all this handler needs — and scanning the raw
130/// span still finds the property names, because they are intact in those bytes.
131const XMP_IDENTIFIER: &[u8] = b"XMP DataXMP";
132
133/// Application identifiers worth classifying by name, so the report ranks them the way the threat
134/// model does rather than filing everything under "an application extension".
135///
136/// These are what real tools actually write. `ImageMagick` puts whole Photoshop 8BIM and IPTC
137/// blocks in here — the IPTC one carries a by-line, which is a person's name.
138const APPLICATION_KINDS: &[(&[u8], MetadataKind)] = &[
139    (b"ICCRGBG1012", MetadataKind::ColourProfile),
140    (b"MGK8BIM0000", MetadataKind::SoftwareFingerprint),
141    (b"MGKIPTC0000", MetadataKind::PersonalIdentity),
142    (b"ImageMagick", MetadataKind::SoftwareFingerprint),
143    (b"Adobe Gif", MetadataKind::SoftwareFingerprint),
144];
145
146/// One block, and the exact bytes it occupied.
147struct Block<'a> {
148    kind: BlockKind,
149    /// The sub-block chain's raw span, without the terminating zero byte. Empty for an image
150    /// block, whose payload this handler never looks inside.
151    body: &'a [u8],
152    /// The first sub-block's contents, which is where an extension puts its fixed-shape fields —
153    /// for an application extension, the eleven-byte identifier.
154    head: &'a [u8],
155    /// The whole block as it appeared. Kept blocks are written out from this, which is what makes
156    /// the copy exact.
157    raw: &'a [u8],
158}
159
160/// What sort of block it is.
161#[derive(Clone, Copy, PartialEq, Eq)]
162enum BlockKind {
163    /// An extension, with its label byte.
164    Extension(u8),
165    /// An image descriptor and its compressed data.
166    Image,
167}
168
169/// The result of one pass over a file: what was found, and what the sanitised file looks like.
170struct Processed {
171    findings: Vec<Finding>,
172    retained: Vec<Retained>,
173    notes: Vec<Note>,
174    output: Vec<u8>,
175}
176
177/// Split `input` into its fixed prefix, its blocks, and anything after the trailer.
178///
179/// Every length in the file was chosen by whoever made it, so every one is read through
180/// [`Reader`] and every failure is a typed error rather than a panic. A file that does not parse
181/// is refused whole: there is no path here that returns a partial block list for a caller to
182/// strip and write out.
183fn walk<'a>(input: &'a [u8], limits: &ParseLimits) -> Result<(&'a [u8], Vec<Block<'a>>, &'a [u8])> {
184    let mut r = Reader::new(input);
185    let signature = r
186        .take(6)
187        .ok_or_else(|| malformed(MalformedDetail::Truncated, Some(0)))?;
188    if !SIGNATURES.iter().any(|candidate| *candidate == signature) {
189        return Err(malformed(MalformedDetail::MissingMarker, Some(0)));
190    }
191
192    // Logical screen descriptor: width, height, a packed field, the background colour index, and
193    // the pixel aspect ratio (§18). Seven bytes, always present.
194    let descriptor = r
195        .take(7)
196        .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(6)))?;
197    // Bit 7 of the packed field says a global colour table follows; bits 0–2 give its size as
198    // 3 × 2^(N+1) bytes (§18). The table is part of the picture, not of its metadata.
199    let packed = descriptor.get(4).copied().unwrap_or_default();
200    if packed & 0x80 != 0 {
201        let entries = colour_table_bytes(packed);
202        r.skip(entries)
203            .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(13)))?;
204    }
205    let prefix = input.get(0..r.position()).unwrap_or_default();
206
207    let mut blocks: Vec<Block<'a>> = Vec::new();
208    let mut budget = limits.max_items;
209
210    loop {
211        let start = r.position();
212        let introducer = r
213            .u8()
214            .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
215        if introducer == TRAILER {
216            break;
217        }
218        spend(&mut budget)?;
219
220        let (kind, head, body) = match introducer {
221            EXTENSION_INTRODUCER => {
222                let label = r
223                    .u8()
224                    .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
225                let (head, body) = sub_blocks(&mut r, input, &mut budget, start)?;
226                (BlockKind::Extension(label), head, body)
227            }
228            IMAGE_SEPARATOR => {
229                // Image descriptor: position, size, and a packed field (§20).
230                let descriptor = r
231                    .take(9)
232                    .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
233                let packed = descriptor.get(8).copied().unwrap_or_default();
234                if packed & 0x80 != 0 {
235                    // A local colour table, sized the same way the global one is (§20).
236                    r.skip(colour_table_bytes(packed)).ok_or_else(|| {
237                        malformed(MalformedDetail::LengthOutOfRange, as_offset(start))
238                    })?;
239                }
240                // The LZW minimum code size, then the compressed data as a sub-block chain
241                // (§22). Never decoded: nothing here needs to know what the picture shows.
242                r.skip(1)
243                    .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
244                let _ = sub_blocks(&mut r, input, &mut budget, start)?;
245                (BlockKind::Image, &[][..], &[][..])
246            }
247            _ => {
248                // §17 admits exactly three things here. Anything else means the walk is no longer
249                // where it thinks it is, and continuing would be slicing arbitrary bytes out of a
250                // file while reporting confidently about them.
251                return Err(malformed(
252                    MalformedDetail::UnexpectedMarker,
253                    as_offset(start),
254                ));
255            }
256        };
257
258        blocks.push(Block {
259            kind,
260            body,
261            head,
262            raw: input.get(start..r.position()).unwrap_or_default(),
263        });
264    }
265
266    Ok((prefix, blocks, r.take_rest()))
267}
268
269/// Size of a colour table in bytes, from the packed field that declares it: 3 × 2^(N+1) (§18).
270///
271/// `N` is three bits, so the largest table is 768 bytes and this cannot overflow.
272fn colour_table_bytes(packed: u8) -> usize {
273    let n = u32::from(packed & 0b0000_0111);
274    3usize.saturating_mul(1usize << n.saturating_add(1))
275}
276
277/// Walk a sub-block chain: a length byte, that many bytes, repeated until a zero length (§15).
278///
279/// Returns the first sub-block's contents and the chain's whole span excluding the terminator.
280/// Each sub-block is charged against the item budget, so a file that is nothing but a very long
281/// chain is bounded like everything else.
282fn sub_blocks<'a>(
283    r: &mut Reader<'a>,
284    input: &'a [u8],
285    budget: &mut u32,
286    block_start: usize,
287) -> Result<(&'a [u8], &'a [u8])> {
288    let span_start = r.position();
289    let mut head: &[u8] = &[];
290    loop {
291        spend(budget)?;
292        let at = r.position();
293        let length = r
294            .u8()
295            .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(block_start)))?;
296        if length == 0 {
297            let span = input.get(span_start..at).unwrap_or_default();
298            return Ok((head, span));
299        }
300        let data = r
301            .take(usize::from(length))
302            .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(at)))?;
303        if head.is_empty() {
304            head = data;
305        }
306    }
307}
308
309/// Charge one structural item against the budget.
310fn spend(budget: &mut u32) -> Result<()> {
311    if *budget == 0 {
312        return Err(StryptError::LimitExceeded {
313            format: Format::Gif,
314            limit: ResourceLimit::ItemCount,
315        });
316    }
317    *budget = budget.saturating_sub(1);
318    Ok(())
319}
320
321/// What to do with one block.
322enum Outcome {
323    /// Copy it through unchanged.
324    Keep,
325    /// Remove it entirely.
326    Drop,
327}
328
329/// A decision about one block, with what to tell the user about it.
330struct Decision {
331    outcome: Outcome,
332    findings: Vec<Finding>,
333    /// Anything kept on purpose. Separate from `findings` because the verification pass requires
334    /// that nothing `inspect` reports as a finding survives a strip — a block that is deliberately
335    /// kept has to be declared, not reported as removed.
336    retained: Vec<Retained>,
337}
338
339impl Decision {
340    const fn keep() -> Self {
341        Self {
342            outcome: Outcome::Keep,
343            findings: Vec::new(),
344            retained: Vec::new(),
345        }
346    }
347
348    /// Copy the block through, and say in the report that it was a deliberate choice.
349    fn kept_on_purpose(location: &'static str, reason: RetentionReason) -> Self {
350        Self {
351            outcome: Outcome::Keep,
352            findings: Vec::new(),
353            retained: vec![Retained {
354                location: location.to_owned(),
355                reason,
356            }],
357        }
358    }
359
360    fn drop_with(findings: Vec<Finding>) -> Self {
361        Self {
362            outcome: Outcome::Drop,
363            findings,
364            retained: Vec::new(),
365        }
366    }
367
368    /// Removed, with nothing to report about it: the graphic control block that belonged to a
369    /// plain-text extension going out with it. It is not metadata, so it is not a finding; it
370    /// cannot stay, because it would retime the next image.
371    const fn drop_silently() -> Self {
372        Self {
373            outcome: Outcome::Drop,
374            findings: Vec::new(),
375            retained: Vec::new(),
376        }
377    }
378}
379
380/// Walk `input`, decide about every block, and build the sanitised file.
381fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
382    let (prefix, blocks, trailing) = walk(input, limits)?;
383    let mut out = Processed {
384        findings: Vec::new(),
385        retained: Vec::new(),
386        notes: Vec::new(),
387        output: Vec::with_capacity(input.len()),
388    };
389    out.output.extend_from_slice(prefix);
390
391    for (index, block) in blocks.iter().enumerate() {
392        let next = blocks.get(index.saturating_add(1));
393        let decision = decide(block, next, options);
394        out.retained.extend(decision.retained);
395        match decision.outcome {
396            Outcome::Keep => out.output.extend_from_slice(block.raw),
397            Outcome::Drop => out.findings.extend(decision.findings),
398        }
399    }
400    out.output.push(TRAILER);
401
402    if !trailing.is_empty() {
403        // Nothing reads past the trailer, and few users know anything can be there. It is a
404        // convenient place to keep a second copy of an image whose visible version was cropped.
405        let kind = if SIGNATURES
406            .iter()
407            .any(|signature| trailing.starts_with(signature.as_slice()))
408        {
409            MetadataKind::Thumbnail
410        } else {
411            MetadataKind::Other
412        };
413        out.findings.push(Finding::new(
414            kind,
415            "trailing data after the trailer",
416            as_u64(trailing.len()),
417        ));
418    }
419
420    Ok(out)
421}
422
423/// Decide about one block. `next` is the block that follows it, which a graphic control extension
424/// needs in order to know what it is controlling.
425fn decide(block: &Block<'_>, next: Option<&Block<'_>>, options: &InspectOptions) -> Decision {
426    let size = as_u64(block.body.len());
427    match block.kind {
428        BlockKind::Image => Decision::keep(),
429        BlockKind::Extension(LABEL_GRAPHIC_CONTROL) => {
430            // Delay, disposal method, and transparent colour index: rendering, not identity. It
431            // goes only when the graphic it controls goes (§23).
432            if matches!(
433                next.map(|b| b.kind),
434                Some(BlockKind::Extension(LABEL_PLAIN_TEXT))
435            ) {
436                return Decision::drop_silently();
437            }
438            Decision::keep()
439        }
440        BlockKind::Extension(LABEL_COMMENT) => Decision::drop_with(vec![
441            Finding::new(MetadataKind::Comment, "Comment Extension", size)
442                .with_field("Comment")
443                .with_value(options, || MetadataValue::Text(xmp::name_of(block.body))),
444        ]),
445        BlockKind::Extension(LABEL_PLAIN_TEXT) => Decision::drop_with(vec![
446            Finding::new(MetadataKind::Comment, "Plain Text Extension", size)
447                .with_field("PlainText"),
448        ]),
449        BlockKind::Extension(LABEL_APPLICATION) => application(block, size, options),
450        BlockKind::Extension(label) => {
451            // §23–§26 define four labels and no more. A block under any other label was put there
452            // by something whose intentions this code cannot know, and a scrubber that copies
453            // through what it does not understand is not scrubbing.
454            Decision::drop_with(vec![Finding::new(
455                MetadataKind::Other,
456                format!("Extension 0x{label:02X}"),
457                size,
458            )])
459        }
460    }
461}
462
463/// An application extension: eleven bytes of identifier, then whatever that application wanted.
464fn application(block: &Block<'_>, size: u64, options: &InspectOptions) -> Decision {
465    let identifier = block
466        .head
467        .get(0..APPLICATION_IDENTIFIER_LEN)
468        .unwrap_or(block.head);
469
470    if LOOP_EXTENSIONS
471        .iter()
472        .any(|candidate| candidate.as_slice() == identifier)
473    {
474        // The loop count. See this module's header for why an allow-list of exactly two entries
475        // is the right shape here.
476        return Decision::kept_on_purpose(
477            "Application Extension (loop count)",
478            RetentionReason::RemovalWouldAlterPayload,
479        );
480    }
481
482    if identifier == XMP_IDENTIFIER {
483        return Decision::drop_with(xmp::scan(
484            block.body,
485            "Application Extension (XMP)",
486            options,
487        ));
488    }
489
490    let kind = APPLICATION_KINDS
491        .iter()
492        .find(|(candidate, _)| *candidate == identifier)
493        .map_or(MetadataKind::Other, |(_, kind)| *kind);
494    Decision::drop_with(vec![
495        Finding::new(kind, "Application Extension", size).with_field(xmp::name_of(identifier)),
496    ])
497}
498
499/// A malformed-file error for this format.
500fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
501    StryptError::Malformed {
502        format: Format::Gif,
503        offset,
504        detail,
505    }
506}
507
508/// A byte position as a reportable offset.
509fn as_offset(position: usize) -> Option<u64> {
510    u64::try_from(position).ok()
511}
512
513/// Widen a length for reporting. Saturating: a report field is not worth failing a strip over.
514fn as_u64(value: usize) -> u64 {
515    u64::try_from(value).unwrap_or(u64::MAX)
516}
517
518#[cfg(test)]
519mod tests {
520    // Test code is never reachable from untrusted bytes, which is the boundary the
521    // panic-freedom lints exist to police (ADR-0006).
522    #![allow(
523        clippy::unwrap_used,
524        clippy::expect_used,
525        clippy::indexing_slicing,
526        clippy::arithmetic_side_effects
527    )]
528
529    use super::*;
530
531    /// A sub-block chain: 255 bytes at a time, then the terminating zero (§15).
532    fn chain(payload: &[u8]) -> Vec<u8> {
533        let mut out = Vec::new();
534        for part in payload.chunks(255) {
535            out.push(u8::try_from(part.len()).unwrap());
536            out.extend_from_slice(part);
537        }
538        out.push(0);
539        out
540    }
541
542    fn extension(label: u8, payload: &[u8]) -> Vec<u8> {
543        let mut out = vec![EXTENSION_INTRODUCER, label];
544        out.extend_from_slice(&chain(payload));
545        out
546    }
547
548    /// An application extension: the eleven-byte identifier as its own sub-block, then the data.
549    fn application_extension(identifier: &[u8], data: &[u8]) -> Vec<u8> {
550        let mut out = vec![EXTENSION_INTRODUCER, LABEL_APPLICATION, 11];
551        out.extend_from_slice(identifier);
552        out.extend_from_slice(&chain(data));
553        out
554    }
555
556    /// A one-pixel image block: descriptor, LZW minimum code size, and its data.
557    fn image() -> Vec<u8> {
558        let mut out = vec![IMAGE_SEPARATOR];
559        out.extend_from_slice(&[0, 0, 0, 0, 1, 0, 1, 0, 0]);
560        out.push(2);
561        out.extend_from_slice(&chain(b"SYNTHETIC-PIXELS"));
562        out
563    }
564
565    /// A graphic control extension: four bytes of disposal, delay, and transparency (§23).
566    fn graphic_control() -> Vec<u8> {
567        extension(LABEL_GRAPHIC_CONTROL, &[0x04, 0x0A, 0x00, 0x00])
568    }
569
570    /// Header, logical screen descriptor without a colour table, the given blocks, and the
571    /// trailer.
572    fn gif(blocks: &[Vec<u8>]) -> Vec<u8> {
573        let mut out = b"GIF89a".to_vec();
574        out.extend_from_slice(&[1, 0, 1, 0, 0x00, 0, 0]);
575        for block in blocks {
576            out.extend_from_slice(block);
577        }
578        out.push(TRAILER);
579        out
580    }
581
582    fn strip_ok(data: &[u8]) -> Stripped {
583        GifHandler
584            .strip(data, &StripOptions::default())
585            .expect("strip failed")
586    }
587
588    fn findings(data: &[u8]) -> Vec<Finding> {
589        GifHandler
590            .inspect(data, &InspectOptions::names_only())
591            .expect("inspect failed")
592            .findings
593    }
594
595    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
596        haystack.windows(needle.len()).any(|w| w == needle)
597    }
598
599    #[test]
600    fn the_picture_is_never_touched() {
601        let input = gif(&[extension(LABEL_COMMENT, b"SYNTHETIC-COMMENT-0001"), image()]);
602        let output = strip_ok(&input).bytes;
603        assert!(
604            contains(&output, b"SYNTHETIC-PIXELS"),
605            "the compressed image data did not survive byte for byte"
606        );
607    }
608
609    #[test]
610    fn a_clean_file_strips_to_a_byte_identical_copy() {
611        // Stronger than "no findings": kept blocks are copied raw, so nothing is re-serialised
612        // and a file that had nothing wrong with it comes back unchanged. TIFF cannot make this
613        // promise (ADR-0033); a block-list format can, and so it must.
614        let input = gif(&[image()]);
615        let stripped = strip_ok(&input);
616        assert!(stripped.report.removed.is_empty());
617        assert_eq!(stripped.bytes, input);
618    }
619
620    #[test]
621    fn a_global_colour_table_is_carried_across() {
622        // The table is the picture's palette. Losing it would leave the image undecodable, which
623        // is the failure direction the TIFF allow-list has to watch for as well.
624        let mut input = b"GIF89a".to_vec();
625        input.extend_from_slice(&[1, 0, 1, 0, 0x80, 0, 0]); // global table, two entries
626        input.extend_from_slice(&[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]);
627        input.extend_from_slice(&extension(LABEL_COMMENT, b"SYNTHETIC-COMMENT-0002"));
628        input.extend_from_slice(&image());
629        input.push(TRAILER);
630
631        let output = strip_ok(&input).bytes;
632        assert!(contains(&output, &[0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF]));
633        assert!(!contains(&output, b"SYNTHETIC-COMMENT-0002"));
634    }
635
636    #[test]
637    fn a_comment_is_reported_and_removed_and_its_text_withheld_by_default() {
638        let input = gif(&[extension(LABEL_COMMENT, b"SYNTHETIC-COMMENT-0003"), image()]);
639        let found = findings(&input);
640        assert_eq!(found[0].kind, MetadataKind::Comment);
641        assert_eq!(found[0].field.as_deref(), Some("Comment"));
642        assert_eq!(
643            found[0].value, None,
644            "a default inspection withholds values"
645        );
646
647        let with_values = GifHandler
648            .inspect(&input, &InspectOptions::with_values())
649            .unwrap();
650        assert_eq!(
651            with_values.findings[0].value,
652            Some(MetadataValue::Text("SYNTHETIC-COMMENT-0003".to_owned()))
653        );
654        assert!(!contains(
655            &strip_ok(&input).bytes,
656            b"SYNTHETIC-COMMENT-0003"
657        ));
658    }
659
660    #[test]
661    fn the_loop_extension_survives_and_says_so() {
662        // The decision this handler turns on: a looping animation must still loop. The block
663        // carries a loop count and nothing else, so there is nothing in it to identify anyone
664        // with — and removing it would change what the user's file does.
665        let input = gif(&[
666            application_extension(b"NETSCAPE2.0", &[0x01, 0x00, 0x00]),
667            image(),
668        ]);
669        let stripped = strip_ok(&input);
670        assert!(contains(&stripped.bytes, b"NETSCAPE2.0"));
671        assert!(stripped.report.removed.is_empty());
672        assert_eq!(
673            stripped.report.retained[0].location,
674            "Application Extension (loop count)"
675        );
676    }
677
678    #[test]
679    fn the_older_loop_spelling_survives_too() {
680        let input = gif(&[
681            application_extension(b"ANIMEXTS1.0", &[0x01, 0x00, 0x00]),
682            image(),
683        ]);
684        assert!(contains(&strip_ok(&input).bytes, b"ANIMEXTS1.0"));
685    }
686
687    #[test]
688    fn an_unknown_application_extension_does_not_survive_by_being_unknown() {
689        // The allow-list's direction, and the reason for it. Under a deny-list a vendor block
690        // nobody has a name for survives precisely because nothing recognised it.
691        let input = gif(&[
692            application_extension(b"VENDORX1.0\0", b"SYNTHETIC-VENDOR-0004"),
693            image(),
694        ]);
695        let found = findings(&input);
696        assert_eq!(found[0].kind, MetadataKind::Other);
697        assert_eq!(found[0].location, "Application Extension");
698        assert!(!contains(&strip_ok(&input).bytes, b"SYNTHETIC-VENDOR-0004"));
699    }
700
701    #[test]
702    fn an_imagemagick_iptc_block_is_ranked_as_naming_a_person() {
703        // `MGKIPTC0000` carries an IPTC record, and its by-line field is somebody's name.
704        let input = gif(&[
705            application_extension(b"MGKIPTC0000", b"\x1c\x02\x50SYNTHETIC-BYLINE-0005"),
706            image(),
707        ]);
708        assert_eq!(findings(&input)[0].kind, MetadataKind::PersonalIdentity);
709        assert!(!contains(&strip_ok(&input).bytes, b"SYNTHETIC-BYLINE-0005"));
710    }
711
712    #[test]
713    fn an_xmp_packet_is_itemised_by_property() {
714        // XMP in a GIF is laid down raw with a magic trailer that makes the packet's own bytes
715        // serve as sub-block lengths (XMP part 3 §1.1.2). Scanning the raw span still finds the
716        // property names, because they are intact in those bytes.
717        let mut packet = b"<x:xmpmeta><dc:creator>SYNTHETIC-XMP-0006</dc:creator>".to_vec();
718        packet.extend_from_slice(b"<xmp:CreatorTool>SYNTHETIC-TOOL</xmp:CreatorTool></x:xmpmeta>");
719        let input = gif(&[application_extension(b"XMP DataXMP", &packet), image()]);
720
721        let fields: Vec<String> = findings(&input)
722            .into_iter()
723            .filter_map(|f| f.field)
724            .collect();
725        assert!(fields.iter().any(|f| f == "dc:creator"));
726        assert!(fields.iter().any(|f| f == "xmp:CreatorTool"));
727        assert!(!contains(&strip_ok(&input).bytes, b"SYNTHETIC-XMP-0006"));
728    }
729
730    #[test]
731    fn a_graphic_control_block_stays_with_its_image_and_goes_with_its_plain_text() {
732        // §23: the block applies to whatever graphic follows it. Leaving one in front of an image
733        // it was never meant for would hand that image somebody else's delay and transparency.
734        let kept = gif(&[graphic_control(), image()]);
735        assert_eq!(strip_ok(&kept).bytes, kept);
736
737        let mut plain_text = vec![EXTENSION_INTRODUCER, LABEL_PLAIN_TEXT, 12];
738        plain_text.extend_from_slice(&[0; 12]);
739        plain_text.extend_from_slice(&chain(b"SYNTHETIC-PLAINTEXT-0007"));
740        let input = gif(&[graphic_control(), plain_text, image()]);
741
742        let stripped = strip_ok(&input);
743        assert!(!contains(&stripped.bytes, b"SYNTHETIC-PLAINTEXT-0007"));
744        assert_eq!(
745            stripped.bytes,
746            gif(&[image()]),
747            "the orphaned graphic control block was left behind"
748        );
749        assert_eq!(stripped.report.removed.len(), 1);
750        assert_eq!(stripped.report.removed[0].location, "Plain Text Extension");
751    }
752
753    #[test]
754    fn an_extension_under_an_undefined_label_is_removed() {
755        let input = gif(&[extension(0x42, b"SYNTHETIC-UNKNOWN-0008"), image()]);
756        let found = findings(&input);
757        assert_eq!(found[0].location, "Extension 0x42");
758        assert!(!contains(
759            &strip_ok(&input).bytes,
760            b"SYNTHETIC-UNKNOWN-0008"
761        ));
762    }
763
764    #[test]
765    fn data_hidden_after_the_trailer_is_removed() {
766        let mut input = gif(&[image()]);
767        input.extend_from_slice(b"SYNTHETIC-APPENDED-0009");
768        let stripped = strip_ok(&input);
769        assert!(!contains(&stripped.bytes, b"SYNTHETIC-APPENDED-0009"));
770        assert_eq!(
771            stripped.report.removed[0].location,
772            "trailing data after the trailer"
773        );
774    }
775
776    #[test]
777    fn a_second_image_after_the_trailer_is_reported_as_a_thumbnail() {
778        let mut input = gif(&[image()]);
779        input.extend_from_slice(&gif(&[image()]));
780        assert_eq!(
781            strip_ok(&input).report.removed[0].kind,
782            MetadataKind::Thumbnail
783        );
784    }
785
786    #[test]
787    fn stripping_twice_changes_nothing() {
788        let input = gif(&[
789            extension(LABEL_COMMENT, b"SYNTHETIC-COMMENT-0010"),
790            application_extension(b"NETSCAPE2.0", &[0x01, 0x00, 0x00]),
791            graphic_control(),
792            image(),
793        ]);
794        let once = strip_ok(&input).bytes;
795        let twice = strip_ok(&once).bytes;
796        assert_eq!(once, twice, "strip is not idempotent");
797    }
798
799    #[test]
800    fn a_file_without_a_trailer_is_refused() {
801        // Fail closed. Completing a damaged file would hand the user something that is not what
802        // they gave us, presented as a clean version of it.
803        let input = gif(&[image()]);
804        let truncated = &input[0..input.len() - 1];
805        assert!(matches!(
806            GifHandler.strip(truncated, &StripOptions::default()),
807            Err(StryptError::Malformed {
808                detail: MalformedDetail::Truncated,
809                ..
810            })
811        ));
812    }
813
814    #[test]
815    fn a_block_introducer_the_format_does_not_define_is_refused() {
816        let input = gif(&[vec![0x99, 0x00]]);
817        assert!(matches!(
818            GifHandler.inspect(&input, &InspectOptions::names_only()),
819            Err(StryptError::Malformed {
820                detail: MalformedDetail::UnexpectedMarker,
821                ..
822            })
823        ));
824    }
825
826    #[test]
827    fn a_sub_block_length_running_past_the_end_of_the_file_is_refused() {
828        let mut input = gif(&[extension(LABEL_COMMENT, b"short"), image()]);
829        // The comment's first length byte sits directly after the introducer and label.
830        let at = 13 + 2;
831        input[at] = 0xFF;
832        assert!(matches!(
833            GifHandler.inspect(&input, &InspectOptions::names_only()),
834            Err(StryptError::Malformed {
835                detail: MalformedDetail::LengthOutOfRange,
836                ..
837            })
838        ));
839    }
840
841    #[test]
842    fn a_header_that_is_not_a_gif_signature_is_refused() {
843        assert!(matches!(
844            GifHandler.inspect(
845                b"GIF88a\x01\x00\x01\x00\x00\x00\x00\x3B",
846                &InspectOptions::names_only()
847            ),
848            Err(StryptError::Malformed {
849                detail: MalformedDetail::MissingMarker,
850                ..
851            })
852        ));
853    }
854
855    #[test]
856    fn a_block_count_beyond_the_limit_is_refused() {
857        let mut blocks: Vec<Vec<u8>> = (0..64).map(|_| extension(LABEL_COMMENT, b"x")).collect();
858        blocks.push(image());
859        let input = gif(&blocks);
860        let options = StripOptions {
861            limits: ParseLimits {
862                max_items: 8,
863                ..ParseLimits::default()
864            },
865            ..StripOptions::default()
866        };
867        assert!(matches!(
868            GifHandler.strip(&input, &options),
869            Err(StryptError::LimitExceeded { .. })
870        ));
871    }
872
873    #[test]
874    fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
875        let input = gif(&[
876            extension(LABEL_COMMENT, b"SYNTHETIC-COMMENT-0011"),
877            application_extension(b"XMP DataXMP", b"<x:xmpmeta><dc:creator>x</dc:creator>"),
878            graphic_control(),
879            image(),
880        ]);
881        for n in 0..=input.len() {
882            let prefix = &input[0..n];
883            let _ = GifHandler.inspect(prefix, &InspectOptions::names_only());
884            let _ = GifHandler.strip(prefix, &StripOptions::default());
885        }
886    }
887}