From d75abfcdff91f4d4ff1a2054950d7bc1f740d225 Mon Sep 17 00:00:00 2001 From: "Sergey \"Shnatsel\" Davidoff" Date: Fri, 1 May 2026 17:08:42 +0100 Subject: [PATCH 1/2] Add test demonstrating incorrect animation frame encoding --- src/animation_encoder.rs | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/animation_encoder.rs b/src/animation_encoder.rs index c4e9f9f..b362e26 100644 --- a/src/animation_encoder.rs +++ b/src/animation_encoder.rs @@ -276,6 +276,36 @@ mod tests { ); } + #[test] + fn animencoder_roundtrip_does_not_include_trailing_frame_data() { + let mut config = default_config(); + config.lossless = 1; + config.quality = 100.0; + + let image = [ + 250, 0, 0, // Declared 1x1 frame data. + 0, 250, 0, // Trailing data that is not part of the frame. + 0, 0, 250, // + 250, 250, 0, // + ]; + + let mut encoder = AnimEncoder::new(2, 2, &config); + encoder.add_frame(AnimFrame::from_rgb(&image, 1, 1, 0)); + + let webp = encoder.encode(); + let anim = AnimDecoder::new(&webp).decode().unwrap(); + let frame = anim.get_frame(0).unwrap(); + let pixels: Vec<_> = frame.get_image().chunks_exact(4).collect(); + + assert_eq!(&pixels[0], &&[250, 0, 0, 255]); + for garbage in [[0, 250, 0, 255], [0, 0, 250, 255], [250, 250, 0, 255]] { + assert!( + !pixels[1..].contains(&garbage.as_slice()), + "decoded pixels included trailing frame data: {pixels:?}" + ); + } + } + #[test] fn test_animdecoder_decode_failure_on_invalid_data() { let data = vec![0u8; 10]; From c9e0582005b9673db315cb0d8d4e6a6fa46390ce Mon Sep 17 00:00:00 2001 From: "Sergey \"Shnatsel\" Davidoff" Date: Fri, 1 May 2026 17:23:25 +0100 Subject: [PATCH 2/2] Fix the bug and update the unit test to verify the invalid dimensions are rejected --- src/animation_encoder.rs | 64 ++++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 12 deletions(-) diff --git a/src/animation_encoder.rs b/src/animation_encoder.rs index b362e26..870df54 100644 --- a/src/animation_encoder.rs +++ b/src/animation_encoder.rs @@ -132,6 +132,7 @@ impl<'a> AnimEncoder<'a> { self.try_encode().unwrap() } pub fn try_encode(&self) -> Result { + validate_animation_frames(self)?; unsafe { anim_encode(self) } } } @@ -142,6 +143,37 @@ pub enum AnimEncodeError { WebPMuxError(WebPMuxError), WebPAnimEncoderGetError(String), } + +fn invalid_config() -> AnimEncodeError { + AnimEncodeError::WebPEncodingError(WebPEncodingError::VP8_ENC_ERROR_INVALID_CONFIGURATION) +} + +fn validate_animation_frames(encoder: &AnimEncoder) -> Result<(), AnimEncodeError> { + for frame in &encoder.frames { + // The WebP container can store smaller ANMF rectangles with offsets, and + // a future API could expose that either by compositing frames onto a full + // canvas here or by using WebPMuxFrameInfo directly. This crate's + // AnimFrame has no offset/blend/dispose fields, and libwebp's + // WebPAnimEncoderAdd expects full canvas snapshots; it then optimizes + // them into subframes internally. Passing smaller frames never worked: + // the old code imported them as canvas-sized images and read trailing + // bytes as pixels. Reject them before reaching the C API + // to avoid libwebp reading out of bounds of the provided buffer. + if frame.width != encoder.width || frame.height != encoder.height { + return Err(invalid_config()); + } + + let expected_len = (frame.width as usize) + .saturating_mul(frame.height as usize) + .saturating_mul(frame.layout.bytes_per_pixel() as usize); + if frame.image.len() < expected_len { + return Err(invalid_config()); + } + } + + Ok(()) +} + unsafe fn anim_encode(all_frame: &AnimEncoder) -> Result { let width = all_frame.width; let height = all_frame.height; @@ -277,7 +309,7 @@ mod tests { } #[test] - fn animencoder_roundtrip_does_not_include_trailing_frame_data() { + fn animencoder_rejects_frames_smaller_than_canvas() { let mut config = default_config(); config.lossless = 1; config.quality = 100.0; @@ -292,18 +324,26 @@ mod tests { let mut encoder = AnimEncoder::new(2, 2, &config); encoder.add_frame(AnimFrame::from_rgb(&image, 1, 1, 0)); - let webp = encoder.encode(); - let anim = AnimDecoder::new(&webp).decode().unwrap(); - let frame = anim.get_frame(0).unwrap(); - let pixels: Vec<_> = frame.get_image().chunks_exact(4).collect(); + assert!(matches!( + encoder.try_encode(), + Err(AnimEncodeError::WebPEncodingError( + WebPEncodingError::VP8_ENC_ERROR_INVALID_CONFIGURATION + )) + )); + } - assert_eq!(&pixels[0], &&[250, 0, 0, 255]); - for garbage in [[0, 250, 0, 255], [0, 0, 250, 255], [250, 250, 0, 255]] { - assert!( - !pixels[1..].contains(&garbage.as_slice()), - "decoded pixels included trailing frame data: {pixels:?}" - ); - } + #[test] + fn animencoder_rejects_frame_buffers_that_are_too_small() { + let config = default_config(); + let mut encoder = AnimEncoder::new(2, 2, &config); + encoder.add_frame(AnimFrame::from_rgb(&[0, 0, 0], 2, 2, 0)); + + assert!(matches!( + encoder.try_encode(), + Err(AnimEncodeError::WebPEncodingError( + WebPEncodingError::VP8_ENC_ERROR_INVALID_CONFIGURATION + )) + )); } #[test]