use crate::container::riff::{self, Chunk, WalkError, name_of};
use crate::detect::Format;
use crate::error::{MalformedDetail, Result, StryptError};
use crate::formats::{MetadataHandler, ParseLimits, StripOptions, Stripped, exif, xmp};
use crate::report::{
Finding, InspectOptions, MetadataKind, MetadataReport, Note, Retained, StripReport,
};
#[derive(Debug, Clone, Copy, Default)]
pub struct WebpHandler;
impl MetadataHandler for WebpHandler {
fn name(&self) -> &'static str {
Format::Webp.id()
}
fn format(&self) -> Format {
Format::Webp
}
fn inspect(&self, input: &[u8], options: &InspectOptions) -> Result<MetadataReport> {
let processed = process(input, options, &ParseLimits::default())?;
Ok(MetadataReport {
format: Format::Webp,
findings: processed.findings,
notes: processed.notes,
})
}
fn strip(&self, input: &[u8], options: &StripOptions) -> Result<Stripped> {
let processed = process(input, &options.inspect, &options.limits)?;
Ok(Stripped {
report: StripReport {
format: Format::Webp,
removed: processed.findings,
retained: processed.retained,
notes: processed.notes,
input_bytes: as_u64(input.len()),
output_bytes: as_u64(processed.output.len()),
},
bytes: processed.output,
})
}
}
const WEBP: riff::FourCc = *b"WEBP";
const VP8X_PAYLOAD_BYTES: u32 = 10;
const ANMF_HEADER_BYTES: usize = 16;
const METADATA_FLAGS: u8 = 0b0010_1100;
const EXIF_INTRODUCER: &[u8] = b"Exif\x00\x00";
const IMAGE_CHUNKS: [&[u8; 4]; 3] = [b"ALPH", b"VP8 ", b"VP8L"];
struct Processed {
findings: Vec<Finding>,
retained: Vec<Retained>,
notes: Vec<Note>,
output: Vec<u8>,
}
fn walk<'a>(input: &'a [u8], limits: &ParseLimits) -> Result<(Vec<Chunk<'a>>, &'a [u8])> {
let mut budget = limits.max_items;
let (chunks, trailing) = riff::read(input, WEBP, &mut budget).map_err(convert)?;
validate_shape(&chunks)?;
Ok((chunks, trailing))
}
fn convert(error: WalkError) -> StryptError {
match error {
WalkError::Malformed { detail, offset } => malformed(detail, as_offset(offset)),
WalkError::Limit(limit) => StryptError::LimitExceeded {
format: Format::Webp,
limit,
},
}
}
fn validate_shape(chunks: &[Chunk<'_>]) -> Result<()> {
let body_start = riff::HEADER_BYTES;
for chunk in chunks {
if &chunk.kind == b"VP8X" && as_u64(chunk.data.len()) != u64::from(VP8X_PAYLOAD_BYTES) {
return Err(malformed(
MalformedDetail::LengthOutOfRange,
as_offset(chunk.offset),
));
}
}
let opens_correctly =
matches!(chunks.first(), Some(c) if matches!(&c.kind, b"VP8X" | b"VP8 " | b"VP8L"));
if !opens_correctly {
return Err(malformed(
MalformedDetail::MissingMarker,
as_offset(body_start),
));
}
let has_picture = chunks
.iter()
.any(|c| matches!(&c.kind, b"VP8 " | b"VP8L" | b"ANMF"));
if !has_picture {
return Err(malformed(
MalformedDetail::MissingMarker,
as_offset(body_start),
));
}
Ok(())
}
enum Outcome {
Keep,
Replace(Vec<u8>),
Drop,
}
struct Decision {
outcome: Outcome,
findings: Vec<Finding>,
retained: Vec<Retained>,
notes: Vec<Note>,
}
impl Decision {
const fn keep() -> Self {
Self {
outcome: Outcome::Keep,
findings: Vec::new(),
retained: Vec::new(),
notes: Vec::new(),
}
}
fn drop_with(findings: Vec<Finding>) -> Self {
Self {
outcome: Outcome::Drop,
findings,
retained: Vec::new(),
notes: Vec::new(),
}
}
fn drop_one(kind: MetadataKind, location: impl Into<String>, bytes: u64) -> Self {
Self::drop_with(vec![Finding::new(kind, location, bytes)])
}
}
fn process(input: &[u8], options: &InspectOptions, limits: &ParseLimits) -> Result<Processed> {
let (chunks, trailing) = walk(input, limits)?;
let mut findings = Vec::new();
let mut retained = Vec::new();
let mut notes = Vec::new();
let mut body: Vec<u8> = Vec::with_capacity(input.len());
for chunk in &chunks {
let decision = decide(chunk, options, limits);
notes.extend(decision.notes);
retained.extend(decision.retained);
findings.extend(decision.findings);
match decision.outcome {
Outcome::Keep => body.extend_from_slice(chunk.raw),
Outcome::Replace(bytes) => body.extend_from_slice(&bytes),
Outcome::Drop => {}
}
}
if !trailing.is_empty() {
let kind = if trailing.starts_with(&riff::RIFF) {
MetadataKind::Thumbnail
} else {
MetadataKind::Other
};
findings.push(Finding::new(
kind,
"trailing data after the RIFF chunk",
as_u64(trailing.len()),
));
}
let output = riff::write(WEBP, &body).map_err(|d| malformed(d, None))?;
Ok(Processed {
findings,
retained,
notes,
output,
})
}
fn decide(chunk: &Chunk<'_>, options: &InspectOptions, limits: &ParseLimits) -> Decision {
let size = as_u64(chunk.data.len());
match &chunk.kind {
b"VP8X" => extended_header(chunk),
b"VP8 " | b"VP8L" | b"ALPH" | b"ANIM" => Decision::keep(),
b"ANMF" => animation_frame(chunk, limits),
b"ICCP" => Decision::drop_one(MetadataKind::ColourProfile, "ICCP", size),
b"EXIF" => exif_chunk(chunk.data, size, options, limits),
b"XMP " => Decision::drop_with(xmp::scan(chunk.data, "XMP", options)),
_ => {
Decision::drop_one(MetadataKind::Other, name_of(&chunk.kind), size)
}
}
}
fn extended_header(chunk: &Chunk<'_>) -> Decision {
let Some(flags) = chunk.data.first().copied() else {
return Decision::keep();
};
if flags & METADATA_FLAGS == 0 {
return Decision::keep();
}
let mut rewritten = Vec::with_capacity(chunk.raw.len());
rewritten.extend_from_slice(b"VP8X");
rewritten.extend_from_slice(&VP8X_PAYLOAD_BYTES.to_le_bytes());
rewritten.push(flags & !METADATA_FLAGS);
rewritten.extend_from_slice(chunk.data.get(1..).unwrap_or_default());
Decision {
outcome: Outcome::Replace(rewritten),
findings: Vec::new(),
retained: Vec::new(),
notes: Vec::new(),
}
}
fn animation_frame(chunk: &Chunk<'_>, limits: &ParseLimits) -> Decision {
let Some(header) = chunk.data.get(0..ANMF_HEADER_BYTES) else {
return unexamined("ANMF", as_u64(chunk.data.len()));
};
let Some(rest) = chunk.data.get(ANMF_HEADER_BYTES..) else {
return unexamined("ANMF", as_u64(chunk.data.len()));
};
let mut budget = limits.max_items;
let Ok(sub_chunks) = riff::chunks(rest, 0, &mut budget) else {
return unexamined("ANMF", as_u64(chunk.data.len()));
};
let mut findings = Vec::new();
let mut payload = Vec::with_capacity(chunk.data.len());
payload.extend_from_slice(header);
for sub in &sub_chunks {
if IMAGE_CHUNKS.contains(&&sub.kind) {
payload.extend_from_slice(sub.raw);
} else {
findings.push(Finding::new(
MetadataKind::Other,
format!("ANMF {}", name_of(&sub.kind)),
as_u64(sub.data.len()),
));
}
}
if findings.is_empty() {
return Decision::keep();
}
let mut rewritten = Vec::with_capacity(payload.len().saturating_add(riff::HEADER_BYTES + 1));
if riff::write_chunk(&mut rewritten, *b"ANMF", &payload).is_err() {
return unexamined("ANMF", as_u64(chunk.data.len()));
}
Decision {
outcome: Outcome::Replace(rewritten),
findings,
retained: Vec::new(),
notes: Vec::new(),
}
}
fn exif_chunk(data: &[u8], size: u64, options: &InspectOptions, limits: &ParseLimits) -> Decision {
let tiff = if data.starts_with(EXIF_INTRODUCER) {
data.get(EXIF_INTRODUCER.len()..).unwrap_or_default()
} else {
data
};
let scanned = exif::scan(tiff, "EXIF", options, limits);
let findings = if scanned.findings.is_empty() {
vec![Finding::new(MetadataKind::Other, "EXIF", size)]
} else {
scanned.findings
};
Decision {
outcome: Outcome::Drop,
findings,
retained: Vec::new(),
notes: scanned.notes,
}
}
fn unexamined(location: &'static str, bytes: u64) -> Decision {
Decision {
outcome: Outcome::Keep,
findings: Vec::new(),
retained: Vec::new(),
notes: vec![Note::UnparsedRegion {
location: location.to_owned(),
bytes,
}],
}
}
fn malformed(detail: MalformedDetail, offset: Option<u64>) -> StryptError {
StryptError::Malformed {
format: Format::Webp,
offset,
detail,
}
}
fn as_offset(position: usize) -> Option<u64> {
u64::try_from(position).ok()
}
fn as_u64(value: usize) -> u64 {
u64::try_from(value).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::arithmetic_side_effects
)]
use super::*;
use crate::report::MetadataValue;
fn chunk(kind: &[u8], data: &[u8]) -> Vec<u8> {
let mut out = kind.to_vec();
out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_le_bytes());
out.extend_from_slice(data);
if data.len() % 2 == 1 {
out.push(0);
}
out
}
fn webp(chunks: &[Vec<u8>]) -> Vec<u8> {
riff::write(WEBP, &chunks.concat()).unwrap()
}
fn vp8x(flags: u8) -> Vec<u8> {
let mut data = vec![flags, 0, 0, 0];
data.extend_from_slice(&15u32.to_le_bytes()[0..3]);
data.extend_from_slice(&15u32.to_le_bytes()[0..3]);
chunk(b"VP8X", &data)
}
fn bitstream() -> Vec<u8> {
chunk(b"VP8L", b"SYNTHETIC-PIXELS")
}
fn strip_ok(data: &[u8]) -> Stripped {
WebpHandler
.strip(data, &StripOptions::default())
.expect("strip failed")
}
fn findings(data: &[u8]) -> Vec<Finding> {
WebpHandler
.inspect(data, &InspectOptions::names_only())
.expect("inspect failed")
.findings
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
#[test]
fn the_picture_is_never_touched() {
let input = webp(&[
vp8x(0b0000_1000),
bitstream(),
chunk(b"EXIF", b"II\x2A\x00\x08\x00\x00\x00\x00\x00"),
]);
let output = strip_ok(&input).bytes;
assert!(
contains(&output, b"SYNTHETIC-PIXELS"),
"the image data did not survive byte for byte"
);
}
#[test]
fn a_simple_file_cannot_carry_metadata_and_comes_back_byte_identical() {
for payload in [chunk(b"VP8L", b"SYNTHETIC-PIXELS"), chunk(b"VP8 ", b"ODD")] {
let input = webp(&[payload]);
let stripped = strip_ok(&input);
assert!(stripped.report.removed.is_empty());
assert_eq!(
stripped.bytes, input,
"a simple WebP was not passed through"
);
}
}
#[test]
fn a_clean_extended_file_comes_back_byte_identical_too() {
let input = webp(&[vp8x(0b0001_0000), chunk(b"ALPH", b"A"), bitstream()]);
let stripped = strip_ok(&input);
assert!(stripped.report.removed.is_empty());
assert_eq!(stripped.bytes, input);
}
#[test]
fn the_metadata_chunks_are_removed_and_the_header_flags_follow() {
let mut tiff = b"II\x2A\x00\x08\x00\x00\x00".to_vec();
tiff.extend_from_slice(&1u16.to_le_bytes());
tiff.extend_from_slice(&0x010Fu16.to_le_bytes()); tiff.extend_from_slice(&2u16.to_le_bytes()); tiff.extend_from_slice(&4u32.to_le_bytes());
tiff.extend_from_slice(b"ACME");
tiff.extend_from_slice(&0u32.to_le_bytes());
let input = webp(&[
vp8x(0b0011_1100),
chunk(b"ICCP", b"SYNTHETIC-PROFILE-0001"),
chunk(b"ALPH", b"A"),
bitstream(),
chunk(b"EXIF", &tiff),
chunk(
b"XMP ",
b"<x:xmpmeta><dc:creator>SYNTHETIC-0002</dc:creator></x:xmpmeta>",
),
]);
let found = findings(&input);
let kinds: Vec<MetadataKind> = found.iter().map(|f| f.kind).collect();
assert!(kinds.contains(&MetadataKind::ColourProfile));
assert!(kinds.contains(&MetadataKind::DeviceIdentity));
assert!(kinds.contains(&MetadataKind::PersonalIdentity));
let output = strip_ok(&input).bytes;
assert!(!contains(&output, b"SYNTHETIC-PROFILE-0001"));
assert!(!contains(&output, b"ACME"));
assert!(!contains(&output, b"SYNTHETIC-0002"));
assert!(findings(&output).is_empty());
let flags = output[20];
assert_eq!(
flags, 0b0001_0000,
"the VP8X flags still claim metadata that is gone"
);
}
#[test]
fn flags_that_were_already_lying_are_corrected_even_with_nothing_to_remove() {
let input = webp(&[vp8x(0b0000_1100), bitstream()]);
let stripped = strip_ok(&input);
assert!(stripped.report.removed.is_empty());
assert_eq!(stripped.bytes[20], 0);
assert_ne!(stripped.bytes, input);
}
#[test]
fn an_exif_chunk_written_with_a_jpeg_introducer_is_still_read() {
let mut tiff = b"II\x2A\x00\x08\x00\x00\x00".to_vec();
tiff.extend_from_slice(&1u16.to_le_bytes());
tiff.extend_from_slice(&0x0110u16.to_le_bytes()); tiff.extend_from_slice(&2u16.to_le_bytes());
tiff.extend_from_slice(&4u32.to_le_bytes());
tiff.extend_from_slice(b"MDL1");
tiff.extend_from_slice(&0u32.to_le_bytes());
let mut payload = EXIF_INTRODUCER.to_vec();
payload.extend_from_slice(&tiff);
let input = webp(&[vp8x(0b0000_1000), bitstream(), chunk(b"EXIF", &payload)]);
let found = findings(&input);
assert_eq!(found[0].field.as_deref(), Some("Model"));
}
#[test]
fn an_unknown_chunk_is_removed_rather_than_preserved() {
let input = webp(&[
vp8x(0),
bitstream(),
chunk(b"PRVW", b"SYNTHETIC-PREVIEW-0003"),
]);
let stripped = strip_ok(&input);
assert!(!contains(&stripped.bytes, b"SYNTHETIC-PREVIEW-0003"));
assert_eq!(stripped.report.removed[0].location, "PRVW");
}
#[test]
fn an_animation_survives_and_a_chunk_hidden_in_a_frame_does_not() {
let mut frame = vec![0u8; ANMF_HEADER_BYTES];
frame.extend_from_slice(&chunk(b"VP8L", b"SYNTHETIC-FRAME-PIXELS"));
let clean = webp(&[
vp8x(0b0000_0010),
chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]),
chunk(b"ANMF", &frame),
]);
assert_eq!(strip_ok(&clean).bytes, clean, "an animation was rewritten");
let mut hostile = vec![0u8; ANMF_HEADER_BYTES];
hostile.extend_from_slice(&chunk(b"VP8L", b"SYNTHETIC-FRAME-PIXELS"));
hostile.extend_from_slice(&chunk(b"JUNK", b"SYNTHETIC-IN-FRAME-0004"));
let input = webp(&[
vp8x(0b0000_0010),
chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]),
chunk(b"ANMF", &hostile),
]);
let stripped = strip_ok(&input);
assert!(!contains(&stripped.bytes, b"SYNTHETIC-IN-FRAME-0004"));
assert!(
contains(&stripped.bytes, b"SYNTHETIC-FRAME-PIXELS"),
"the frame's picture did not survive"
);
assert_eq!(stripped.report.removed[0].location, "ANMF JUNK");
}
#[test]
fn a_frame_that_does_not_parse_is_kept_and_declared_unexamined() {
let mut frame = vec![0u8; ANMF_HEADER_BYTES];
frame.extend_from_slice(b"VP8L\xff\xff\xff\xffPRESERVED-0005");
let input = webp(&[
vp8x(0b0000_0010),
chunk(b"ANIM", &[0, 0, 0, 0, 0, 0]),
chunk(b"ANMF", &frame),
]);
let stripped = strip_ok(&input);
assert!(contains(&stripped.bytes, b"PRESERVED-0005"));
assert!(matches!(
stripped.report.notes.first(),
Some(Note::UnparsedRegion { location, .. }) if location == "ANMF"
));
}
#[test]
fn data_after_the_riff_chunk_is_removed() {
let mut input = webp(&[vp8x(0), bitstream()]);
input.extend_from_slice(b"SYNTHETIC-APPENDED-0006");
let stripped = strip_ok(&input);
assert!(!contains(&stripped.bytes, b"SYNTHETIC-APPENDED-0006"));
assert_eq!(
stripped.report.removed[0].location,
"trailing data after the RIFF chunk"
);
}
#[test]
fn a_second_file_after_the_riff_chunk_is_reported_as_a_thumbnail() {
let mut input = webp(&[vp8x(0), bitstream()]);
input.extend_from_slice(&webp(&[bitstream()]));
assert_eq!(
strip_ok(&input).report.removed[0].kind,
MetadataKind::Thumbnail
);
}
#[test]
fn an_xmp_packet_is_itemised_by_property() {
let input = webp(&[
vp8x(0b0000_0100),
bitstream(),
chunk(
b"XMP ",
br#"<x:xmpmeta xmpMM:DocumentID="uuid:1" xmp:CreatorTool="SYNTHETIC"/>"#,
),
]);
let found = findings(&input);
let fields: Vec<&str> = found.iter().filter_map(|f| f.field.as_deref()).collect();
assert!(fields.contains(&"xmpMM:DocumentID"), "{fields:?}");
assert!(fields.contains(&"xmp:CreatorTool"), "{fields:?}");
}
#[test]
fn values_are_withheld_from_a_default_inspection() {
let mut tiff = b"II\x2A\x00\x08\x00\x00\x00".to_vec();
tiff.extend_from_slice(&1u16.to_le_bytes());
tiff.extend_from_slice(&0x010Fu16.to_le_bytes());
tiff.extend_from_slice(&2u16.to_le_bytes());
tiff.extend_from_slice(&4u32.to_le_bytes());
tiff.extend_from_slice(b"ACME");
tiff.extend_from_slice(&0u32.to_le_bytes());
let input = webp(&[vp8x(0b0000_1000), bitstream(), chunk(b"EXIF", &tiff)]);
assert_eq!(findings(&input)[0].value, None);
let with_values = WebpHandler
.inspect(&input, &InspectOptions::with_values())
.unwrap();
assert_eq!(
with_values.findings[0].value,
Some(MetadataValue::Text("ACME".to_owned()))
);
}
#[test]
fn stripping_twice_changes_nothing() {
let input = webp(&[
vp8x(0b0011_1100),
chunk(b"ICCP", b"SYNTHETIC-PROFILE-0001"),
bitstream(),
chunk(b"XMP ", b"<x:xmpmeta/>"),
]);
let once = strip_ok(&input).bytes;
let twice = strip_ok(&once).bytes;
assert_eq!(once, twice, "strip is not idempotent");
}
#[test]
fn a_riff_size_beyond_the_end_of_the_file_is_refused_rather_than_clamped() {
let mut input = webp(&[vp8x(0), bitstream()]);
input[4..8].copy_from_slice(&0x00FF_FFFFu32.to_le_bytes());
assert!(matches!(
WebpHandler.inspect(&input, &InspectOptions::names_only()),
Err(StryptError::Malformed {
detail: MalformedDetail::LengthOutOfRange,
..
})
));
}
#[test]
fn a_chunk_size_beyond_the_riff_extent_is_refused() {
let input = webp(&[vp8x(0), bitstream(), {
let mut lying = b"EXIF".to_vec();
lying.extend_from_slice(&0x0010_0000u32.to_le_bytes());
lying.extend_from_slice(b"II\x2A\x00");
lying
}]);
assert!(matches!(
WebpHandler.inspect(&input, &InspectOptions::names_only()),
Err(StryptError::Malformed {
detail: MalformedDetail::LengthOutOfRange,
..
})
));
}
#[test]
fn a_file_with_no_picture_chunk_is_refused_rather_than_emptied() {
let input = webp(&[
vp8x(0b0000_1000),
chunk(b"EXIF", b"II\x2A\x00\x08\x00\x00\x00"),
]);
assert!(matches!(
WebpHandler.strip(&input, &StripOptions::default()),
Err(StryptError::Malformed {
detail: MalformedDetail::MissingMarker,
..
})
));
}
#[test]
fn a_file_that_does_not_open_with_a_header_or_bitstream_chunk_is_refused() {
let input = webp(&[chunk(b"EXIF", b"II\x2A\x00\x08\x00\x00\x00"), bitstream()]);
assert!(matches!(
WebpHandler.inspect(&input, &InspectOptions::names_only()),
Err(StryptError::Malformed {
detail: MalformedDetail::MissingMarker,
..
})
));
}
#[test]
fn a_vp8x_of_the_wrong_length_is_refused() {
let input = webp(&[chunk(b"VP8X", &[0u8; 8]), bitstream()]);
assert!(matches!(
WebpHandler.inspect(&input, &InspectOptions::names_only()),
Err(StryptError::Malformed {
detail: MalformedDetail::LengthOutOfRange,
..
})
));
}
#[test]
fn a_four_character_code_that_is_not_ascii_is_refused() {
let input = webp(&[vp8x(0), bitstream(), chunk(b"\x00\x01\x02\x03", b"")]);
assert!(matches!(
WebpHandler.inspect(&input, &InspectOptions::names_only()),
Err(StryptError::Malformed {
detail: MalformedDetail::UnexpectedMarker,
..
})
));
}
#[test]
fn a_file_that_is_not_riff_or_not_webp_is_refused() {
assert!(matches!(
WebpHandler.inspect(b"RIFX\x04\x00\x00\x00WEBP", &InspectOptions::names_only()),
Err(StryptError::Malformed { .. })
));
assert!(matches!(
WebpHandler.inspect(b"RIFF\x04\x00\x00\x00WAVE", &InspectOptions::names_only()),
Err(StryptError::Malformed {
detail: MalformedDetail::MissingMarker,
..
})
));
}
#[test]
fn truncation_at_every_length_is_refused_or_survived_but_never_panics() {
let input = webp(&[
vp8x(0b0011_1100),
chunk(b"ICCP", b"SYNTHETIC-PROFILE-0001"),
bitstream(),
chunk(b"EXIF", b"II\x2A\x00\x08\x00\x00\x00\x00\x00"),
chunk(b"XMP ", b"<x:xmpmeta/>"),
]);
for n in 0..=input.len() {
let prefix = &input[0..n];
let _ = WebpHandler.inspect(prefix, &InspectOptions::names_only());
let _ = WebpHandler.strip(prefix, &StripOptions::default());
}
}
#[test]
fn a_chunk_count_beyond_the_limit_is_refused() {
let mut chunks = vec![vp8x(0), bitstream()];
chunks.extend((0..64).map(|_| chunk(b"JUNK", b"x")));
let input = webp(&chunks);
let options = StripOptions {
limits: ParseLimits {
max_items: 8,
..ParseLimits::default()
},
..StripOptions::default()
};
assert!(matches!(
WebpHandler.strip(&input, &options),
Err(StryptError::LimitExceeded { .. })
));
}
#[test]
fn an_odd_length_chunk_keeps_its_padding_byte() {
let input = webp(&[vp8x(0), chunk(b"VP8 ", b"ODD")]);
let stripped = strip_ok(&input);
assert_eq!(stripped.bytes, input);
assert_eq!(stripped.bytes.len() % 2, 0);
}
}