From decfa8f54e847a8a0f0c0c2c67fbe074925940d9 Mon Sep 17 00:00:00 2001 From: Jared Forth Date: Mon, 5 Oct 2026 13:02:06 -0500 Subject: [PATCH] chore: test coverage --- .github/workflows/rust.yml | 18 ++++++++ Cargo.toml | 8 ++++ README.md | 1 + src/animation_decoder.rs | 93 ++++++++++++++++++++++---------------- src/animation_encoder.rs | 61 +++++++++++++++++++++++++ src/decoder.rs | 17 +++++-- src/encoder.rs | 28 ++++++++++++ src/lib.rs | 13 ++---- 8 files changed, 188 insertions(+), 51 deletions(-) diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 1baef98..be2129a 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -19,5 +19,23 @@ jobs: run: cargo build --verbose - name: Run tests run: cargo test --verbose + - name: Run tests without default features + run: cargo test --no-default-features --verbose - name: Format check run: cargo fmt --check + + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + - uses: taiki-e/install-action@cargo-llvm-cov + - name: Generate coverage + run: cargo llvm-cov --all-features --lcov --output-path lcov.info + - uses: codecov/codecov-action@v5 + with: + files: lcov.info + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/Cargo.toml b/Cargo.toml index 01219ba..dabca3c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,3 +24,11 @@ img = [ "image" ] [dev-dependencies] image = "0.25" + +[[example]] +name = "convert" +required-features = ["img"] + +[[example]] +name = "convert_by_args" +required-features = ["img"] diff --git a/README.md b/README.md index a0b0cc2..24af74b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ ![Build Status](https://github.com/jaredforth/webp/actions/workflows/rust.yml/badge.svg) +[![codecov](https://codecov.io/gh/jaredforth/webp/graph/badge.svg)](https://codecov.io/gh/jaredforth/webp) [![Crate](https://img.shields.io/crates/v/webp.svg)](https://crates.io/crates/webp) [![API](https://docs.rs/webp/badge.svg)](https://docs.rs/webp) ![Crates.io](https://img.shields.io/crates/d/webp) diff --git a/src/animation_decoder.rs b/src/animation_decoder.rs index 7f18c22..9ef5ff7 100644 --- a/src/animation_decoder.rs +++ b/src/animation_decoder.rs @@ -11,24 +11,16 @@ impl<'a> AnimDecoder<'a> { Self { data } } pub fn decode(&self) -> Result { - unsafe { self.decode_internal(true) } + unsafe { self.decode_internal() } } - unsafe fn decode_internal(&self, mut has_alpha: bool) -> Result { + // libwebp's animation decoder only outputs RGBA-family modes, so frames are always RGBA. + unsafe fn decode_internal(&self) -> Result { let mut dec_options: WebPAnimDecoderOptions = std::mem::zeroed(); - dec_options.color_mode = if has_alpha { - WEBP_CSP_MODE::MODE_RGBA - } else { - WEBP_CSP_MODE::MODE_RGB - }; let ok = WebPAnimDecoderOptionsInitInternal(&mut dec_options, WebPGetDemuxABIVersion()); if ok == 0 { return Err(String::from("option init error")); } - match dec_options.color_mode { - WEBP_CSP_MODE::MODE_RGBA | WEBP_CSP_MODE::MODE_RGB => {} - _ => return Err(String::from("unsupport color mode")), - } - has_alpha = dec_options.color_mode == WEBP_CSP_MODE::MODE_RGBA; + dec_options.color_mode = WEBP_CSP_MODE::MODE_RGBA; let webp_data = WebPData { bytes: self.data.as_ptr(), size: self.data.len(), @@ -40,6 +32,7 @@ impl<'a> AnimDecoder<'a> { let mut anim_info: WebPAnimInfo = std::mem::zeroed(); let ok = WebPAnimDecoderGetInfo(dec, &mut anim_info); if ok == 0 { + WebPAnimDecoderDelete(dec); return Err(String::from("null info")); } let width = anim_info.canvas_width; @@ -49,25 +42,21 @@ impl<'a> AnimDecoder<'a> { let mut buf: *mut u8 = std::ptr::null_mut(); let mut timestamp: std::os::raw::c_int = 0; let ok = WebPAnimDecoderGetNext(dec, &mut buf, &mut timestamp); - if ok != 0 { - let len = (if has_alpha { 4 } else { 3 } * width * height) as usize; - let mut img = Vec::with_capacity(len); - buf.copy_to(img.spare_capacity_mut().as_mut_ptr().cast(), len); - img.set_len(len); - let layout = if has_alpha { - PixelLayout::Rgba - } else { - PixelLayout::Rgb - }; - let frame = DecodeAnimFrame { - img, - width, - height, - layout, - timestamp, - }; - list.push(frame); + if ok == 0 { + WebPAnimDecoderDelete(dec); + return Err(String::from("frame decode error")); } + let len = (4 * width * height) as usize; + let mut img = Vec::with_capacity(len); + buf.copy_to(img.spare_capacity_mut().as_mut_ptr().cast(), len); + img.set_len(len); + list.push(DecodeAnimFrame { + img, + width, + height, + layout: PixelLayout::Rgba, + timestamp, + }); } WebPAnimDecoderReset(dec); //let demuxer:WebPDemuxer=WebPAnimDecoderGetDemuxer(dec); @@ -102,7 +91,7 @@ impl From> for DecodeAnimImage { } impl DecodeAnimImage { #[inline] - pub fn get_frame(&self, index: usize) -> Option { + pub fn get_frame(&self, index: usize) -> Option> { let f = self.frames.get(index)?; Some(AnimFrame::new( &f.img, @@ -114,7 +103,7 @@ impl DecodeAnimImage { )) } #[inline] - pub fn get_frames(&self, index: core::ops::Range) -> Option> { + pub fn get_frames(&self, index: core::ops::Range) -> Option>> { let dec_frames = self.frames.get(index)?; let mut frames = Vec::with_capacity(dec_frames.len()); for f in dec_frames { @@ -144,12 +133,9 @@ impl<'a> IntoIterator for &'a DecodeAnimImage { type IntoIter = std::vec::IntoIter; fn into_iter(self) -> Self::IntoIter { - let fs = self.get_frames(0..self.frames.len()); - if let Some(v) = fs { - v.into_iter() - } else { - vec![].into_iter() - } + self.get_frames(0..self.frames.len()) + .unwrap_or_default() + .into_iter() } } @@ -231,6 +217,37 @@ mod tests { assert_eq!(count, anim.len()); } + #[test] + fn test_decode_corrupt_frame_is_error() { + let mut config = WebPConfig::new().unwrap(); + config.lossless = 1; + let bufs: Vec> = (0..3) + .map(|t| { + (0..16 * 16 * 4) + .map(|i| ((i * 7 + t * 31) % 256) as u8) + .collect() + }) + .collect(); + let mut encoder = crate::AnimEncoder::new(16, 16, &config); + for (t, px) in bufs.iter().enumerate() { + encoder.add_frame(AnimFrame::from_rgba(px, 16, 16, t as i32 * 100)); + } + let mut data = encoder.encode().to_vec(); + + // Corrupt the last frame's VP8L payload, leaving chunk headers intact. + let pos = data.windows(4).rposition(|w| w == b"VP8L").unwrap(); + let size = u32::from_le_bytes(data[pos + 4..pos + 8].try_into().unwrap()) as usize; + for b in &mut data[pos + 8 + 6..pos + 8 + size] { + *b ^= 0xA5; + } + + // Used to loop forever; must now fail instead of returning partial frames. + assert_eq!( + AnimDecoder::new(&data).decode().err().as_deref(), + Some("frame decode error") + ); + } + #[test] fn test_decode_failure_on_invalid_data() { let data = vec![0u8; 10]; diff --git a/src/animation_encoder.rs b/src/animation_encoder.rs index c4e9f9f..0df8afe 100644 --- a/src/animation_encoder.rs +++ b/src/animation_encoder.rs @@ -276,6 +276,67 @@ mod tests { ); } + #[cfg(feature = "img")] + #[test] + fn test_animframe_from_image() { + use image::{ImageBuffer, Luma, LumaA, Rgb, Rgb32FImage}; + + let luma = DynamicImage::ImageLuma8(ImageBuffer::from_pixel(1, 1, Luma([0]))); + let luma_a = DynamicImage::ImageLumaA8(ImageBuffer::from_pixel(1, 1, LumaA([0, 0]))); + let rgb32f = DynamicImage::ImageRgb32F(Rgb32FImage::new(1, 1)); + assert!(AnimFrame::from_image(&luma, 0).is_err()); + assert!(AnimFrame::from_image(&luma_a, 0).is_err()); + assert!(AnimFrame::from_image(&rgb32f, 0).is_err()); + + let rgb = DynamicImage::ImageRgb8(ImageBuffer::from_pixel(2, 1, Rgb([1, 2, 3]))); + let frame = AnimFrame::from_image(&rgb, 7).unwrap(); + assert_eq!(frame.get_layout(), PixelLayout::Rgb); + assert_eq!(frame.get_time_ms(), 7); + + // round-trip back into a DynamicImage via the RGB branch + assert_eq!(DynamicImage::from(&frame), rgb); + } + + #[test] + fn test_encoder_from_animframe() { + let rgb = [1u8, 2, 3, 4, 5, 6]; + let frame = AnimFrame::from_rgb(&rgb, 2, 1, 0); + let mem = Encoder::from(&frame).encode_lossless(); + assert!(!mem.is_empty()); + } + + #[test] + fn test_animencoder_invalid_config() { + let config = default_config(); + let mut bad = default_config(); + bad.quality = 200.0; + let mut encoder = AnimEncoder::new(2, 1, &config); + encoder.add_frame(AnimFrame::new( + &[1, 2, 3, 4, 5, 6], + PixelLayout::Rgb, + 2, + 1, + 0, + Some(&bad), + )); + assert!(matches!( + encoder.try_encode(), + Err(AnimEncodeError::WebPEncodingError(_)) + )); + } + + #[test] + fn test_animencoder_invalid_loop_count() { + let config = default_config(); + let mut encoder = AnimEncoder::new(2, 1, &config); + encoder.set_loop_count(-1); + encoder.add_frame(AnimFrame::from_rgb(&[1, 2, 3, 4, 5, 6], 2, 1, 0)); + assert!(matches!( + encoder.try_encode(), + Err(AnimEncodeError::WebPMuxError(_)) + )); + } + #[test] fn test_animdecoder_decode_failure_on_invalid_data() { let data = vec![0u8; 10]; diff --git a/src/decoder.rs b/src/decoder.rs index 38066c2..2e734f3 100644 --- a/src/decoder.rs +++ b/src/decoder.rs @@ -189,10 +189,21 @@ mod tests { #[test] fn test_decoder_rejects_animation() { + let config = WebPConfig::new().unwrap(); + let mut encoder = crate::AnimEncoder::new(1, 1, &config); + encoder.add_frame(crate::AnimFrame::from_rgb(&[0, 0, 0], 1, 1, 0)); + encoder.add_frame(crate::AnimFrame::from_rgb(&[255, 255, 255], 1, 1, 100)); + let data = encoder.encode(); + assert!(BitstreamFeatures::new(&data).unwrap().has_animation()); + assert!(Decoder::new(&data).decode().is_none()); + } + + #[test] + fn test_decoder_truncated_data() { let data = minimal_webp_rgb(); - let decoder = Decoder::new(&data); - let image = decoder.decode(); - assert!(image.is_some()); + let truncated = &data[..data.len() - 8]; + assert!(BitstreamFeatures::new(truncated).is_some()); + assert!(Decoder::new(truncated).decode().is_none()); } #[test] diff --git a/src/encoder.rs b/src/encoder.rs index b64c9c5..e2bbb52 100644 --- a/src/encoder.rs +++ b/src/encoder.rs @@ -260,6 +260,8 @@ mod tests { let luma_a = DynamicImage::ImageLumaA8(ImageBuffer::from_pixel(1, 1, image::LumaA([0, 0]))); assert!(Encoder::from_image(&luma).is_err()); assert!(Encoder::from_image(&luma_a).is_err()); + let rgb32f = DynamicImage::ImageRgb32F(image::Rgb32FImage::new(1, 1)); + assert!(Encoder::from_image(&rgb32f).is_err()); let rgb = DynamicImage::ImageRgb8(ImageBuffer::from_pixel(2, 2, image::Rgb([1, 2, 3]))); let rgba = @@ -278,4 +280,30 @@ mod tests { let mem = encoder.encode(75.0); assert!(!mem.is_empty()); } + + #[test] + fn test_oversized_buffer_is_accepted() { + let image = [0u8; 16]; + let mem = Encoder::from_rgb(&image, 2, 2).encode_lossless(); + assert!(!mem.is_empty()); + } + + #[test] + fn test_encode_invalid_config() { + let image = [0u8; 12]; + let res = Encoder::from_rgb(&image, 2, 2).encode_simple(false, 200.0); + assert!(matches!( + res, + Err(WebPEncodingError::VP8_ENC_ERROR_INVALID_CONFIGURATION) + )); + } + + #[test] + fn test_encode_zero_dimensions() { + let res = Encoder::from_rgb(&[], 0, 0).encode_simple(false, 75.0); + assert!(matches!( + res, + Err(WebPEncodingError::VP8_ENC_ERROR_BAD_DIMENSION) + )); + } } diff --git a/src/lib.rs b/src/lib.rs index 4b7c853..87cae82 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,7 +26,7 @@ pub use shared::*; pub use libwebp_sys::WebPConfig; -#[cfg(test)] +#[cfg(all(test, feature = "img"))] mod tests { use std::ops::Deref; @@ -181,15 +181,8 @@ mod tests { t += 250; } let webp = encoder.encode(); - let mut decode_images: Vec = vec![]; - match AnimDecoder::new(&webp).decode() { - Ok(frames) => { - decode_images.extend((&frames).into_iter().map(|a| (&a).into())); - } - Err(mes) => { - println!("{}", mes); - } - } + let frames = AnimDecoder::new(&webp).decode().unwrap(); + let decode_images: Vec = frames.into_iter().map(|a| (&a).into()).collect(); let mut encode_rgba = vec![]; for v in encode_images.into_iter() { let value = DynamicImage::ImageRgba8(v.to_rgba8());