Skip to main content

strypt_core/formats/
webp.rs

1//! WebP.
2//!
3//! A RIFF container, so structurally the closest thing in this crate to the PNG handler: a
4//! flat list of chunks, each with a four-character code and its own length, walked once and
5//! filtered. Where PNG puts its metadata in `tEXt`, `zTXt`, `iTXt`, `tIME`, `eXIf`, and
6//! `iCCP`, WebP puts all of it in exactly three chunks — `ICCP`, `EXIF`, and `XMP ` — plus
7//! whatever a producer left in an unknown chunk.
8//!
9//! Everything here follows RFC 9649, which is the WebP container's authoritative
10//! specification (verified 2026-08-19); section numbers below refer to it.
11//!
12//! # Chunk surgery, never re-encoding, and no checksums anywhere
13//!
14//! Kept chunks are copied through **as raw bytes** — code, length, payload, and RIFF padding
15//! byte verbatim. WebP carries no per-chunk CRC at all (§2.3), so unlike PNG there is not even
16//! a checksum to preserve, and the only field in the whole file that has to be recomputed is
17//! the RIFF chunk's own size. A file with nothing to remove therefore strips to a
18//! byte-identical copy of itself, and idempotence follows from the design rather than from a
19//! test passing.
20//!
21//! # `VP8X` is the one chunk this handler rewrites
22//!
23//! An extended-format file opens with a `VP8X` chunk whose flags byte declares which optional
24//! parts the file has: an ICC profile, an alpha channel, Exif metadata, XMP metadata, an
25//! animation (§2.7, Figure 7). Remove the `ICCP`, `EXIF`, or `XMP ` chunk and leave the
26//! matching bit set, and the file now lies about itself — some decoders warn, some refuse.
27//!
28//! So those three bits are cleared, and nothing else in the chunk is touched: the alpha and
29//! animation bits, the reserved bits, and the canvas dimensions are copied byte for byte
30//! (ADR-0023). This is the same trade the JPEG handler already makes when it rewrites `APP0`
31//! to zero a thumbnail's dimensions (ADR-0021) — a kept structure is corrected rather than
32//! left inconsistent with what was removed. A `VP8X` whose metadata bits are already clear is
33//! copied through untouched, so the rewrite happens only where it changes something.
34//!
35//! # No decompressor, again
36//!
37//! Nothing WebP puts metadata in is compressed at the container level: `ICCP` holds a profile,
38//! `EXIF` holds a TIFF block the shared reader in [`crate::formats::exif`] handles directly,
39//! and `XMP ` holds a plain XML packet. As with PNG (ADR-0022), `strypt-core` gains no
40//! dependency and no inflate path for this format.
41//!
42//! # What is checked, and what is not
43//!
44//! Every length in the file was chosen by whoever made it, so every one is read through
45//! [`crate::container::riff`] and every failure is a typed error rather than a panic. The
46//! declared RIFF size bounds the walk: bytes beyond it are trailing data and go, and a RIFF size
47//! that runs past the end of the file is a lie and the file is refused rather than clamped.
48//!
49//! The chunk walk itself is not here — it moved to [`crate::container::riff`] when WAV became its
50//! second caller (ADR-0039). What stays is everything that is WebP rather than RIFF: the form
51//! type, `VP8X`'s fixed length, the shape check, the flag correction, and `ANMF`.
52
53use crate::container::riff::{self, Chunk, WalkError, name_of};
54use crate::detect::Format;
55use crate::error::{MalformedDetail, Result, StryptError};
56use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, exif, xmp};
57use crate::report::{
58    Finding, InspectOptions, MetadataKind, MetadataReport, Note, Retained, StripReport,
59};
60
61/// Removal of metadata from WebP images.
62#[derive(Debug, Clone, Copy, Default)]
63pub struct WebpHandler;
64
65impl MetadataHandler for WebpHandler {
66    fn name(&self) -> &'static str {
67        Format::Webp.id()
68    }
69
70    fn format(&self) -> Format {
71        Format::Webp
72    }
73
74    fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
75        // Inspection runs the identical pass that stripping does and throws the output away,
76        // so "everything `strip` removes is something `inspect` can see" is true by
77        // construction rather than by two code paths agreeing to stay in step — which is what
78        // makes the pipeline's verification pass mean anything (`docs/ARCHITECTURE.md` §3).
79        let processed = process(input, options, &ParseLimits::default())?;
80        Ok(MetadataReport {
81            format: Format::Webp,
82            findings: processed.findings,
83            notes: processed.notes,
84        })
85    }
86
87    fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
88        let processed = process(input, &options.inspect, &options.limits)?;
89        Ok(Stripped {
90            report: StripReport {
91                format: Format::Webp,
92                removed: processed.findings,
93                retained: processed.retained,
94                notes: processed.notes,
95                input_bytes: as_u64(input.len()),
96                output_bytes: as_u64(processed.output.len()),
97            },
98            bytes: processed.output,
99        })
100    }
101}
102
103/// The form type that makes a RIFF file a WebP file (§2.3).
104const WEBP: riff::FourCc = *b"WEBP";
105
106/// `VP8X`'s payload is exactly ten bytes — one of flags, three reserved, and the canvas width
107/// and height each as a 24-bit value, both stored minus one (§2.7).
108const VP8X_PAYLOAD_BYTES: u32 = 10;
109
110/// Bytes of frame geometry, duration, and flags at the front of an `ANMF` payload, before the
111/// frame's own sub-chunks begin (§2.7.1.1, Figure 9).
112const ANMF_HEADER_BYTES: usize = 16;
113
114/// The bits of `VP8X`'s flags byte that declare a metadata chunk is present.
115///
116/// §2.7 numbers the flags from the most significant bit: two reserved bits, then ICC profile,
117/// alpha, Exif, XMP, animation, and one more reserved bit. This mask is ICC, Exif, and XMP —
118/// the three whose chunks this handler removes. Alpha and animation describe the picture, not
119/// the metadata, and are left exactly as they were.
120const METADATA_FLAGS: u8 = 0b0010_1100;
121
122/// A JPEG `APP1` Exif payload begins with this introducer; a WebP `EXIF` chunk's payload does
123/// not — §2.7.1.5 says the payload is the Exif metadata itself. It is tolerated anyway,
124/// because a producer that copies a JPEG's `APP1` payload across verbatim brings the
125/// introducer with it, and feeding those six bytes to the TIFF reader shifts every offset
126/// inside the block and yields a confident parse of the wrong bytes.
127const EXIF_INTRODUCER: &[u8] = b"Exif\x00\x00";
128
129/// Chunks that carry the picture itself, in a still image or in one animation frame.
130///
131/// Copied through byte for byte wherever they appear. `ALPH` is the alpha channel, `VP8 ` and
132/// `VP8L` are the lossy and lossless bitstreams (§2.7.1.2–§2.7.1.4).
133const IMAGE_CHUNKS: [&[u8; 4]; 3] = [b"ALPH", b"VP8 ", b"VP8L"];
134
135/// The result of one pass over a file: what was found, and what the sanitised file looks like.
136struct Processed {
137    findings: Vec<Finding>,
138    retained: Vec<Retained>,
139    notes: Vec<Note>,
140    output: Vec<u8>,
141}
142
143/// Split `input` into its chunks, plus anything after the RIFF chunk the header declared.
144///
145/// A file that does not parse is refused whole: there is no path here that returns a partial
146/// chunk list for a caller to strip and write out.
147fn walk<'a>(input: &'a [u8], limits: &ParseLimits) -> Result<(Vec<Chunk<'a>>, &'a [u8])> {
148    let mut budget = limits.max_items;
149    let (chunks, trailing) = riff::read(input, WEBP, &mut budget).map_err(convert)?;
150    validate_shape(&chunks)?;
151    Ok((chunks, trailing))
152}
153
154/// A container-layer walk failure as this format's error.
155fn convert(error: WalkError) -> StryptError {
156    match error {
157        WalkError::Malformed { detail, offset } => malformed(detail, as_offset(offset)),
158        WalkError::Limit(limit) => StryptError::LimitExceeded {
159            format: Format::Webp,
160            limit,
161        },
162    }
163}
164
165/// Refuse a chunk list that is not a shape this handler has understood.
166///
167/// These checks exist to stop the handler emitting something that passes for a WebP file and is
168/// not one. The picture check matters most: a file consisting of nothing but a `VP8X` and an
169/// `EXIF` chunk would otherwise strip to a container with no picture in it, and be reported as a
170/// success.
171fn validate_shape(chunks: &[Chunk<'_>]) -> Result<()> {
172    // Offsets reported here point at the RIFF body rather than at a chunk, as they did when
173    // these checks ran inside the walk.
174    let body_start = riff::HEADER_BYTES;
175
176    for chunk in chunks {
177        // §2.7 fixes this chunk's length. A different one means the flags byte and the canvas
178        // dimensions are not where the specification puts them, so the handler cannot correct
179        // the flags and must not guess.
180        if &chunk.kind == b"VP8X" && as_u64(chunk.data.len()) != u64::from(VP8X_PAYLOAD_BYTES) {
181            return Err(malformed(
182                MalformedDetail::LengthOutOfRange,
183                as_offset(chunk.offset),
184            ));
185        }
186    }
187
188    // §2.7: an extended file opens with `VP8X`, and a simple file is one bitstream chunk.
189    let opens_correctly =
190        matches!(chunks.first(), Some(c) if matches!(&c.kind, b"VP8X" | b"VP8 " | b"VP8L"));
191    if !opens_correctly {
192        return Err(malformed(
193            MalformedDetail::MissingMarker,
194            as_offset(body_start),
195        ));
196    }
197    let has_picture = chunks
198        .iter()
199        .any(|c| matches!(&c.kind, b"VP8 " | b"VP8L" | b"ANMF"));
200    if !has_picture {
201        return Err(malformed(
202            MalformedDetail::MissingMarker,
203            as_offset(body_start),
204        ));
205    }
206    Ok(())
207}
208
209/// What to do with one chunk.
210enum Outcome {
211    /// Copy it through unchanged.
212    Keep,
213    /// Copy it through with the given bytes in its place. Used only by `VP8X`, and only when
214    /// its flags no longer match what the file contains.
215    Replace(Vec<u8>),
216    /// Remove it entirely.
217    Drop,
218}
219
220/// A decision about one chunk, with what to tell the user about it.
221struct Decision {
222    outcome: Outcome,
223    findings: Vec<Finding>,
224    /// Anything kept on purpose. Separate from `findings` because the verification pass
225    /// requires that nothing `inspect` reports as a finding survives a strip — a chunk that is
226    /// deliberately kept has to be declared, not reported as removed.
227    retained: Vec<Retained>,
228    notes: Vec<Note>,
229}
230
231impl Decision {
232    const fn keep() -> Self {
233        Self {
234            outcome: Outcome::Keep,
235            findings: Vec::new(),
236            retained: Vec::new(),
237            notes: Vec::new(),
238        }
239    }
240
241    fn drop_with(findings: Vec<Finding>) -> Self {
242        Self {
243            outcome: Outcome::Drop,
244            findings,
245            retained: Vec::new(),
246            notes: Vec::new(),
247        }
248    }
249
250    fn drop_one(kind: MetadataKind, location: impl Into<String>, bytes: u64) -> Self {
251        Self::drop_with(vec![Finding::new(kind, location, bytes)])
252    }
253}
254
255/// Walk `input`, decide about every chunk, and build the sanitised file.
256fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
257    let (chunks, trailing) = walk(input, limits)?;
258
259    let mut findings = Vec::new();
260    let mut retained = Vec::new();
261    let mut notes = Vec::new();
262
263    // The RIFF payload is assembled first, because the size field in front of it is the one
264    // field in a WebP file that cannot be copied and has to be computed.
265    let mut body: Vec<u8> = Vec::with_capacity(input.len());
266
267    for chunk in &chunks {
268        let decision = decide(chunk, options, limits);
269        notes.extend(decision.notes);
270        retained.extend(decision.retained);
271        findings.extend(decision.findings);
272        match decision.outcome {
273            Outcome::Keep => body.extend_from_slice(chunk.raw),
274            Outcome::Replace(bytes) => body.extend_from_slice(&bytes),
275            Outcome::Drop => {}
276        }
277    }
278
279    if !trailing.is_empty() {
280        // Nothing reads past the length the RIFF header declares, and few users know anything
281        // can be there. It is a convenient place to keep a second copy of an image whose
282        // visible version was cropped.
283        let kind = if trailing.starts_with(&riff::RIFF) {
284            MetadataKind::Thumbnail
285        } else {
286            MetadataKind::Other
287        };
288        findings.push(Finding::new(
289            kind,
290            "trailing data after the RIFF chunk",
291            as_u64(trailing.len()),
292        ));
293    }
294
295    // Unreachable in practice: the output body is never larger than the input's declared RIFF
296    // size, which was itself read as a `u32`.
297    let output = riff::write(WEBP, &body).map_err(|d| malformed(d, None))?;
298
299    Ok(Processed {
300        findings,
301        retained,
302        notes,
303        output,
304    })
305}
306
307/// Decide about one chunk.
308fn decide(chunk: &Chunk<'_>, options: &InspectOptions, limits: &ParseLimits) -> Decision {
309    let size = as_u64(chunk.data.len());
310    match &chunk.kind {
311        // The extended-format header. Kept, with its metadata flags corrected to match the
312        // file it now describes.
313        b"VP8X" => extended_header(chunk),
314        // The picture, the alpha channel, and the animation's global parameters — background
315        // colour and loop count, neither of which names anyone.
316        b"VP8 " | b"VP8L" | b"ALPH" | b"ANIM" => Decision::keep(),
317        // One animation frame, which is a container of its own.
318        b"ANMF" => animation_frame(chunk, limits),
319        // An embedded ICC colour profile. A per-device profile is a fingerprint, and its
320        // internal tags routinely carry the vendor, the model, and the calibration date.
321        b"ICCP" => Decision::drop_one(MetadataKind::ColourProfile, "ICCP", size),
322        b"EXIF" => exif_chunk(chunk.data, size, options, limits),
323        b"XMP " => Decision::drop_with(xmp::scan(chunk.data, "XMP", options)),
324        _ => {
325            // §2.7.1.6 tells readers to ignore an unknown chunk and writers to preserve it.
326            // strypt deliberately does the opposite of the second half: it is not a general
327            // WebP writer, and an unknown chunk is precisely where a producer or an attacker
328            // puts something they do not want a metadata tool to look at. A scrubber that
329            // copies through what it does not understand is not scrubbing. Because the same
330            // section makes unknown chunks ignorable, dropping one cannot break a decoder —
331            // which is why this handler has no equivalent of PNG's unknown-critical-chunk
332            // dilemma (ADR-0022).
333            Decision::drop_one(MetadataKind::Other, name_of(&chunk.kind), size)
334        }
335    }
336}
337
338/// `VP8X`: the extended-format header, whose flags declare what else the file contains.
339///
340/// Copied byte for byte when its metadata bits are already clear, so an extended file with
341/// nothing to remove still strips to an identical copy of itself. Otherwise the flags byte is
342/// rewritten with those three bits cleared and every other byte of the chunk carried across
343/// unchanged (ADR-0023).
344fn extended_header(chunk: &Chunk<'_>) -> Decision {
345    let Some(flags) = chunk.data.first().copied() else {
346        // Unreachable: `walk` refuses a `VP8X` that is not exactly ten bytes long.
347        return Decision::keep();
348    };
349    if flags & METADATA_FLAGS == 0 {
350        return Decision::keep();
351    }
352
353    let mut rewritten = Vec::with_capacity(chunk.raw.len());
354    rewritten.extend_from_slice(b"VP8X");
355    rewritten.extend_from_slice(&VP8X_PAYLOAD_BYTES.to_le_bytes());
356    rewritten.push(flags & !METADATA_FLAGS);
357    // The reserved bits and the canvas dimensions. The payload is ten bytes, so it is even
358    // and there is no padding byte to reproduce.
359    rewritten.extend_from_slice(chunk.data.get(1..).unwrap_or_default());
360    Decision {
361        outcome: Outcome::Replace(rewritten),
362        findings: Vec::new(),
363        retained: Vec::new(),
364        notes: Vec::new(),
365    }
366}
367
368/// `ANMF`: one animation frame — a fixed header, then the frame's own sub-chunks.
369///
370/// §2.7.1.1 allows a frame to carry an optional list of *unknown* chunks alongside its alpha
371/// and bitstream sub-chunks, which makes the inside of a frame a hiding place with the
372/// specification's blessing. So the sub-chunk area is walked and filtered the same way the
373/// top level is: the picture chunks are copied through byte for byte, anything else is
374/// removed and reported.
375///
376/// The frame header is copied verbatim. Nothing in it depends on the sub-chunks that follow —
377/// the frame's position, size, duration, and blending flags are all self-contained — so
378/// dropping a sub-chunk cannot leave the header describing something that is no longer there.
379///
380/// A sub-chunk area that does not parse is left exactly as it arrived, with a note saying so.
381/// Refusing the whole file would be the wrong call for a frame strypt only partly understands,
382/// and silently keeping it would let the user believe the frame had been scrubbed.
383fn animation_frame(chunk: &Chunk<'_>, limits: &ParseLimits) -> Decision {
384    let Some(header) = chunk.data.get(0..ANMF_HEADER_BYTES) else {
385        return unexamined("ANMF", as_u64(chunk.data.len()));
386    };
387    let Some(rest) = chunk.data.get(ANMF_HEADER_BYTES..) else {
388        return unexamined("ANMF", as_u64(chunk.data.len()));
389    };
390
391    let mut budget = limits.max_items;
392    let Ok(sub_chunks) = riff::chunks(rest, 0, &mut budget) else {
393        return unexamined("ANMF", as_u64(chunk.data.len()));
394    };
395
396    let mut findings = Vec::new();
397    let mut payload = Vec::with_capacity(chunk.data.len());
398    payload.extend_from_slice(header);
399    for sub in &sub_chunks {
400        if IMAGE_CHUNKS.contains(&&sub.kind) {
401            payload.extend_from_slice(sub.raw);
402        } else {
403            findings.push(Finding::new(
404                MetadataKind::Other,
405                format!("ANMF {}", name_of(&sub.kind)),
406                as_u64(sub.data.len()),
407            ));
408        }
409    }
410
411    if findings.is_empty() {
412        // Nothing was dropped, so the frame is copied rather than reassembled — which keeps
413        // an ordinary animation byte-identical through a strip.
414        return Decision::keep();
415    }
416
417    let mut rewritten = Vec::with_capacity(payload.len().saturating_add(riff::HEADER_BYTES + 1));
418    if riff::write_chunk(&mut rewritten, *b"ANMF", &payload).is_err() {
419        // Unreachable: the rebuilt payload is never larger than the one that was parsed.
420        return unexamined("ANMF", as_u64(chunk.data.len()));
421    }
422
423    Decision {
424        outcome: Outcome::Replace(rewritten),
425        findings,
426        retained: Vec::new(),
427        notes: Vec::new(),
428    }
429}
430
431/// `EXIF`: a raw TIFF block, byte-order mark first.
432fn exif_chunk(data: &[u8], size: u64, options: &InspectOptions, limits: &ParseLimits) -> Decision {
433    let tiff = if data.starts_with(EXIF_INTRODUCER) {
434        data.get(EXIF_INTRODUCER.len()..).unwrap_or_default()
435    } else {
436        data
437    };
438    let scanned = exif::scan(tiff, "EXIF", options, limits);
439    let findings = if scanned.findings.is_empty() {
440        // An Exif block that named nothing is still an Exif block, and it is still going.
441        vec![Finding::new(MetadataKind::Other, "EXIF", size)]
442    } else {
443        scanned.findings
444    };
445    Decision {
446        outcome: Outcome::Drop,
447        findings,
448        retained: Vec::new(),
449        notes: scanned.notes,
450    }
451}
452
453/// Keep a region untouched and say plainly that its bytes went by unexamined.
454fn unexamined(location: &'static str, bytes: u64) -> Decision {
455    Decision {
456        outcome: Outcome::Keep,
457        findings: Vec::new(),
458        retained: Vec::new(),
459        notes: vec![Note::UnparsedRegion {
460            location: location.to_owned(),
461            bytes,
462        }],
463    }
464}
465
466/// A malformed-file error for this format.
467fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
468    StryptError::Malformed {
469        format: Format::Webp,
470        offset,
471        detail,
472    }
473}
474
475/// A byte position as a reportable offset.
476fn as_offset(position: usize) -> Option<u64> {
477    u64::try_from(position).ok()
478}
479
480/// Widen a length for reporting. Saturating: a report field is not worth failing a strip over.
481fn as_u64(value: usize) -> u64 {
482    u64::try_from(value).unwrap_or(u64::MAX)
483}
484
485#[cfg(test)]
486mod tests {
487    // Test code is never reachable from untrusted bytes, which is the boundary the
488    // panic-freedom lints exist to police (ADR-0006).
489    #![allow(
490        clippy::unwrap_used,
491        clippy::expect_used,
492        clippy::indexing_slicing,
493        clippy::arithmetic_side_effects
494    )]
495
496    use super::*;
497    use crate::report::MetadataValue;
498
499    /// One chunk: code, little-endian size, payload, and a padding byte when the size is odd.
500    fn chunk(kind: &[u8], data: &[u8]) -> Vec<u8> {
501        let mut out = kind.to_vec();
502        out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_le_bytes());
503        out.extend_from_slice(data);
504        if data.len() % 2 == 1 {
505            out.push(0);
506        }
507        out
508    }
509
510    /// A RIFF/WEBP container around the given chunks, with a correct size field.
511    fn webp(chunks: &[Vec<u8>]) -> Vec<u8> {
512        riff::write(WEBP, &chunks.concat()).unwrap()
513    }
514
515    /// A `VP8X` payload with the given flags and a 16x16 canvas.
516    fn vp8x(flags: u8) -> Vec<u8> {
517        let mut data = vec![flags, 0, 0, 0];
518        data.extend_from_slice(&15u32.to_le_bytes()[0..3]);
519        data.extend_from_slice(&15u32.to_le_bytes()[0..3]);
520        chunk(b"VP8X", &data)
521    }
522
523    /// A stand-in lossless bitstream. Its contents are never parsed by this handler.
524    fn bitstream() -> Vec<u8> {
525        chunk(b"VP8L", b"SYNTHETIC-PIXELS")
526    }
527
528    fn strip_ok(data: &[u8]) -> Stripped {
529        WebpHandler
530            .strip(data, &StripOptions::default())
531            .expect("strip failed")
532    }
533
534    fn findings(data: &[u8]) -> Vec<Finding> {
535        WebpHandler
536            .inspect(data, &InspectOptions::names_only())
537            .expect("inspect failed")
538            .findings
539    }
540
541    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
542        haystack.windows(needle.len()).any(|w| w == needle)
543    }
544
545    #[test]
546    fn the_picture_is_never_touched() {
547        let input = webp(&[
548            vp8x(0b0000_1000),
549            bitstream(),
550            chunk(b"EXIF", b"II\x2A\x00\x08\x00\x00\x00\x00\x00"),
551        ]);
552        let output = strip_ok(&input).bytes;
553        assert!(
554            contains(&output, b"SYNTHETIC-PIXELS"),
555            "the image data did not survive byte for byte"
556        );
557    }
558
559    #[test]
560    fn a_simple_file_cannot_carry_metadata_and_comes_back_byte_identical() {
561        // A file with no `VP8X` has nowhere to put an ICC profile, Exif, or XMP: §2.7 requires
562        // the extended header before any of them. So this is not merely "nothing was found" —
563        // it is a guaranteed pass-through, and asserting it keeps that guarantee honest.
564        for payload in [chunk(b"VP8L", b"SYNTHETIC-PIXELS"), chunk(b"VP8 ", b"ODD")] {
565            let input = webp(&[payload]);
566            let stripped = strip_ok(&input);
567            assert!(stripped.report.removed.is_empty());
568            assert_eq!(
569                stripped.bytes, input,
570                "a simple WebP was not passed through"
571            );
572        }
573    }
574
575    #[test]
576    fn a_clean_extended_file_comes_back_byte_identical_too() {
577        // The alpha and animation bits are not metadata flags, so a `VP8X` carrying only those
578        // is copied rather than rewritten.
579        let input = webp(&[vp8x(0b0001_0000), chunk(b"ALPH", b"A"), bitstream()]);
580        let stripped = strip_ok(&input);
581        assert!(stripped.report.removed.is_empty());
582        assert_eq!(stripped.bytes, input);
583    }
584
585    #[test]
586    fn the_metadata_chunks_are_removed_and_the_header_flags_follow() {
587        let mut tiff = b"II\x2A\x00\x08\x00\x00\x00".to_vec();
588        tiff.extend_from_slice(&1u16.to_le_bytes());
589        tiff.extend_from_slice(&0x010Fu16.to_le_bytes()); // Make
590        tiff.extend_from_slice(&2u16.to_le_bytes()); // ASCII
591        tiff.extend_from_slice(&4u32.to_le_bytes());
592        tiff.extend_from_slice(b"ACME");
593        tiff.extend_from_slice(&0u32.to_le_bytes());
594
595        // ICC, alpha, Exif, and XMP all declared.
596        let input = webp(&[
597            vp8x(0b0011_1100),
598            chunk(b"ICCP", b"SYNTHETIC-PROFILE-0001"),
599            chunk(b"ALPH", b"A"),
600            bitstream(),
601            chunk(b"EXIF", &tiff),
602            chunk(
603                b"XMP ",
604                b"<x:xmpmeta><dc:creator>SYNTHETIC-0002</dc:creator></x:xmpmeta>",
605            ),
606        ]);
607
608        let found = findings(&input);
609        let kinds: Vec<MetadataKind> = found.iter().map(|f| f.kind).collect();
610        assert!(kinds.contains(&MetadataKind::ColourProfile));
611        assert!(kinds.contains(&MetadataKind::DeviceIdentity));
612        assert!(kinds.contains(&MetadataKind::PersonalIdentity));
613
614        let output = strip_ok(&input).bytes;
615        assert!(!contains(&output, b"SYNTHETIC-PROFILE-0001"));
616        assert!(!contains(&output, b"ACME"));
617        assert!(!contains(&output, b"SYNTHETIC-0002"));
618        assert!(findings(&output).is_empty());
619
620        // The header now describes the file it is actually in: ICC, Exif, and XMP cleared,
621        // alpha untouched.
622        let flags = output[20];
623        assert_eq!(
624            flags, 0b0001_0000,
625            "the VP8X flags still claim metadata that is gone"
626        );
627    }
628
629    #[test]
630    fn flags_that_were_already_lying_are_corrected_even_with_nothing_to_remove() {
631        // A file whose header claims an Exif chunk it does not have. Nothing is removable, so
632        // `show` reports nothing — and `strip` still hands back a file that tells the truth.
633        let input = webp(&[vp8x(0b0000_1100), bitstream()]);
634        let stripped = strip_ok(&input);
635        assert!(stripped.report.removed.is_empty());
636        assert_eq!(stripped.bytes[20], 0);
637        assert_ne!(stripped.bytes, input);
638    }
639
640    #[test]
641    fn an_exif_chunk_written_with_a_jpeg_introducer_is_still_read() {
642        // The container specification puts no introducer here, but a producer copying a JPEG
643        // `APP1` payload across brings one. Feeding those six bytes to the TIFF reader would
644        // shift every offset in the block.
645        let mut tiff = b"II\x2A\x00\x08\x00\x00\x00".to_vec();
646        tiff.extend_from_slice(&1u16.to_le_bytes());
647        tiff.extend_from_slice(&0x0110u16.to_le_bytes()); // Model
648        tiff.extend_from_slice(&2u16.to_le_bytes());
649        tiff.extend_from_slice(&4u32.to_le_bytes());
650        tiff.extend_from_slice(b"MDL1");
651        tiff.extend_from_slice(&0u32.to_le_bytes());
652
653        let mut payload = EXIF_INTRODUCER.to_vec();
654        payload.extend_from_slice(&tiff);
655        let input = webp(&[vp8x(0b0000_1000), bitstream(), chunk(b"EXIF", &payload)]);
656
657        let found = findings(&input);
658        assert_eq!(found[0].field.as_deref(), Some("Model"));
659    }
660
661    #[test]
662    fn an_unknown_chunk_is_removed_rather_than_preserved() {
663        // §2.7.1.6 asks writers to preserve unknown chunks. strypt is not a general writer,
664        // and an unknown chunk can hold anything at all.
665        let input = webp(&[
666            vp8x(0),
667            bitstream(),
668            chunk(b"PRVW", b"SYNTHETIC-PREVIEW-0003"),
669        ]);
670        let stripped = strip_ok(&input);
671        assert!(!contains(&stripped.bytes, b"SYNTHETIC-PREVIEW-0003"));
672        assert_eq!(stripped.report.removed[0].location, "PRVW");
673    }
674
675    #[test]
676    fn an_animation_survives_and_a_chunk_hidden_in_a_frame_does_not() {
677        let mut frame = vec![0u8; ANMF_HEADER_BYTES];
678        frame.extend_from_slice(&chunk(b"VP8L", b"SYNTHETIC-FRAME-PIXELS"));
679        let clean = webp(&[
680            vp8x(0b0000_0010),
681            chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]),
682            chunk(b"ANMF", &frame),
683        ]);
684        assert_eq!(strip_ok(&clean).bytes, clean, "an animation was rewritten");
685
686        let mut hostile = vec![0u8; ANMF_HEADER_BYTES];
687        hostile.extend_from_slice(&chunk(b"VP8L", b"SYNTHETIC-FRAME-PIXELS"));
688        hostile.extend_from_slice(&chunk(b"JUNK", b"SYNTHETIC-IN-FRAME-0004"));
689        let input = webp(&[
690            vp8x(0b0000_0010),
691            chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]),
692            chunk(b"ANMF", &hostile),
693        ]);
694        let stripped = strip_ok(&input);
695        assert!(!contains(&stripped.bytes, b"SYNTHETIC-IN-FRAME-0004"));
696        assert!(
697            contains(&stripped.bytes, b"SYNTHETIC-FRAME-PIXELS"),
698            "the frame's picture did not survive"
699        );
700        assert_eq!(stripped.report.removed[0].location, "ANMF JUNK");
701    }
702
703    #[test]
704    fn a_frame_that_does_not_parse_is_kept_and_declared_unexamined() {
705        let mut frame = vec![0u8; ANMF_HEADER_BYTES];
706        frame.extend_from_slice(b"VP8L\xff\xff\xff\xffPRESERVED-0005");
707        let input = webp(&[
708            vp8x(0b0000_0010),
709            chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]),
710            chunk(b"ANMF", &frame),
711        ]);
712        let stripped = strip_ok(&input);
713        assert!(contains(&stripped.bytes, b"PRESERVED-0005"));
714        assert!(matches!(
715            stripped.report.notes.first(),
716            Some(Note::UnparsedRegion { location, .. }) if location == "ANMF"
717        ));
718    }
719
720    #[test]
721    fn data_after_the_riff_chunk_is_removed() {
722        let mut input = webp(&[vp8x(0), bitstream()]);
723        input.extend_from_slice(b"SYNTHETIC-APPENDED-0006");
724        let stripped = strip_ok(&input);
725        assert!(!contains(&stripped.bytes, b"SYNTHETIC-APPENDED-0006"));
726        assert_eq!(
727            stripped.report.removed[0].location,
728            "trailing data after the RIFF chunk"
729        );
730    }
731
732    #[test]
733    fn a_second_file_after_the_riff_chunk_is_reported_as_a_thumbnail() {
734        let mut input = webp(&[vp8x(0), bitstream()]);
735        input.extend_from_slice(&webp(&[bitstream()]));
736        assert_eq!(
737            strip_ok(&input).report.removed[0].kind,
738            MetadataKind::Thumbnail
739        );
740    }
741
742    #[test]
743    fn an_xmp_packet_is_itemised_by_property() {
744        let input = webp(&[
745            vp8x(0b0000_0100),
746            bitstream(),
747            chunk(
748                b"XMP ",
749                br#"<x:xmpmeta xmpMM:DocumentID="uuid:1" xmp:CreatorTool="SYNTHETIC"/>"#,
750            ),
751        ]);
752        let found = findings(&input);
753        let fields: Vec<&str> = found.iter().filter_map(|f| f.field.as_deref()).collect();
754        assert!(fields.contains(&"xmpMM:DocumentID"), "{fields:?}");
755        assert!(fields.contains(&"xmp:CreatorTool"), "{fields:?}");
756    }
757
758    #[test]
759    fn values_are_withheld_from_a_default_inspection() {
760        let mut tiff = b"II\x2A\x00\x08\x00\x00\x00".to_vec();
761        tiff.extend_from_slice(&1u16.to_le_bytes());
762        tiff.extend_from_slice(&0x010Fu16.to_le_bytes());
763        tiff.extend_from_slice(&2u16.to_le_bytes());
764        tiff.extend_from_slice(&4u32.to_le_bytes());
765        tiff.extend_from_slice(b"ACME");
766        tiff.extend_from_slice(&0u32.to_le_bytes());
767        let input = webp(&[vp8x(0b0000_1000), bitstream(), chunk(b"EXIF", &tiff)]);
768
769        assert_eq!(findings(&input)[0].value, None);
770        let with_values = WebpHandler
771            .inspect(&input, &InspectOptions::with_values())
772            .unwrap();
773        assert_eq!(
774            with_values.findings[0].value,
775            Some(MetadataValue::Text("ACME".to_owned()))
776        );
777    }
778
779    #[test]
780    fn stripping_twice_changes_nothing() {
781        let input = webp(&[
782            vp8x(0b0011_1100),
783            chunk(b"ICCP", b"SYNTHETIC-PROFILE-0001"),
784            bitstream(),
785            chunk(b"XMP ", b"<x:xmpmeta/>"),
786        ]);
787        let once = strip_ok(&input).bytes;
788        let twice = strip_ok(&once).bytes;
789        assert_eq!(once, twice, "strip is not idempotent");
790    }
791
792    #[test]
793    fn a_riff_size_beyond_the_end_of_the_file_is_refused_rather_than_clamped() {
794        let mut input = webp(&[vp8x(0), bitstream()]);
795        input[4..8].copy_from_slice(&0x00FF_FFFFu32.to_le_bytes());
796        assert!(matches!(
797            WebpHandler.inspect(&input, &InspectOptions::names_only()),
798            Err(StryptError::Malformed {
799                detail: MalformedDetail::LengthOutOfRange,
800                ..
801            })
802        ));
803    }
804
805    #[test]
806    fn a_chunk_size_beyond_the_riff_extent_is_refused() {
807        let input = webp(&[vp8x(0), bitstream(), {
808            let mut lying = b"EXIF".to_vec();
809            lying.extend_from_slice(&0x0010_0000u32.to_le_bytes());
810            lying.extend_from_slice(b"II\x2A\x00");
811            lying
812        }]);
813        assert!(matches!(
814            WebpHandler.inspect(&input, &InspectOptions::names_only()),
815            Err(StryptError::Malformed {
816                detail: MalformedDetail::LengthOutOfRange,
817                ..
818            })
819        ));
820    }
821
822    #[test]
823    fn a_file_with_no_picture_chunk_is_refused_rather_than_emptied() {
824        // Otherwise this strips to a valid-looking container with no image in it, and the user
825        // is told it succeeded.
826        let input = webp(&[
827            vp8x(0b0000_1000),
828            chunk(b"EXIF", b"II\x2A\x00\x08\x00\x00\x00"),
829        ]);
830        assert!(matches!(
831            WebpHandler.strip(&input, &StripOptions::default()),
832            Err(StryptError::Malformed {
833                detail: MalformedDetail::MissingMarker,
834                ..
835            })
836        ));
837    }
838
839    #[test]
840    fn a_file_that_does_not_open_with_a_header_or_bitstream_chunk_is_refused() {
841        let input = webp(&[chunk(b"EXIF", b"II\x2A\x00\x08\x00\x00\x00"), bitstream()]);
842        assert!(matches!(
843            WebpHandler.inspect(&input, &InspectOptions::names_only()),
844            Err(StryptError::Malformed {
845                detail: MalformedDetail::MissingMarker,
846                ..
847            })
848        ));
849    }
850
851    #[test]
852    fn a_vp8x_of_the_wrong_length_is_refused() {
853        let input = webp(&[chunk(b"VP8X", &[0u8; 8]), bitstream()]);
854        assert!(matches!(
855            WebpHandler.inspect(&input, &InspectOptions::names_only()),
856            Err(StryptError::Malformed {
857                detail: MalformedDetail::LengthOutOfRange,
858                ..
859            })
860        ));
861    }
862
863    #[test]
864    fn a_four_character_code_that_is_not_ascii_is_refused() {
865        let input = webp(&[vp8x(0), bitstream(), chunk(b"\x00\x01\x02\x03", b"")]);
866        assert!(matches!(
867            WebpHandler.inspect(&input, &InspectOptions::names_only()),
868            Err(StryptError::Malformed {
869                detail: MalformedDetail::UnexpectedMarker,
870                ..
871            })
872        ));
873    }
874
875    #[test]
876    fn a_file_that_is_not_riff_or_not_webp_is_refused() {
877        assert!(matches!(
878            WebpHandler.inspect(b"RIFX\x04\x00\x00\x00WEBP", &InspectOptions::names_only()),
879            Err(StryptError::Malformed { .. })
880        ));
881        // Detection routes this to the WAV handler (ADR-0039); reaching this one directly is
882        // still a refusal, because the form type is not `WEBP`.
883        assert!(matches!(
884            WebpHandler.inspect(b"RIFF\x04\x00\x00\x00WAVE", &InspectOptions::names_only()),
885            Err(StryptError::Malformed {
886                detail: MalformedDetail::MissingMarker,
887                ..
888            })
889        ));
890    }
891
892    #[test]
893    fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
894        let input = webp(&[
895            vp8x(0b0011_1100),
896            chunk(b"ICCP", b"SYNTHETIC-PROFILE-0001"),
897            bitstream(),
898            chunk(b"EXIF", b"II\x2A\x00\x08\x00\x00\x00\x00\x00"),
899            chunk(b"XMP ", b"<x:xmpmeta/>"),
900        ]);
901        for n in 0..=input.len() {
902            let prefix = &input[0..n];
903            let _ = WebpHandler.inspect(prefix, &InspectOptions::names_only());
904            let _ = WebpHandler.strip(prefix, &StripOptions::default());
905        }
906    }
907
908    #[test]
909    fn a_chunk_count_beyond_the_limit_is_refused() {
910        let mut chunks = vec![vp8x(0), bitstream()];
911        chunks.extend((0..64).map(|_| chunk(b"JUNK", b"x")));
912        let input = webp(&chunks);
913        let options = StripOptions {
914            limits: ParseLimits {
915                max_items: 8,
916                ..ParseLimits::default()
917            },
918            ..StripOptions::default()
919        };
920        assert!(matches!(
921            WebpHandler.strip(&input, &options),
922            Err(StryptError::LimitExceeded { .. })
923        ));
924    }
925
926    #[test]
927    fn an_odd_length_chunk_keeps_its_padding_byte() {
928        // §2.3 pads an odd payload to an even boundary. A handler that dropped the pad when
929        // copying a chunk through would shift every chunk after it by one byte.
930        let input = webp(&[vp8x(0), chunk(b"VP8 ", b"ODD")]);
931        let stripped = strip_ok(&input);
932        assert_eq!(stripped.bytes, input);
933        assert_eq!(stripped.bytes.len() % 2, 0);
934    }
935}