1use 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#[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 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
99const RIFF: &[u8; 4] = b"RIFF";
101const WEBP: &[u8; 4] = b"WEBP";
102
103const CHUNK_HEADER_BYTES: usize = 8;
105
106const VP8X_PAYLOAD_BYTES: u32 = 10;
109
110const ANMF_HEADER_BYTES: usize = 16;
113
114const METADATA_FLAGS: u8 = 0b0010_1100;
121
122const EXIF_INTRODUCER: &[u8] = b"Exif\x00\x00";
128
129const IMAGE_CHUNKS: [&[u8; 4]; 3] = [b"ALPH", b"VP8 ", b"VP8L"];
134
135struct Chunk<'a> {
137 kind: [u8; 4],
139 data: &'a [u8],
141 raw: &'a [u8],
144}
145
146struct Processed {
148 findings: Vec<Finding>,
149 retained: Vec<Retained>,
150 notes: Vec<Note>,
151 output: Vec<u8>,
152}
153
154fn 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 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 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 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 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 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 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 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
259fn validate_shape(chunks: &[Chunk<'_>], body_start: usize) -> Result<()> {
266 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
287enum Outcome {
289 Keep,
291 Replace(Vec<u8>),
294 Drop,
296}
297
298struct Decision {
300 outcome: Outcome,
301 findings: Vec<Finding>,
302 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
333fn 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 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 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 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
393fn decide(chunk: &Chunk<'_>, options: &InspectOptions, limits: &ParseLimits) -> Decision {
395 let size = as_u64(chunk.data.len());
396 match &chunk.kind {
397 b"VP8X" => extended_header(chunk),
400 b"VP8 " | b"VP8L" | b"ALPH" | b"ANIM" => Decision::keep(),
403 b"ANMF" => animation_frame(chunk),
405 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 Decision::drop_one(MetadataKind::Other, name_of(&chunk.kind), size)
420 }
421 }
422}
423
424fn extended_header(chunk: &Chunk<'_>) -> Decision {
431 let Some(flags) = chunk.data.first().copied() else {
432 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 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
454fn 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 return Decision::keep();
500 }
501
502 let Ok(size) = u32::try_from(payload.len()) else {
503 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
522fn 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 raw: data.get(start..r.position())?,
544 });
545 }
546 Some(out)
547}
548
549fn 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 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
571fn 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
584fn name_of(kind: &[u8]) -> String {
589 xmp::name_of(kind).trim_end().to_owned()
590}
591
592fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
594 StryptError::Malformed {
595 format: Format::Webp,
596 offset,
597 detail,
598 }
599}
600
601fn as_offset(position: usize) -> Option<u64> {
603 u64::try_from(position).ok()
604}
605
606fn as_u64(value: usize) -> u64 {
608 u64::try_from(value).unwrap_or(u64::MAX)
609}
610
611#[cfg(test)]
612mod tests {
613 #![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 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 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 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 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 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 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()); tiff.extend_from_slice(&2u16.to_le_bytes()); 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 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 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 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 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()); 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 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 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 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}