From 8fd8212b3171a7bf5df9e48d32b652eadae33358 Mon Sep 17 00:00:00 2001 From: telecos Date: Thu, 12 Feb 2026 23:36:46 +0000 Subject: [PATCH] Define SpecCompliance enum for controlling spec strictness --- src/codecs/bmp/decoder.rs | 52 ++++++++++++++++++------------------- src/codecs/jpeg/decoder.rs | 49 +++++++++++++++++++++++++++++----- src/io/image_reader_type.rs | 43 ++++++++++++++++++++++++++---- src/lib.rs | 2 +- 4 files changed, 108 insertions(+), 38 deletions(-) diff --git a/src/codecs/bmp/decoder.rs b/src/codecs/bmp/decoder.rs index e8f6945d2a..6eccd950fe 100644 --- a/src/codecs/bmp/decoder.rs +++ b/src/codecs/bmp/decoder.rs @@ -9,20 +9,9 @@ use crate::color::ColorType; use crate::error::{ DecodingError, ImageError, ImageResult, UnsupportedError, UnsupportedErrorKind, }; +use crate::io::image_reader_type::SpecCompliance; use crate::{ImageDecoder, ImageFormat}; -/// Controls how strictly the BMP decoder adheres to the specification. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(crate) enum BmpSpec { - /// Strictly follow the BMP specification. - /// Rejects files that violate spec constraints (e.g., RLE with top-down). - Strict, - /// Allow some non-conformant files that violate some spec constraints - /// but still can be decoded at best effort. - #[default] - Lenient, -} - const BITMAPCOREHEADER_SIZE: u32 = 12; const BITMAPINFOHEADER_SIZE: u32 = 40; const BITMAPV2HEADER_SIZE: u32 = 52; @@ -128,12 +117,12 @@ struct ParsedCoreHeader { impl ParsedCoreHeader { /// Parse BITMAPCOREHEADER fields from an 8-byte buffer. - fn parse(buffer: &[u8; 8], spec_strictness: BmpSpec) -> ImageResult { + fn parse(buffer: &[u8; 8], spec_strictness: SpecCompliance) -> ImageResult { let width = i32::from(u16::from_le_bytes(buffer[0..2].try_into().unwrap())); let height = i32::from(u16::from_le_bytes(buffer[2..4].try_into().unwrap())); let planes = u16::from_le_bytes(buffer[4..6].try_into().unwrap()); - if spec_strictness == BmpSpec::Strict && planes != 1 { + if spec_strictness == SpecCompliance::Strict && planes != 1 { return Err(DecoderError::MoreThanOnePlane.into()); } @@ -169,7 +158,7 @@ struct ParsedInfoHeader { impl ParsedInfoHeader { /// Parse BITMAPINFOHEADER fields from a 36-byte buffer. - fn parse(buffer: &[u8; 36], spec_strictness: BmpSpec) -> ImageResult { + fn parse(buffer: &[u8; 36], spec_strictness: SpecCompliance) -> ImageResult { let width = i32::from_le_bytes(buffer[0..4].try_into().unwrap()); let mut height = i32::from_le_bytes(buffer[4..8].try_into().unwrap()); @@ -193,7 +182,7 @@ impl ParsedInfoHeader { }; let planes = u16::from_le_bytes(buffer[8..10].try_into().unwrap()); - if spec_strictness == BmpSpec::Strict && planes != 1 { + if spec_strictness == SpecCompliance::Strict && planes != 1 { return Err(DecoderError::MoreThanOnePlane.into()); } @@ -202,7 +191,7 @@ impl ParsedInfoHeader { // Top-down DIBs cannot be compressed (per BMP specification). // In lenient mode, we allow this for compatibility with other decoders. - if spec_strictness == BmpSpec::Strict + if spec_strictness == SpecCompliance::Strict && top_down && compression != BI_RGB && compression != BI_BITFIELDS @@ -827,7 +816,7 @@ impl Bitfields { b_mask: u32, a_mask: u32, max_len: u32, - spec_strictness: BmpSpec, + spec_strictness: SpecCompliance, ) -> ImageResult { let bitfields = Bitfields { r: Bitfield::from_mask(r_mask, max_len)?, @@ -837,7 +826,7 @@ impl Bitfields { }; // In strict mode, all RGB channels must have non-zero masks. // In lenient mode, allow zero masks (the channel will read as 0). - if spec_strictness == BmpSpec::Strict + if spec_strictness == SpecCompliance::Strict && (bitfields.r.len == 0 || bitfields.g.len == 0 || bitfields.b.len == 0) { return Err(DecoderError::BitfieldMaskMissing(max_len).into()); @@ -925,7 +914,7 @@ pub struct BmpDecoder { palette: Option>, bitfields: Option, icc_profile: Option>, - spec_strictness: BmpSpec, + spec_strictness: SpecCompliance, /// Current decoder state for resumable decoding. state: DecoderState, @@ -960,7 +949,7 @@ impl BmpDecoder { palette: None, bitfields: None, icc_profile: None, - spec_strictness: BmpSpec::default(), + spec_strictness: SpecCompliance::default(), state: DecoderState::default(), } } @@ -972,6 +961,17 @@ impl BmpDecoder { Ok(decoder) } + /// Create a new decoder with the given spec compliance mode. + pub(crate) fn new_with_spec_compliance( + reader: R, + spec: SpecCompliance, + ) -> ImageResult> { + let mut decoder = Self::new_decoder(reader); + decoder.spec_strictness = spec; + decoder.read_metadata()?; + Ok(decoder) + } + /// Create a new decoder that decodes from the stream `r` without reading /// metadata immediately. This allows for resumable decoding when the /// underlying reader may return `UnexpectedEof`. @@ -1458,7 +1458,8 @@ impl BmpDecoder { match self.colors_used { 0 => Ok(1 << self.bit_count), _ => { - if self.spec_strictness == BmpSpec::Strict && self.colors_used > 1 << self.bit_count + if self.spec_strictness == SpecCompliance::Strict + && self.colors_used > 1 << self.bit_count { return Err(DecoderError::PaletteSizeExceeded { colors_used: self.colors_used, @@ -2643,11 +2644,10 @@ mod test { panic!("{description}: read_image failed: {e:?}"); }); - // Strict mode (internal): these files should be rejected - let mut strict_decoder = BmpDecoder::new_resumable(Cursor::new(&data)); - strict_decoder.spec_strictness = BmpSpec::Strict; + // Strict mode: these files should be rejected assert!( - strict_decoder.read_metadata().is_err(), + BmpDecoder::new_with_spec_compliance(Cursor::new(&data), SpecCompliance::Strict) + .is_err(), "{description}: expected error in strict mode, but got Ok" ); } diff --git a/src/codecs/jpeg/decoder.rs b/src/codecs/jpeg/decoder.rs index 96c433f627..1c960679be 100644 --- a/src/codecs/jpeg/decoder.rs +++ b/src/codecs/jpeg/decoder.rs @@ -7,6 +7,7 @@ use crate::color::ColorType; use crate::error::{ DecodingError, ImageError, ImageResult, LimitError, UnsupportedError, UnsupportedErrorKind, }; +use crate::io::image_reader_type::SpecCompliance; use crate::metadata::Orientation; use crate::{ImageDecoder, ImageFormat, Limits}; @@ -16,6 +17,7 @@ type ZuneColorSpace = zune_core::colorspace::ColorSpace; pub struct JpegDecoder { input: Vec, orig_color_space: ZuneColorSpace, + spec_compliance: SpecCompliance, width: u16, height: u16, limits: Limits, @@ -65,6 +67,7 @@ impl JpegDecoder { Ok(JpegDecoder { input, orig_color_space, + spec_compliance: SpecCompliance::default(), width, height, limits, @@ -72,6 +75,16 @@ impl JpegDecoder { phantom: PhantomData, }) } + + /// Create a new decoder with the given spec compliance mode. + pub(crate) fn new_with_spec_compliance( + r: R, + spec: SpecCompliance, + ) -> ImageResult> { + let mut decoder = Self::new(r)?; + decoder.spec_compliance = spec; + Ok(decoder) + } } impl ImageDecoder for JpegDecoder { @@ -85,7 +98,7 @@ impl ImageDecoder for JpegDecoder { fn icc_profile(&mut self) -> ImageResult>> { let options = zune_core::options::DecoderOptions::default() - .set_strict_mode(false) + .set_strict_mode(self.spec_compliance == SpecCompliance::Strict) .set_max_width(usize::MAX) .set_max_height(usize::MAX); let mut decoder = @@ -96,7 +109,7 @@ impl ImageDecoder for JpegDecoder { fn exif_metadata(&mut self) -> ImageResult>> { let options = zune_core::options::DecoderOptions::default() - .set_strict_mode(false) + .set_strict_mode(self.spec_compliance == SpecCompliance::Strict) .set_max_width(usize::MAX) .set_max_height(usize::MAX); let mut decoder = @@ -115,7 +128,7 @@ impl ImageDecoder for JpegDecoder { fn xmp_metadata(&mut self) -> ImageResult>> { let options = zune_core::options::DecoderOptions::default() - .set_strict_mode(false) + .set_strict_mode(self.spec_compliance == SpecCompliance::Strict) .set_max_width(usize::MAX) .set_max_height(usize::MAX); let mut decoder = @@ -127,7 +140,7 @@ impl ImageDecoder for JpegDecoder { fn iptc_metadata(&mut self) -> ImageResult>> { let options = zune_core::options::DecoderOptions::default() - .set_strict_mode(false) + .set_strict_mode(self.spec_compliance == SpecCompliance::Strict) .set_max_width(usize::MAX) .set_max_height(usize::MAX); let mut decoder = @@ -160,7 +173,12 @@ impl ImageDecoder for JpegDecoder { ))); } - let mut decoder = new_zune_decoder(&self.input, self.orig_color_space, self.limits); + let mut decoder = new_zune_decoder( + &self.input, + self.orig_color_space, + self.spec_compliance == SpecCompliance::Strict, + self.limits, + ); decoder.decode_into(buf).map_err(ImageError::from_jpeg)?; Ok(()) } @@ -207,12 +225,13 @@ fn to_supported_color_space(orig: ZuneColorSpace) -> ZuneColorSpace { fn new_zune_decoder( input: &[u8], orig_color_space: ZuneColorSpace, + strict_mode: bool, limits: Limits, ) -> zune_jpeg::JpegDecoder> { let target_color_space = to_supported_color_space(orig_color_space); let mut options = zune_core::options::DecoderOptions::default() .jpeg_set_out_colorspace(target_color_space) - .set_strict_mode(false); + .set_strict_mode(strict_mode); options = options.set_max_width(match limits.max_image_width { Some(max_width) => max_width as usize, // u32 to usize never truncates None => usize::MAX, @@ -251,4 +270,22 @@ mod tests { let mut decoder = JpegDecoder::new(Cursor::new(data)).unwrap(); assert_eq!(decoder.orientation().unwrap(), Orientation::FlipHorizontal); } + + #[test] + fn test_strict_vs_lenient_spec_compliance() { + let mut image = fs::read("tests/images/jpg/progressive/cat.jpg").unwrap(); + image.truncate(image.len() - 1000); // simulate a truncated image + + // Default (lenient) mode: truncated image should be accepted + let decoder = JpegDecoder::new(Cursor::new(&image)).unwrap(); + let mut buffer = vec![0u8; decoder.total_bytes() as usize]; + assert!(decoder.read_image(&mut buffer).is_ok()); + + // Strict mode: truncated image should be rejected + let decoder = + JpegDecoder::new_with_spec_compliance(Cursor::new(&image), SpecCompliance::Strict) + .unwrap(); + let mut buffer = vec![0u8; decoder.total_bytes() as usize]; + assert!(decoder.read_image(&mut buffer).is_err()); + } } diff --git a/src/io/image_reader_type.rs b/src/io/image_reader_type.rs index d1a46e427b..08f93ae301 100644 --- a/src/io/image_reader_type.rs +++ b/src/io/image_reader_type.rs @@ -10,6 +10,17 @@ use crate::{DynamicImage, ImageDecoder, ImageError, ImageFormat}; use super::free_functions; +/// Controls how strictly an image decoder adheres to the format specification. +/// The default is [`SpecCompliance::Lenient`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SpecCompliance { + /// Strictly follow the format specification. + Strict, + /// Allow non-conformant files that can still be decoded at best effort. + #[default] + Lenient, +} + #[derive(Clone)] enum Format { BuiltIn(ImageFormat), @@ -72,6 +83,8 @@ pub struct ImageReader { format: Option, /// Decoding limits limits: Limits, + /// Spec compliance mode + spec_compliance: SpecCompliance, } impl<'a, R: 'a + BufRead + Seek> ImageReader { @@ -90,6 +103,7 @@ impl<'a, R: 'a + BufRead + Seek> ImageReader { inner: buffered_reader, format: None, limits: Limits::default(), + spec_compliance: SpecCompliance::default(), } } @@ -102,6 +116,7 @@ impl<'a, R: 'a + BufRead + Seek> ImageReader { inner: buffered_reader, format: Some(Format::BuiltIn(format)), limits: Limits::default(), + spec_compliance: SpecCompliance::default(), } } @@ -137,6 +152,11 @@ impl<'a, R: 'a + BufRead + Seek> ImageReader { self.limits = limits; } + /// Set the spec compliance mode for decoding. + pub fn set_spec_compliance(&mut self, spec: SpecCompliance) { + self.spec_compliance = spec; + } + /// Unwrap the reader. pub fn into_inner(self) -> R { self.inner @@ -151,6 +171,7 @@ impl<'a, R: 'a + BufRead + Seek> ImageReader { format: Format, reader: R, limits_for_png: Limits, + spec_compliance: SpecCompliance, ) -> ImageResult> { #[allow(unused)] use crate::codecs::*; @@ -178,7 +199,10 @@ impl<'a, R: 'a + BufRead + Seek> ImageReader { #[cfg(feature = "gif")] ImageFormat::Gif => Box::new(gif::GifDecoder::new(reader)?), #[cfg(feature = "jpeg")] - ImageFormat::Jpeg => Box::new(jpeg::JpegDecoder::new(reader)?), + ImageFormat::Jpeg => Box::new(jpeg::JpegDecoder::new_with_spec_compliance( + reader, + spec_compliance, + )?), #[cfg(feature = "webp")] ImageFormat::WebP => Box::new(webp::WebPDecoder::new(reader)?), #[cfg(feature = "tiff")] @@ -188,7 +212,10 @@ impl<'a, R: 'a + BufRead + Seek> ImageReader { #[cfg(feature = "dds")] ImageFormat::Dds => Box::new(dds::DdsDecoder::new(reader)?), #[cfg(feature = "bmp")] - ImageFormat::Bmp => Box::new(bmp::BmpDecoder::new(reader)?), + ImageFormat::Bmp => Box::new(bmp::BmpDecoder::new_with_spec_compliance( + reader, + spec_compliance, + )?), #[cfg(feature = "ico")] ImageFormat::Ico => Box::new(ico::IcoDecoder::new(reader)?), #[cfg(feature = "hdr")] @@ -211,8 +238,12 @@ impl<'a, R: 'a + BufRead + Seek> ImageReader { /// Convert the reader into a decoder. pub fn into_decoder(mut self) -> ImageResult { - let mut decoder = - Self::make_decoder(self.require_format()?, self.inner, self.limits.clone())?; + let mut decoder = Self::make_decoder( + self.require_format()?, + self.inner, + self.limits.clone(), + self.spec_compliance, + )?; decoder.set_limits(self.limits)?; Ok(decoder) } @@ -294,7 +325,8 @@ impl<'a, R: 'a + BufRead + Seek> ImageReader { let format = self.require_format()?; let mut limits = self.limits; - let mut decoder = Self::make_decoder(format, self.inner, limits.clone())?; + let mut decoder = + Self::make_decoder(format, self.inner, limits.clone(), self.spec_compliance)?; // Check that we do not allocate a bigger buffer than we are allowed to // FIXME: should this rather go in `DynamicImage::from_decoder` somehow? @@ -340,6 +372,7 @@ impl ImageReader> { inner: BufReader::new(File::open(path)?), format, limits: Limits::default(), + spec_compliance: SpecCompliance::default(), }) } } diff --git a/src/lib.rs b/src/lib.rs index 5464a12e72..d8bdefaf68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -163,7 +163,7 @@ pub use crate::io::{ decoder::{AnimationDecoder, ImageDecoder}, encoder::ImageEncoder, format::ImageFormat, - image_reader_type::ImageReader, + image_reader_type::{ImageReader, SpecCompliance}, limits::{LimitSupport, Limits}, };