Skip to content

Commit 49acf15

Browse files
authored
Merge pull request #2764 from telecos/feature/lenient-validation
Allow lenient validation in BMP decoder
2 parents c402bf0 + fb47c4a commit 49acf15

File tree

7 files changed

+102
-13
lines changed

7 files changed

+102
-13
lines changed

src/codecs/bmp/decoder.rs

Lines changed: 100 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@ use crate::error::{
1111
};
1212
use crate::{ImageDecoder, ImageFormat};
1313

14+
/// Controls how strictly the BMP decoder adheres to the specification.
15+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16+
pub(crate) enum BmpSpec {
17+
/// Strictly follow the BMP specification.
18+
/// Rejects files that violate spec constraints (e.g., RLE with top-down).
19+
Strict,
20+
/// Allow some non-conformant files that violate some spec constraints
21+
/// but still can be decoded at best effort.
22+
#[default]
23+
Lenient,
24+
}
25+
1426
const BITMAPCOREHEADER_SIZE: u32 = 12;
1527
const BITMAPINFOHEADER_SIZE: u32 = 40;
1628
const BITMAPV2HEADER_SIZE: u32 = 52;
@@ -116,12 +128,12 @@ struct ParsedCoreHeader {
116128

117129
impl ParsedCoreHeader {
118130
/// Parse BITMAPCOREHEADER fields from an 8-byte buffer.
119-
fn parse(buffer: &[u8; 8]) -> ImageResult<Self> {
131+
fn parse(buffer: &[u8; 8], spec_strictness: BmpSpec) -> ImageResult<Self> {
120132
let width = i32::from(u16::from_le_bytes(buffer[0..2].try_into().unwrap()));
121133
let height = i32::from(u16::from_le_bytes(buffer[2..4].try_into().unwrap()));
122134

123135
let planes = u16::from_le_bytes(buffer[4..6].try_into().unwrap());
124-
if planes != 1 {
136+
if spec_strictness == BmpSpec::Strict && planes != 1 {
125137
return Err(DecoderError::MoreThanOnePlane.into());
126138
}
127139

@@ -157,7 +169,7 @@ struct ParsedInfoHeader {
157169

158170
impl ParsedInfoHeader {
159171
/// Parse BITMAPINFOHEADER fields from a 36-byte buffer.
160-
fn parse(buffer: &[u8; 36]) -> ImageResult<Self> {
172+
fn parse(buffer: &[u8; 36], spec_strictness: BmpSpec) -> ImageResult<Self> {
161173
let width = i32::from_le_bytes(buffer[0..4].try_into().unwrap());
162174
let mut height = i32::from_le_bytes(buffer[4..8].try_into().unwrap());
163175

@@ -181,15 +193,21 @@ impl ParsedInfoHeader {
181193
};
182194

183195
let planes = u16::from_le_bytes(buffer[8..10].try_into().unwrap());
184-
if planes != 1 {
196+
if spec_strictness == BmpSpec::Strict && planes != 1 {
185197
return Err(DecoderError::MoreThanOnePlane.into());
186198
}
187199

188200
let bit_count = u16::from_le_bytes(buffer[10..12].try_into().unwrap());
189201
let compression = u32::from_le_bytes(buffer[12..16].try_into().unwrap());
190202

191-
// Top-down DIBs cannot be compressed
192-
if top_down && compression != BI_RGB && compression != BI_BITFIELDS {
203+
// Top-down DIBs cannot be compressed (per BMP specification).
204+
// In lenient mode, we allow this for compatibility with other decoders.
205+
if spec_strictness == BmpSpec::Strict
206+
&& top_down
207+
&& compression != BI_RGB
208+
&& compression != BI_BITFIELDS
209+
&& compression != BI_ALPHABITFIELDS
210+
{
193211
return Err(DecoderError::ImageTypeInvalidForTopDown(compression).into());
194212
}
195213

@@ -780,6 +798,7 @@ impl Bitfield {
780798
fn read(&self, data: u32) -> u8 {
781799
let data = data >> self.shift;
782800
match self.len {
801+
0 => 0,
783802
1 => ((data & 0b1) * 0xff) as u8,
784803
2 => ((data & 0b11) * 0x55) as u8,
785804
3 => LOOKUP_TABLE_3_BIT_TO_8_BIT[(data & 0b00_0111) as usize],
@@ -808,14 +827,19 @@ impl Bitfields {
808827
b_mask: u32,
809828
a_mask: u32,
810829
max_len: u32,
830+
spec_strictness: BmpSpec,
811831
) -> ImageResult<Bitfields> {
812832
let bitfields = Bitfields {
813833
r: Bitfield::from_mask(r_mask, max_len)?,
814834
g: Bitfield::from_mask(g_mask, max_len)?,
815835
b: Bitfield::from_mask(b_mask, max_len)?,
816836
a: Bitfield::from_mask(a_mask, max_len)?,
817837
};
818-
if bitfields.r.len == 0 || bitfields.g.len == 0 || bitfields.b.len == 0 {
838+
// In strict mode, all RGB channels must have non-zero masks.
839+
// In lenient mode, allow zero masks (the channel will read as 0).
840+
if spec_strictness == BmpSpec::Strict
841+
&& (bitfields.r.len == 0 || bitfields.g.len == 0 || bitfields.b.len == 0)
842+
{
819843
return Err(DecoderError::BitfieldMaskMissing(max_len).into());
820844
}
821845
Ok(bitfields)
@@ -901,6 +925,7 @@ pub struct BmpDecoder<R> {
901925
palette: Option<Vec<[u8; 3]>>,
902926
bitfields: Option<Bitfields>,
903927
icc_profile: Option<Vec<u8>>,
928+
spec_strictness: BmpSpec,
904929

905930
/// Current decoder state for resumable decoding.
906931
state: DecoderState,
@@ -935,7 +960,7 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
935960
palette: None,
936961
bitfields: None,
937962
icc_profile: None,
938-
963+
spec_strictness: BmpSpec::default(),
939964
state: DecoderState::default(),
940965
}
941966
}
@@ -1120,7 +1145,7 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
11201145
let mut buffer = [0u8; 8];
11211146
self.reader.read_exact(&mut buffer)?;
11221147

1123-
let parsed = ParsedCoreHeader::parse(&buffer)?;
1148+
let parsed = ParsedCoreHeader::parse(&buffer, self.spec_strictness)?;
11241149

11251150
self.width = parsed.width;
11261151
self.height = parsed.height;
@@ -1140,7 +1165,8 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
11401165
// Info header (after size field): 36 bytes minimum
11411166
let mut buffer = [0u8; 36];
11421167
self.reader.read_exact(&mut buffer)?;
1143-
let parsed = ParsedInfoHeader::parse(&buffer)?;
1168+
1169+
let parsed = ParsedInfoHeader::parse(&buffer, self.spec_strictness)?;
11441170

11451171
self.width = parsed.width;
11461172
self.height = parsed.height;
@@ -1193,6 +1219,7 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
11931219
parsed.b_mask,
11941220
parsed.a_mask,
11951221
max_len,
1222+
self.spec_strictness,
11961223
)?)
11971224
}
11981225
_ => None,
@@ -1431,14 +1458,17 @@ impl<R: BufRead + Seek> BmpDecoder<R> {
14311458
match self.colors_used {
14321459
0 => Ok(1 << self.bit_count),
14331460
_ => {
1434-
if self.colors_used > 1 << self.bit_count {
1461+
if self.spec_strictness == BmpSpec::Strict && self.colors_used > 1 << self.bit_count
1462+
{
14351463
return Err(DecoderError::PaletteSizeExceeded {
14361464
colors_used: self.colors_used,
14371465
bit_count: self.bit_count,
14381466
}
14391467
.into());
14401468
}
1441-
Ok(self.colors_used as usize)
1469+
// In lenient mode, clamp to max palette size for the bit depth
1470+
let max_size = 1usize << self.bit_count;
1471+
Ok((self.colors_used as usize).min(max_size))
14421472
}
14431473
}
14441474
}
@@ -2563,4 +2593,62 @@ mod test {
25632593
assert_eq!(buf, ref_buf, "{path}: decoded data mismatch");
25642594
}
25652595
}
2596+
2597+
/// Test that BMP files with known spec violations are accepted by the
2598+
/// decoder (which defaults to lenient mode), and that strict mode still
2599+
/// detects the violations internally.
2600+
///
2601+
/// These files come from the Chromium BMP test suite ("bad/" category):
2602+
/// - `rletopdown`: RLE compression with top-down orientation (spec forbids this)
2603+
/// - `badplanes`: planes field != 1 (spec requires exactly 1)
2604+
/// - `badpalettesize`: colors_used exceeds max for the bit depth
2605+
/// - `pal8oversizepal`: 8-bit palette with colors_used=300 (max is 256)
2606+
/// - `rgb16-880`: 16-bit bitfields with 8-8-0 channel widths (blue mask is zero)
2607+
#[test]
2608+
fn test_strict_vs_lenient_spec_validation() {
2609+
let questionable_files = [
2610+
(
2611+
"tests/images/bmp/images/lenient/rletopdown.bmp",
2612+
"rletopdown: RLE with top-down should be rejected in strict mode",
2613+
),
2614+
(
2615+
"tests/images/bmp/images/lenient/badplanes.bmp",
2616+
"badplanes: planes != 1 should be rejected in strict mode",
2617+
),
2618+
(
2619+
"tests/images/bmp/images/lenient/badpalettesize.bmp",
2620+
"badpalettesize: palette size exceeding bit depth should be rejected in strict mode",
2621+
),
2622+
(
2623+
"tests/images/bmp/images/lenient/pal8oversizepal.bmp",
2624+
"pal8oversizepal: colors_used=300 exceeds max 256 for 8-bit",
2625+
),
2626+
(
2627+
"tests/images/bmp/images/lenient/rgb16-880.bmp",
2628+
"rgb16-880: zero blue mask should be rejected in strict mode",
2629+
),
2630+
];
2631+
2632+
for (path, description) in &questionable_files {
2633+
let data = std::fs::read(path)
2634+
.unwrap_or_else(|e| panic!("{description}: failed to read {path}: {e}"));
2635+
2636+
// Default (lenient) mode: these files should be accepted
2637+
let decoder = BmpDecoder::new(Cursor::new(&data)).unwrap_or_else(|e| {
2638+
panic!("{description}: decoding failed: {e:?}");
2639+
});
2640+
let mut buf = vec![0u8; decoder.total_bytes() as usize];
2641+
decoder.read_image(buf.as_mut_slice()).unwrap_or_else(|e| {
2642+
panic!("{description}: read_image failed: {e:?}");
2643+
});
2644+
2645+
// Strict mode (internal): these files should be rejected
2646+
let mut strict_decoder = BmpDecoder::new_resumable(Cursor::new(&data));
2647+
strict_decoder.spec_strictness = BmpSpec::Strict;
2648+
assert!(
2649+
strict_decoder.read_metadata().is_err(),
2650+
"{description}: expected error in strict mode, but got Ok"
2651+
);
2652+
}
2653+
}
25662654
}
9.04 KB
Binary file not shown.
File renamed without changes.
File renamed without changes.
16.1 KB
Binary file not shown.
File renamed without changes.

tests/regression.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,8 @@ fn bad_bmps() {
9494
// Manually reading the file so we can use load() instead of open()
9595
// We have to use load() so we can override the format
9696
let im_file = BufReader::new(File::open(path).unwrap());
97-
let im = image::load(im_file, image::ImageFormat::Bmp);
97+
let im: Result<image::DynamicImage, image::ImageError> =
98+
image::load(im_file, image::ImageFormat::Bmp);
9899
assert!(im.is_err());
99100
}
100101
}

0 commit comments

Comments
 (0)