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//! [`Reader`] and every failure is a typed error rather than a panic. The declared RIFF size
46//! bounds the walk: bytes beyond it are trailing data and go, and a RIFF size that runs past
47//! the end of the file is a lie and the file is refused rather than clamped.
48
49use crate::bytes::{Reader, u32_to_usize};
50use crate::detect::Format;
51use crate::error::{MalformedDetail, ResourceLimit, Result, StryptError};
52use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, exif, xmp};
53use crate::report::{
54    Finding, InspectOptions, MetadataKind, MetadataReport, Note, Retained, StripReport,
55};
56
57/// Removal of metadata from WebP images.
58#[derive(Debug, Clone, Copy, Default)]
59pub struct WebpHandler;
60
61impl MetadataHandler for WebpHandler {
62    fn name(&self) -> &'static str {
63        Format::Webp.id()
64    }
65
66    fn format(&self) -> Format {
67        Format::Webp
68    }
69
70    fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
71        // Inspection runs the identical pass that stripping does and throws the output away,
72        // so "everything `strip` removes is something `inspect` can see" is true by
73        // construction rather than by two code paths agreeing to stay in step — which is what
74        // makes the pipeline's verification pass mean anything (`docs/ARCHITECTURE.md` §3).
75        let processed = process(input, options, &ParseLimits::default())?;
76        Ok(MetadataReport {
77            format: Format::Webp,
78            findings: processed.findings,
79            notes: processed.notes,
80        })
81    }
82
83    fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
84        let processed = process(input, &options.inspect, &options.limits)?;
85        Ok(Stripped {
86            report: StripReport {
87                format: Format::Webp,
88                removed: processed.findings,
89                retained: processed.retained,
90                notes: processed.notes,
91                input_bytes: as_u64(input.len()),
92                output_bytes: as_u64(processed.output.len()),
93            },
94            bytes: processed.output,
95        })
96    }
97}
98
99/// The RIFF container's code, and the form type that makes it a WebP file (§2.3).
100const RIFF: &[u8; 4] = b"RIFF";
101const WEBP: &[u8; 4] = b"WEBP";
102
103/// Bytes in a chunk header: the four-character code and a 32-bit little-endian size (§2.3).
104const CHUNK_HEADER_BYTES: usize = 8;
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/// One chunk, and the exact bytes it occupied.
136struct Chunk<'a> {
137    /// The four-character code.
138    kind: [u8; 4],
139    /// The payload, without the header or the RIFF padding byte around it.
140    data: &'a [u8],
141    /// The whole chunk as it appeared, header and padding included. Kept chunks are written
142    /// out from this, which is what makes the copy exact.
143    raw: &'a [u8],
144}
145
146/// The result of one pass over a file: what was found, and what the sanitised file looks like.
147struct Processed {
148    findings: Vec<Finding>,
149    retained: Vec<Retained>,
150    notes: Vec<Note>,
151    output: Vec<u8>,
152}
153
154/// Split `input` into its chunks, plus anything after the RIFF chunk the header declared.
155///
156/// A file that does not parse is refused whole: there is no path here that returns a partial
157/// chunk list for a caller to strip and write out.
158fn walk<'a>(input: &'a [u8], limits: &ParseLimits) -> Result<(Vec<Chunk<'a>>, &'a [u8])> {
159    let mut r = Reader::new(input);
160    if r.take(RIFF.len()) != Some(RIFF.as_slice()) {
161        return Err(malformed(MalformedDetail::MissingMarker, Some(0)));
162    }
163
164    // §2.3: the size counts the `WEBP` form type and every chunk after it, but not the eight
165    // bytes of the RIFF header itself.
166    let declared = r
167        .u32_le()
168        .ok_or_else(|| malformed(MalformedDetail::Truncated, Some(0)))?;
169    let size = u32_to_usize(declared)
170        .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(4)))?;
171    if size < WEBP.len() || size > r.remaining() {
172        // Refused rather than clamped to the real file length. A clamp turns a lying size
173        // field into a silent parse of the wrong extent, and a "cleaned" copy of a truncated
174        // file would be a repair the user never asked for, presented as a clean version.
175        return Err(malformed(MalformedDetail::LengthOutOfRange, as_offset(4)));
176    }
177
178    let body_start = r.position();
179    if r.take(WEBP.len()) != Some(WEBP.as_slice()) {
180        return Err(malformed(
181            MalformedDetail::MissingMarker,
182            as_offset(body_start),
183        ));
184    }
185    // Cannot overflow: `size <= r.remaining()` was checked at `body_start`.
186    let end = body_start.saturating_add(size);
187
188    let mut chunks: Vec<Chunk<'a>> = Vec::new();
189    let mut budget = limits.max_items;
190
191    while r.position() < end {
192        if budget == 0 {
193            return Err(StryptError::LimitExceeded {
194                format: Format::Webp,
195                limit: ResourceLimit::ItemCount,
196            });
197        }
198        budget = budget.saturating_sub(1);
199
200        let start = r.position();
201        let kind: [u8; 4] = r
202            .take(4)
203            .and_then(|k| k.try_into().ok())
204            .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
205        if !kind.iter().all(|b| b.is_ascii_graphic() || *b == b' ') {
206            // A four-character code is ASCII by definition, and the defined ones include a
207            // space (`VP8 `, `XMP `). Anything else means the walk is no longer where it
208            // thinks it is, and continuing would be slicing arbitrary bytes out of a file
209            // while reporting confidently about them.
210            return Err(malformed(
211                MalformedDetail::UnexpectedMarker,
212                as_offset(start),
213            ));
214        }
215
216        let declared = r
217            .u32_le()
218            .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
219        if &kind == b"VP8X" && declared != VP8X_PAYLOAD_BYTES {
220            // §2.7 fixes this chunk's length. A different one means the flags byte and the
221            // canvas dimensions are not where the specification puts them, so the handler
222            // cannot correct the flags and must not guess.
223            return Err(malformed(
224                MalformedDetail::LengthOutOfRange,
225                as_offset(start),
226            ));
227        }
228        let length = u32_to_usize(declared)
229            .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(start)))?;
230
231        // §2.3: an odd-length payload is followed by one padding byte, which must be zero.
232        let padding = length & 1;
233        let padded = length
234            .checked_add(padding)
235            .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(start)))?;
236        if padded > end.saturating_sub(r.position()) {
237            // The chunk claims more than the RIFF size says is left, which is the field a
238            // hostile file lies about.
239            return Err(malformed(
240                MalformedDetail::LengthOutOfRange,
241                as_offset(start),
242            ));
243        }
244
245        let data = r
246            .take(length)
247            .ok_or_else(|| malformed(MalformedDetail::LengthOutOfRange, as_offset(start)))?;
248        r.skip(padding)
249            .ok_or_else(|| malformed(MalformedDetail::Truncated, as_offset(start)))?;
250        let raw = input.get(start..r.position()).unwrap_or_default();
251
252        chunks.push(Chunk { kind, data, raw });
253    }
254
255    validate_shape(&chunks, body_start)?;
256    Ok((chunks, input.get(end..).unwrap_or_default()))
257}
258
259/// Refuse a chunk list that is not a shape this handler has understood.
260///
261/// Both checks exist to stop the handler emitting something that passes for a WebP file and is
262/// not one. The second matters most: a file consisting of nothing but a `VP8X` and an `EXIF`
263/// chunk would otherwise strip to a container with no picture in it, and be reported as a
264/// success.
265fn validate_shape(chunks: &[Chunk<'_>], body_start: usize) -> Result<()> {
266    // §2.7: an extended file opens with `VP8X`, and a simple file is one bitstream chunk.
267    let opens_correctly =
268        matches!(chunks.first(), Some(c) if matches!(&c.kind, b"VP8X" | b"VP8 " | b"VP8L"));
269    if !opens_correctly {
270        return Err(malformed(
271            MalformedDetail::MissingMarker,
272            as_offset(body_start),
273        ));
274    }
275    let has_picture = chunks
276        .iter()
277        .any(|c| matches!(&c.kind, b"VP8 " | b"VP8L" | b"ANMF"));
278    if !has_picture {
279        return Err(malformed(
280            MalformedDetail::MissingMarker,
281            as_offset(body_start),
282        ));
283    }
284    Ok(())
285}
286
287/// What to do with one chunk.
288enum Outcome {
289    /// Copy it through unchanged.
290    Keep,
291    /// Copy it through with the given bytes in its place. Used only by `VP8X`, and only when
292    /// its flags no longer match what the file contains.
293    Replace(Vec<u8>),
294    /// Remove it entirely.
295    Drop,
296}
297
298/// A decision about one chunk, with what to tell the user about it.
299struct Decision {
300    outcome: Outcome,
301    findings: Vec<Finding>,
302    /// Anything kept on purpose. Separate from `findings` because the verification pass
303    /// requires that nothing `inspect` reports as a finding survives a strip — a chunk that is
304    /// deliberately kept has to be declared, not reported as removed.
305    retained: Vec<Retained>,
306    notes: Vec<Note>,
307}
308
309impl Decision {
310    const fn keep() -> Self {
311        Self {
312            outcome: Outcome::Keep,
313            findings: Vec::new(),
314            retained: Vec::new(),
315            notes: Vec::new(),
316        }
317    }
318
319    fn drop_with(findings: Vec<Finding>) -> Self {
320        Self {
321            outcome: Outcome::Drop,
322            findings,
323            retained: Vec::new(),
324            notes: Vec::new(),
325        }
326    }
327
328    fn drop_one(kind: MetadataKind, location: impl Into<String>, bytes: u64) -> Self {
329        Self::drop_with(vec![Finding::new(kind, location, bytes)])
330    }
331}
332
333/// Walk `input`, decide about every chunk, and build the sanitised file.
334fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
335    let (chunks, trailing) = walk(input, limits)?;
336
337    let mut findings = Vec::new();
338    let mut retained = Vec::new();
339    let mut notes = Vec::new();
340
341    // The RIFF payload is assembled first, because the size field in front of it is the one
342    // field in a WebP file that cannot be copied and has to be computed.
343    let mut body: Vec<u8> = Vec::with_capacity(input.len());
344    body.extend_from_slice(WEBP);
345
346    for chunk in &chunks {
347        let decision = decide(chunk, options, limits);
348        notes.extend(decision.notes);
349        retained.extend(decision.retained);
350        findings.extend(decision.findings);
351        match decision.outcome {
352            Outcome::Keep => body.extend_from_slice(chunk.raw),
353            Outcome::Replace(bytes) => body.extend_from_slice(&bytes),
354            Outcome::Drop => {}
355        }
356    }
357
358    if !trailing.is_empty() {
359        // Nothing reads past the length the RIFF header declares, and few users know anything
360        // can be there. It is a convenient place to keep a second copy of an image whose
361        // visible version was cropped.
362        let kind = if trailing.starts_with(RIFF) {
363            MetadataKind::Thumbnail
364        } else {
365            MetadataKind::Other
366        };
367        findings.push(Finding::new(
368            kind,
369            "trailing data after the RIFF chunk",
370            as_u64(trailing.len()),
371        ));
372    }
373
374    // Unreachable in practice: the output body is never larger than the input's declared RIFF
375    // size, which was itself read as a `u32`. Written as a refusal rather than a saturating
376    // cast because the alternative is a file whose size field lies.
377    let size = u32::try_from(body.len())
378        .map_err(|_| malformed(MalformedDetail::LengthOutOfRange, None))?;
379
380    let mut output = Vec::with_capacity(body.len().saturating_add(CHUNK_HEADER_BYTES));
381    output.extend_from_slice(RIFF);
382    output.extend_from_slice(&size.to_le_bytes());
383    output.extend_from_slice(&body);
384
385    Ok(Processed {
386        findings,
387        retained,
388        notes,
389        output,
390    })
391}
392
393/// Decide about one chunk.
394fn decide(chunk: &Chunk<'_>, options: &InspectOptions, limits: &ParseLimits) -> Decision {
395    let size = as_u64(chunk.data.len());
396    match &chunk.kind {
397        // The extended-format header. Kept, with its metadata flags corrected to match the
398        // file it now describes.
399        b"VP8X" => extended_header(chunk),
400        // The picture, the alpha channel, and the animation's global parameters — background
401        // colour and loop count, neither of which names anyone.
402        b"VP8 " | b"VP8L" | b"ALPH" | b"ANIM" => Decision::keep(),
403        // One animation frame, which is a container of its own.
404        b"ANMF" => animation_frame(chunk),
405        // An embedded ICC colour profile. A per-device profile is a fingerprint, and its
406        // internal tags routinely carry the vendor, the model, and the calibration date.
407        b"ICCP" => Decision::drop_one(MetadataKind::ColourProfile, "ICCP", size),
408        b"EXIF" => exif_chunk(chunk.data, size, options, limits),
409        b"XMP " => Decision::drop_with(xmp::scan(chunk.data, "XMP", options)),
410        _ => {
411            // §2.7.1.6 tells readers to ignore an unknown chunk and writers to preserve it.
412            // strypt deliberately does the opposite of the second half: it is not a general
413            // WebP writer, and an unknown chunk is precisely where a producer or an attacker
414            // puts something they do not want a metadata tool to look at. A scrubber that
415            // copies through what it does not understand is not scrubbing. Because the same
416            // section makes unknown chunks ignorable, dropping one cannot break a decoder —
417            // which is why this handler has no equivalent of PNG's unknown-critical-chunk
418            // dilemma (ADR-0022).
419            Decision::drop_one(MetadataKind::Other, name_of(&chunk.kind), size)
420        }
421    }
422}
423
424/// `VP8X`: the extended-format header, whose flags declare what else the file contains.
425///
426/// Copied byte for byte when its metadata bits are already clear, so an extended file with
427/// nothing to remove still strips to an identical copy of itself. Otherwise the flags byte is
428/// rewritten with those three bits cleared and every other byte of the chunk carried across
429/// unchanged (ADR-0023).
430fn extended_header(chunk: &Chunk<'_>) -> Decision {
431    let Some(flags) = chunk.data.first().copied() else {
432        // Unreachable: `walk` refuses a `VP8X` that is not exactly ten bytes long.
433        return Decision::keep();
434    };
435    if flags & METADATA_FLAGS == 0 {
436        return Decision::keep();
437    }
438
439    let mut rewritten = Vec::with_capacity(chunk.raw.len());
440    rewritten.extend_from_slice(b"VP8X");
441    rewritten.extend_from_slice(&VP8X_PAYLOAD_BYTES.to_le_bytes());
442    rewritten.push(flags & !METADATA_FLAGS);
443    // The reserved bits and the canvas dimensions. The payload is ten bytes, so it is even
444    // and there is no padding byte to reproduce.
445    rewritten.extend_from_slice(chunk.data.get(1..).unwrap_or_default());
446    Decision {
447        outcome: Outcome::Replace(rewritten),
448        findings: Vec::new(),
449        retained: Vec::new(),
450        notes: Vec::new(),
451    }
452}
453
454/// `ANMF`: one animation frame — a fixed header, then the frame's own sub-chunks.
455///
456/// §2.7.1.1 allows a frame to carry an optional list of *unknown* chunks alongside its alpha
457/// and bitstream sub-chunks, which makes the inside of a frame a hiding place with the
458/// specification's blessing. So the sub-chunk area is walked and filtered the same way the
459/// top level is: the picture chunks are copied through byte for byte, anything else is
460/// removed and reported.
461///
462/// The frame header is copied verbatim. Nothing in it depends on the sub-chunks that follow —
463/// the frame's position, size, duration, and blending flags are all self-contained — so
464/// dropping a sub-chunk cannot leave the header describing something that is no longer there.
465///
466/// A sub-chunk area that does not parse is left exactly as it arrived, with a note saying so.
467/// Refusing the whole file would be the wrong call for a frame strypt only partly understands,
468/// and silently keeping it would let the user believe the frame had been scrubbed.
469fn animation_frame(chunk: &Chunk<'_>) -> Decision {
470    let Some(header) = chunk.data.get(0..ANMF_HEADER_BYTES) else {
471        return unexamined("ANMF", as_u64(chunk.data.len()));
472    };
473    let Some(rest) = chunk.data.get(ANMF_HEADER_BYTES..) else {
474        return unexamined("ANMF", as_u64(chunk.data.len()));
475    };
476
477    let Some(sub_chunks) = walk_sub_chunks(rest) else {
478        return unexamined("ANMF", as_u64(chunk.data.len()));
479    };
480
481    let mut findings = Vec::new();
482    let mut payload = Vec::with_capacity(chunk.data.len());
483    payload.extend_from_slice(header);
484    for sub in &sub_chunks {
485        if IMAGE_CHUNKS.contains(&&sub.kind) {
486            payload.extend_from_slice(sub.raw);
487        } else {
488            findings.push(Finding::new(
489                MetadataKind::Other,
490                format!("ANMF {}", name_of(&sub.kind)),
491                as_u64(sub.data.len()),
492            ));
493        }
494    }
495
496    if findings.is_empty() {
497        // Nothing was dropped, so the frame is copied rather than reassembled — which keeps
498        // an ordinary animation byte-identical through a strip.
499        return Decision::keep();
500    }
501
502    let Ok(size) = u32::try_from(payload.len()) else {
503        // Unreachable: the rebuilt payload is never larger than the one that was parsed.
504        return unexamined("ANMF", as_u64(chunk.data.len()));
505    };
506    let mut rewritten = Vec::with_capacity(payload.len().saturating_add(CHUNK_HEADER_BYTES + 1));
507    rewritten.extend_from_slice(b"ANMF");
508    rewritten.extend_from_slice(&size.to_le_bytes());
509    rewritten.extend_from_slice(&payload);
510    if payload.len() & 1 == 1 {
511        rewritten.push(0);
512    }
513
514    Decision {
515        outcome: Outcome::Replace(rewritten),
516        findings,
517        retained: Vec::new(),
518        notes: Vec::new(),
519    }
520}
521
522/// Walk the sub-chunk area inside an `ANMF` payload, or [`None`] if it does not parse cleanly.
523///
524/// Deliberately total and deliberately strict: any short read, any lying length, and the whole
525/// area is declared not understood rather than half-parsed.
526fn walk_sub_chunks(data: &[u8]) -> Option<Vec<Chunk<'_>>> {
527    let mut r = Reader::new(data);
528    let mut out = Vec::new();
529    while !r.is_empty() {
530        let start = r.position();
531        let kind: [u8; 4] = r.take(4)?.try_into().ok()?;
532        if !kind.iter().all(|b| b.is_ascii_graphic() || *b == b' ') {
533            return None;
534        }
535        let length = u32_to_usize(r.u32_le()?)?;
536        let payload = r.take(length)?;
537        r.skip(length & 1)?;
538        out.push(Chunk {
539            kind,
540            data: payload,
541            // Every iteration consumes at least the eight bytes of a header, so this loop
542            // cannot spin on a zero-length chunk.
543            raw: data.get(start..r.position())?,
544        });
545    }
546    Some(out)
547}
548
549/// `EXIF`: a raw TIFF block, byte-order mark first.
550fn exif_chunk(data: &[u8], size: u64, options: &InspectOptions, limits: &ParseLimits) -> Decision {
551    let tiff = if data.starts_with(EXIF_INTRODUCER) {
552        data.get(EXIF_INTRODUCER.len()..).unwrap_or_default()
553    } else {
554        data
555    };
556    let scanned = exif::scan(tiff, "EXIF", options, limits);
557    let findings = if scanned.findings.is_empty() {
558        // An Exif block that named nothing is still an Exif block, and it is still going.
559        vec![Finding::new(MetadataKind::Other, "EXIF", size)]
560    } else {
561        scanned.findings
562    };
563    Decision {
564        outcome: Outcome::Drop,
565        findings,
566        retained: Vec::new(),
567        notes: scanned.notes,
568    }
569}
570
571/// Keep a region untouched and say plainly that its bytes went by unexamined.
572fn unexamined(location: &'static str, bytes: u64) -> Decision {
573    Decision {
574        outcome: Outcome::Keep,
575        findings: Vec::new(),
576        retained: Vec::new(),
577        notes: vec![Note::UnparsedRegion {
578            location: location.to_owned(),
579            bytes,
580        }],
581    }
582}
583
584/// A four-character code as a reportable name.
585///
586/// Trailing spaces are padding, not part of the name — the defined codes include `VP8 ` and
587/// `XMP ` — and a report reads better without them.
588fn name_of(kind: &[u8]) -> String {
589    xmp::name_of(kind).trim_end().to_owned()
590}
591
592/// A malformed-file error for this format.
593fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
594    StryptError::Malformed {
595        format: Format::Webp,
596        offset,
597        detail,
598    }
599}
600
601/// A byte position as a reportable offset.
602fn as_offset(position: usize) -> Option<u64> {
603    u64::try_from(position).ok()
604}
605
606/// Widen a length for reporting. Saturating: a report field is not worth failing a strip over.
607fn as_u64(value: usize) -> u64 {
608    u64::try_from(value).unwrap_or(u64::MAX)
609}
610
611#[cfg(test)]
612mod tests {
613    // Test code is never reachable from untrusted bytes, which is the boundary the
614    // panic-freedom lints exist to police (ADR-0006).
615    #![allow(
616        clippy::unwrap_used,
617        clippy::expect_used,
618        clippy::indexing_slicing,
619        clippy::arithmetic_side_effects
620    )]
621
622    use super::*;
623    use crate::report::MetadataValue;
624
625    /// One chunk: code, little-endian size, payload, and a padding byte when the size is odd.
626    fn chunk(kind: &[u8], data: &[u8]) -> Vec<u8> {
627        let mut out = kind.to_vec();
628        out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_le_bytes());
629        out.extend_from_slice(data);
630        if data.len() % 2 == 1 {
631            out.push(0);
632        }
633        out
634    }
635
636    /// A RIFF/WEBP container around the given chunks, with a correct size field.
637    fn webp(chunks: &[Vec<u8>]) -> Vec<u8> {
638        let mut body = WEBP.to_vec();
639        for c in chunks {
640            body.extend_from_slice(c);
641        }
642        let mut out = RIFF.to_vec();
643        out.extend_from_slice(&u32::try_from(body.len()).unwrap().to_le_bytes());
644        out.extend_from_slice(&body);
645        out
646    }
647
648    /// A `VP8X` payload with the given flags and a 16x16 canvas.
649    fn vp8x(flags: u8) -> Vec<u8> {
650        let mut data = vec![flags, 0, 0, 0];
651        data.extend_from_slice(&15u32.to_le_bytes()[0..3]);
652        data.extend_from_slice(&15u32.to_le_bytes()[0..3]);
653        chunk(b"VP8X", &data)
654    }
655
656    /// A stand-in lossless bitstream. Its contents are never parsed by this handler.
657    fn bitstream() -> Vec<u8> {
658        chunk(b"VP8L", b"SYNTHETIC-PIXELS")
659    }
660
661    fn strip_ok(data: &[u8]) -> Stripped {
662        WebpHandler
663            .strip(data, &StripOptions::default())
664            .expect("strip failed")
665    }
666
667    fn findings(data: &[u8]) -> Vec<Finding> {
668        WebpHandler
669            .inspect(data, &InspectOptions::names_only())
670            .expect("inspect failed")
671            .findings
672    }
673
674    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
675        haystack.windows(needle.len()).any(|w| w == needle)
676    }
677
678    #[test]
679    fn the_picture_is_never_touched() {
680        let input = webp(&[
681            vp8x(0b0000_1000),
682            bitstream(),
683            chunk(b"EXIF", b"II\x2A\x00\x08\x00\x00\x00\x00\x00"),
684        ]);
685        let output = strip_ok(&input).bytes;
686        assert!(
687            contains(&output, b"SYNTHETIC-PIXELS"),
688            "the image data did not survive byte for byte"
689        );
690    }
691
692    #[test]
693    fn a_simple_file_cannot_carry_metadata_and_comes_back_byte_identical() {
694        // A file with no `VP8X` has nowhere to put an ICC profile, Exif, or XMP: §2.7 requires
695        // the extended header before any of them. So this is not merely "nothing was found" —
696        // it is a guaranteed pass-through, and asserting it keeps that guarantee honest.
697        for payload in [chunk(b"VP8L", b"SYNTHETIC-PIXELS"), chunk(b"VP8 ", b"ODD")] {
698            let input = webp(&[payload]);
699            let stripped = strip_ok(&input);
700            assert!(stripped.report.removed.is_empty());
701            assert_eq!(
702                stripped.bytes, input,
703                "a simple WebP was not passed through"
704            );
705        }
706    }
707
708    #[test]
709    fn a_clean_extended_file_comes_back_byte_identical_too() {
710        // The alpha and animation bits are not metadata flags, so a `VP8X` carrying only those
711        // is copied rather than rewritten.
712        let input = webp(&[vp8x(0b0001_0000), chunk(b"ALPH", b"A"), bitstream()]);
713        let stripped = strip_ok(&input);
714        assert!(stripped.report.removed.is_empty());
715        assert_eq!(stripped.bytes, input);
716    }
717
718    #[test]
719    fn the_metadata_chunks_are_removed_and_the_header_flags_follow() {
720        let mut tiff = b"II\x2A\x00\x08\x00\x00\x00".to_vec();
721        tiff.extend_from_slice(&1u16.to_le_bytes());
722        tiff.extend_from_slice(&0x010Fu16.to_le_bytes()); // Make
723        tiff.extend_from_slice(&2u16.to_le_bytes()); // ASCII
724        tiff.extend_from_slice(&4u32.to_le_bytes());
725        tiff.extend_from_slice(b"ACME");
726        tiff.extend_from_slice(&0u32.to_le_bytes());
727
728        // ICC, alpha, Exif, and XMP all declared.
729        let input = webp(&[
730            vp8x(0b0011_1100),
731            chunk(b"ICCP", b"SYNTHETIC-PROFILE-0001"),
732            chunk(b"ALPH", b"A"),
733            bitstream(),
734            chunk(b"EXIF", &tiff),
735            chunk(
736                b"XMP ",
737                b"<x:xmpmeta><dc:creator>SYNTHETIC-0002</dc:creator></x:xmpmeta>",
738            ),
739        ]);
740
741        let found = findings(&input);
742        let kinds: Vec<MetadataKind> = found.iter().map(|f| f.kind).collect();
743        assert!(kinds.contains(&MetadataKind::ColourProfile));
744        assert!(kinds.contains(&MetadataKind::DeviceIdentity));
745        assert!(kinds.contains(&MetadataKind::PersonalIdentity));
746
747        let output = strip_ok(&input).bytes;
748        assert!(!contains(&output, b"SYNTHETIC-PROFILE-0001"));
749        assert!(!contains(&output, b"ACME"));
750        assert!(!contains(&output, b"SYNTHETIC-0002"));
751        assert!(findings(&output).is_empty());
752
753        // The header now describes the file it is actually in: ICC, Exif, and XMP cleared,
754        // alpha untouched.
755        let flags = output[20];
756        assert_eq!(
757            flags, 0b0001_0000,
758            "the VP8X flags still claim metadata that is gone"
759        );
760    }
761
762    #[test]
763    fn flags_that_were_already_lying_are_corrected_even_with_nothing_to_remove() {
764        // A file whose header claims an Exif chunk it does not have. Nothing is removable, so
765        // `show` reports nothing — and `strip` still hands back a file that tells the truth.
766        let input = webp(&[vp8x(0b0000_1100), bitstream()]);
767        let stripped = strip_ok(&input);
768        assert!(stripped.report.removed.is_empty());
769        assert_eq!(stripped.bytes[20], 0);
770        assert_ne!(stripped.bytes, input);
771    }
772
773    #[test]
774    fn an_exif_chunk_written_with_a_jpeg_introducer_is_still_read() {
775        // The container specification puts no introducer here, but a producer copying a JPEG
776        // `APP1` payload across brings one. Feeding those six bytes to the TIFF reader would
777        // shift every offset in the block.
778        let mut tiff = b"II\x2A\x00\x08\x00\x00\x00".to_vec();
779        tiff.extend_from_slice(&1u16.to_le_bytes());
780        tiff.extend_from_slice(&0x0110u16.to_le_bytes()); // Model
781        tiff.extend_from_slice(&2u16.to_le_bytes());
782        tiff.extend_from_slice(&4u32.to_le_bytes());
783        tiff.extend_from_slice(b"MDL1");
784        tiff.extend_from_slice(&0u32.to_le_bytes());
785
786        let mut payload = EXIF_INTRODUCER.to_vec();
787        payload.extend_from_slice(&tiff);
788        let input = webp(&[vp8x(0b0000_1000), bitstream(), chunk(b"EXIF", &payload)]);
789
790        let found = findings(&input);
791        assert_eq!(found[0].field.as_deref(), Some("Model"));
792    }
793
794    #[test]
795    fn an_unknown_chunk_is_removed_rather_than_preserved() {
796        // §2.7.1.6 asks writers to preserve unknown chunks. strypt is not a general writer,
797        // and an unknown chunk can hold anything at all.
798        let input = webp(&[
799            vp8x(0),
800            bitstream(),
801            chunk(b"PRVW", b"SYNTHETIC-PREVIEW-0003"),
802        ]);
803        let stripped = strip_ok(&input);
804        assert!(!contains(&stripped.bytes, b"SYNTHETIC-PREVIEW-0003"));
805        assert_eq!(stripped.report.removed[0].location, "PRVW");
806    }
807
808    #[test]
809    fn an_animation_survives_and_a_chunk_hidden_in_a_frame_does_not() {
810        let mut frame = vec![0u8; ANMF_HEADER_BYTES];
811        frame.extend_from_slice(&chunk(b"VP8L", b"SYNTHETIC-FRAME-PIXELS"));
812        let clean = webp(&[
813            vp8x(0b0000_0010),
814            chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]),
815            chunk(b"ANMF", &frame),
816        ]);
817        assert_eq!(strip_ok(&clean).bytes, clean, "an animation was rewritten");
818
819        let mut hostile = vec![0u8; ANMF_HEADER_BYTES];
820        hostile.extend_from_slice(&chunk(b"VP8L", b"SYNTHETIC-FRAME-PIXELS"));
821        hostile.extend_from_slice(&chunk(b"JUNK", b"SYNTHETIC-IN-FRAME-0004"));
822        let input = webp(&[
823            vp8x(0b0000_0010),
824            chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]),
825            chunk(b"ANMF", &hostile),
826        ]);
827        let stripped = strip_ok(&input);
828        assert!(!contains(&stripped.bytes, b"SYNTHETIC-IN-FRAME-0004"));
829        assert!(
830            contains(&stripped.bytes, b"SYNTHETIC-FRAME-PIXELS"),
831            "the frame's picture did not survive"
832        );
833        assert_eq!(stripped.report.removed[0].location, "ANMF JUNK");
834    }
835
836    #[test]
837    fn a_frame_that_does_not_parse_is_kept_and_declared_unexamined() {
838        let mut frame = vec![0u8; ANMF_HEADER_BYTES];
839        frame.extend_from_slice(b"VP8L\xff\xff\xff\xffPRESERVED-0005");
840        let input = webp(&[
841            vp8x(0b0000_0010),
842            chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]),
843            chunk(b"ANMF", &frame),
844        ]);
845        let stripped = strip_ok(&input);
846        assert!(contains(&stripped.bytes, b"PRESERVED-0005"));
847        assert!(matches!(
848            stripped.report.notes.first(),
849            Some(Note::UnparsedRegion { location, .. }) if location == "ANMF"
850        ));
851    }
852
853    #[test]
854    fn data_after_the_riff_chunk_is_removed() {
855        let mut input = webp(&[vp8x(0), bitstream()]);
856        input.extend_from_slice(b"SYNTHETIC-APPENDED-0006");
857        let stripped = strip_ok(&input);
858        assert!(!contains(&stripped.bytes, b"SYNTHETIC-APPENDED-0006"));
859        assert_eq!(
860            stripped.report.removed[0].location,
861            "trailing data after the RIFF chunk"
862        );
863    }
864
865    #[test]
866    fn a_second_file_after_the_riff_chunk_is_reported_as_a_thumbnail() {
867        let mut input = webp(&[vp8x(0), bitstream()]);
868        input.extend_from_slice(&webp(&[bitstream()]));
869        assert_eq!(
870            strip_ok(&input).report.removed[0].kind,
871            MetadataKind::Thumbnail
872        );
873    }
874
875    #[test]
876    fn an_xmp_packet_is_itemised_by_property() {
877        let input = webp(&[
878            vp8x(0b0000_0100),
879            bitstream(),
880            chunk(
881                b"XMP ",
882                br#"<x:xmpmeta xmpMM:DocumentID="uuid:1" xmp:CreatorTool="SYNTHETIC"/>"#,
883            ),
884        ]);
885        let found = findings(&input);
886        let fields: Vec<&str> = found.iter().filter_map(|f| f.field.as_deref()).collect();
887        assert!(fields.contains(&"xmpMM:DocumentID"), "{fields:?}");
888        assert!(fields.contains(&"xmp:CreatorTool"), "{fields:?}");
889    }
890
891    #[test]
892    fn values_are_withheld_from_a_default_inspection() {
893        let mut tiff = b"II\x2A\x00\x08\x00\x00\x00".to_vec();
894        tiff.extend_from_slice(&1u16.to_le_bytes());
895        tiff.extend_from_slice(&0x010Fu16.to_le_bytes());
896        tiff.extend_from_slice(&2u16.to_le_bytes());
897        tiff.extend_from_slice(&4u32.to_le_bytes());
898        tiff.extend_from_slice(b"ACME");
899        tiff.extend_from_slice(&0u32.to_le_bytes());
900        let input = webp(&[vp8x(0b0000_1000), bitstream(), chunk(b"EXIF", &tiff)]);
901
902        assert_eq!(findings(&input)[0].value, None);
903        let with_values = WebpHandler
904            .inspect(&input, &InspectOptions::with_values())
905            .unwrap();
906        assert_eq!(
907            with_values.findings[0].value,
908            Some(MetadataValue::Text("ACME".to_owned()))
909        );
910    }
911
912    #[test]
913    fn stripping_twice_changes_nothing() {
914        let input = webp(&[
915            vp8x(0b0011_1100),
916            chunk(b"ICCP", b"SYNTHETIC-PROFILE-0001"),
917            bitstream(),
918            chunk(b"XMP ", b"<x:xmpmeta/>"),
919        ]);
920        let once = strip_ok(&input).bytes;
921        let twice = strip_ok(&once).bytes;
922        assert_eq!(once, twice, "strip is not idempotent");
923    }
924
925    #[test]
926    fn a_riff_size_beyond_the_end_of_the_file_is_refused_rather_than_clamped() {
927        let mut input = webp(&[vp8x(0), bitstream()]);
928        input[4..8].copy_from_slice(&0x00FF_FFFFu32.to_le_bytes());
929        assert!(matches!(
930            WebpHandler.inspect(&input, &InspectOptions::names_only()),
931            Err(StryptError::Malformed {
932                detail: MalformedDetail::LengthOutOfRange,
933                ..
934            })
935        ));
936    }
937
938    #[test]
939    fn a_chunk_size_beyond_the_riff_extent_is_refused() {
940        let input = webp(&[vp8x(0), bitstream(), {
941            let mut lying = b"EXIF".to_vec();
942            lying.extend_from_slice(&0x0010_0000u32.to_le_bytes());
943            lying.extend_from_slice(b"II\x2A\x00");
944            lying
945        }]);
946        assert!(matches!(
947            WebpHandler.inspect(&input, &InspectOptions::names_only()),
948            Err(StryptError::Malformed {
949                detail: MalformedDetail::LengthOutOfRange,
950                ..
951            })
952        ));
953    }
954
955    #[test]
956    fn a_file_with_no_picture_chunk_is_refused_rather_than_emptied() {
957        // Otherwise this strips to a valid-looking container with no image in it, and the user
958        // is told it succeeded.
959        let input = webp(&[
960            vp8x(0b0000_1000),
961            chunk(b"EXIF", b"II\x2A\x00\x08\x00\x00\x00"),
962        ]);
963        assert!(matches!(
964            WebpHandler.strip(&input, &StripOptions::default()),
965            Err(StryptError::Malformed {
966                detail: MalformedDetail::MissingMarker,
967                ..
968            })
969        ));
970    }
971
972    #[test]
973    fn a_file_that_does_not_open_with_a_header_or_bitstream_chunk_is_refused() {
974        let input = webp(&[chunk(b"EXIF", b"II\x2A\x00\x08\x00\x00\x00"), bitstream()]);
975        assert!(matches!(
976            WebpHandler.inspect(&input, &InspectOptions::names_only()),
977            Err(StryptError::Malformed {
978                detail: MalformedDetail::MissingMarker,
979                ..
980            })
981        ));
982    }
983
984    #[test]
985    fn a_vp8x_of_the_wrong_length_is_refused() {
986        let input = webp(&[chunk(b"VP8X", &[0u8; 8]), bitstream()]);
987        assert!(matches!(
988            WebpHandler.inspect(&input, &InspectOptions::names_only()),
989            Err(StryptError::Malformed {
990                detail: MalformedDetail::LengthOutOfRange,
991                ..
992            })
993        ));
994    }
995
996    #[test]
997    fn a_four_character_code_that_is_not_ascii_is_refused() {
998        let input = webp(&[vp8x(0), bitstream(), chunk(b"\x00\x01\x02\x03", b"")]);
999        assert!(matches!(
1000            WebpHandler.inspect(&input, &InspectOptions::names_only()),
1001            Err(StryptError::Malformed {
1002                detail: MalformedDetail::UnexpectedMarker,
1003                ..
1004            })
1005        ));
1006    }
1007
1008    #[test]
1009    fn a_file_that_is_not_riff_or_not_webp_is_refused() {
1010        assert!(matches!(
1011            WebpHandler.inspect(b"RIFX\x04\x00\x00\x00WEBP", &InspectOptions::names_only()),
1012            Err(StryptError::Malformed { .. })
1013        ));
1014        assert!(matches!(
1015            WebpHandler.inspect(b"RIFF\x04\x00\x00\x00WAVE", &InspectOptions::names_only()),
1016            Err(StryptError::Malformed {
1017                detail: MalformedDetail::MissingMarker,
1018                ..
1019            })
1020        ));
1021    }
1022
1023    #[test]
1024    fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
1025        let input = webp(&[
1026            vp8x(0b0011_1100),
1027            chunk(b"ICCP", b"SYNTHETIC-PROFILE-0001"),
1028            bitstream(),
1029            chunk(b"EXIF", b"II\x2A\x00\x08\x00\x00\x00\x00\x00"),
1030            chunk(b"XMP ", b"<x:xmpmeta/>"),
1031        ]);
1032        for n in 0..=input.len() {
1033            let prefix = &input[0..n];
1034            let _ = WebpHandler.inspect(prefix, &InspectOptions::names_only());
1035            let _ = WebpHandler.strip(prefix, &StripOptions::default());
1036        }
1037    }
1038
1039    #[test]
1040    fn a_chunk_count_beyond_the_limit_is_refused() {
1041        let mut chunks = vec![vp8x(0), bitstream()];
1042        chunks.extend((0..64).map(|_| chunk(b"JUNK", b"x")));
1043        let input = webp(&chunks);
1044        let options = StripOptions {
1045            limits: ParseLimits {
1046                max_items: 8,
1047                ..ParseLimits::default()
1048            },
1049            ..StripOptions::default()
1050        };
1051        assert!(matches!(
1052            WebpHandler.strip(&input, &options),
1053            Err(StryptError::LimitExceeded { .. })
1054        ));
1055    }
1056
1057    #[test]
1058    fn an_odd_length_chunk_keeps_its_padding_byte() {
1059        // §2.3 pads an odd payload to an even boundary. A handler that dropped the pad when
1060        // copying a chunk through would shift every chunk after it by one byte.
1061        let input = webp(&[vp8x(0), chunk(b"VP8 ", b"ODD")]);
1062        let stripped = strip_ok(&input);
1063        assert_eq!(stripped.bytes, input);
1064        assert_eq!(stripped.bytes.len() % 2, 0);
1065    }
1066}