LR-IMP-FORMATS: GIF and BMP sizes from the header; a canvas the file can't fill is refused (#2553)

read_header had no reader for GIF or BMP and fell back to a full decode, so a few-dozen-byte file
declaring a canvas just under the 1-gigapixel limit made the import probe allocate ~4.3 GB; a
failed allocation aborts the process. A decode had the same allocation.

- other::header reads the size from the header. A canvas up to 4 MP is still decoded there, so
  truncated small files are refused at import as before.
- check_fillable refuses a canvas larger than the file's bytes can describe (BMP: 128 pixels per
  byte, covering 1-bit and RLE; GIF: 11,000, an LZW code of at least 3 bits expanding to at most
  4096 pixels), in both the probe and the decode, before the decoder allocates.
- The GIF and BMP decoders get an allocation limit (the RGBA buffer of max_pixels).

The approach follows the abandoned #2182 (PierrunoYT). JPEG XL still decodes in the probe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K93HTyCiwYvbRGaAkjPvXn
This commit is contained in:
Claude
2026-10-22 10:55:54 -07:00
parent c66014e65f
commit c2cdb112b7
4 changed files with 126 additions and 0 deletions
+1
View File
@@ -207,6 +207,7 @@ pub fn read_header_unguarded(bytes: &[u8], format: Format) -> Result<Header> {
Format::WebP => webp::header(bytes)?,
Format::Psd => psd::header(bytes)?,
Format::Heif => heif::header(bytes)?,
Format::Gif | Format::Bmp => other::header(bytes, format)?,
// rare here (or, for JPEG XL, without a header reader yet): a small decode, as before
_ => {
let d = decode_unguarded(bytes, format, &DecodeOptions::fit(64, 64))?;
+56
View File
@@ -4,6 +4,58 @@ use crate::convert::{Buf, Meta, Model, Raw, check_size, finish};
use crate::{DecodeOptions, Decoded, Error, Format, Result};
use image::{DynamicImage, ImageDecoder};
/// Canvases up to this many pixels are still decoded by [`header`] (at most 16 MB), so a truncated
/// small file is refused at import as before.
const HEADER_DECODE_MAX_PIXELS: u64 = 4_000_000;
/// Stored size for the import probe (issue #2553). The probe used to decode these in full, and a
/// few-dozen-byte file declaring a gigapixel canvas allocated gigabytes. The size now comes from the
/// header and must be one the file's bytes can fill; only a canvas small enough to be cheap is
/// decoded (to catch truncation). Orientation is always 1: neither format carries EXIF here.
pub(crate) fn header(bytes: &[u8], format: Format) -> Result<(u32, u32, u16)> {
let err = |e: image::ImageError| Error::Malformed(format, e.to_string());
let cursor = std::io::Cursor::new(bytes);
let (w, h) = match format {
Format::Gif => image::codecs::gif::GifDecoder::new(cursor).map_err(err)?.dimensions(),
Format::Bmp => image::codecs::bmp::BmpDecoder::new(cursor).map_err(err)?.dimensions(),
_ => return Err(Error::Unsupported(format, "not handled by this decoder")),
};
if w == 0 || h == 0 {
return Err(Error::Malformed(format, "zero dimension".into()));
}
check_fillable(format, bytes.len(), w, h)?;
if u64::from(w).saturating_mul(u64::from(h)) <= HEADER_DECODE_MAX_PIXELS {
let d = decode(bytes, format, &DecodeOptions::fit(64, 64))?;
return Ok((d.source_width, d.source_height, d.orientation));
}
Ok((w, h, 1))
}
/// The decoder's allocation limit: the RGBA buffer of the largest image `opts` allows, so an
/// oversized canvas is an error from the decoder instead of an allocation attempt.
fn limits(opts: &DecodeOptions) -> image::Limits {
let mut l = image::Limits::default();
l.max_alloc = Some(opts.max_pixels.saturating_mul(4));
l
}
/// The most pixels `len` bytes of pixel data can describe: BMP stores at least 1 bit per pixel and
/// its RLE at most 255 pixels per 2-byte run (128 per byte covers both); a GIF LZW code takes at
/// least 3 bits and expands to at most 4096 pixels (11,000 per byte). The decoders allocate the
/// whole canvas before reading any pixel data, so a header declaring far more than the file can
/// fill is refused first (issue #2553).
fn fillable(format: Format, len: usize) -> u64 {
let per_byte = if format == Format::Gif { 11_000 } else { 128 };
(len as u64).saturating_mul(per_byte)
}
fn check_fillable(format: Format, len: usize, w: u32, h: u32) -> Result<()> {
if u64::from(w).saturating_mul(u64::from(h)) > fillable(format, len) {
return Err(Error::Malformed(format, format!("a {w} × {h} canvas is far larger than its {len} bytes can hold")));
}
Ok(())
}
pub(crate) fn decode(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
let err = |e: image::ImageError| Error::Malformed(format, e.to_string());
let cursor = std::io::Cursor::new(bytes);
@@ -12,6 +64,8 @@ pub(crate) fn decode(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Resu
let mut d = image::codecs::gif::GifDecoder::new(cursor).map_err(err)?;
let (w, h) = d.dimensions();
check_size(format, w as u64, h as u64, opts)?;
check_fillable(format, bytes.len(), w, h)?;
d.set_limits(limits(opts)).map_err(err)?;
let icc = d.icc_profile().ok().flatten();
(DynamicImage::from_decoder(d).map_err(err)?, icc)
}
@@ -19,6 +73,8 @@ pub(crate) fn decode(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Resu
let mut d = image::codecs::bmp::BmpDecoder::new(cursor).map_err(err)?;
let (w, h) = d.dimensions();
check_size(format, w as u64, h as u64, opts)?;
check_fillable(format, bytes.len(), w, h)?;
d.set_limits(limits(opts)).map_err(err)?;
let icc = d.icc_profile().ok().flatten();
(DynamicImage::from_decoder(d).map_err(err)?, icc)
}
+61
View File
@@ -74,6 +74,67 @@ fn thumbnail_rejects_huge_gif_before_allocating_source_pixels() {
assert!(matches!(result, Err(Error::TooLarge(42_127, 16_304))), "{result:?}");
}
/// A GIF of a few dozen bytes: a `w` × `h` logical screen and one empty frame of that size.
fn tiny_gif(w: u16, h: u16) -> Vec<u8> {
let mut b = b"GIF89a".to_vec();
b.extend(w.to_le_bytes());
b.extend(h.to_le_bytes());
b.extend([0, 0, 0, b',', 0, 0, 0, 0]);
b.extend(w.to_le_bytes());
b.extend(h.to_le_bytes());
b.extend([0, 2, 0, b';']);
b
}
/// A 54-byte uncompressed 24-bit BMP header declaring `w` × `h` and no pixel data.
fn tiny_bmp(w: i32, h: i32) -> Vec<u8> {
let mut b = b"BM".to_vec();
b.extend(54u32.to_le_bytes());
b.extend(0u32.to_le_bytes());
b.extend(54u32.to_le_bytes());
b.extend(40u32.to_le_bytes());
b.extend(w.to_le_bytes());
b.extend(h.to_le_bytes());
b.extend(1u16.to_le_bytes());
b.extend(24u16.to_le_bytes());
b.extend([0u8; 24]);
b
}
/// Issue #2553: the import probe had no header reader for GIF or BMP and decoded them in full, so
/// a tiny file declaring a canvas just under the 1-gigapixel limit allocated ~4.3 GB (an abort, not
/// an error, when it failed). Both the probe and a decode now refuse a canvas the file's bytes
/// can't fill, before the decoder allocates it.
#[test]
fn tiny_gif_and_bmp_declaring_a_gigapixel_canvas_are_refused_before_allocating() {
for (name, bytes) in [("gif", tiny_gif(32_767, 32_767)), ("bmp", tiny_bmp(32_000, 32_000))] {
assert!(bytes.len() < 64, "{name}: {} bytes", bytes.len());
let header = read_header(&bytes);
assert!(matches!(&header, Err(Error::Malformed(_, m)) if m.contains("can hold")), "{name}: {header:?}");
let decoded = decode(&bytes, DecodeOptions::default());
assert!(matches!(&decoded, Err(Error::Malformed(_, m)) if m.contains("can hold")), "{name}: {:?}", decoded.err());
}
}
/// Above 4 MP the probe reads GIF and BMP sizes from the header without decoding; the size agrees
/// with a full decode.
#[test]
fn large_gif_and_bmp_sizes_come_from_the_header() {
let (w, h) = (2_100u32, 2_000u32);
let rgb = vec![90u8; (w * h * 3) as usize];
let mut bmp = Vec::new();
image::codecs::bmp::BmpEncoder::new(&mut bmp).encode(&rgb, w, h, image::ExtendedColorType::Rgb8).unwrap();
let mut gif = Vec::new();
let frame = image::RgbaImage::from_pixel(w, h, image::Rgba([90, 90, 90, 255]));
image::codecs::gif::GifEncoder::new(&mut gif).encode_frame(image::Frame::new(frame)).unwrap();
for (name, bytes) in [("bmp", bmp), ("gif", gif)] {
let header = read_header(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((header.width, header.height, header.orientation), (w, h, 1), "{name}");
let d = decode(&bytes, DecodeOptions::fit(64, 64)).unwrap();
assert_eq!((d.source_width, d.source_height), (w, h), "{name}");
}
}
#[test]
fn thumbnail_respects_configured_source_pixel_limit() {
let image = Rgba8::from_fn(3, 2, |_, _| [40, 80, 120, 255]);
+8
View File
@@ -1,5 +1,13 @@
# What's new in LightCraft
## 2026-10-22
### A damaged GIF or BMP can no longer crash an import
- A GIF or BMP whose header claims an enormous picture (a file of a few dozen bytes can claim a billion pixels) is
now refused with an error when importing or opening it (issue #2553). Before, LightCraft set aside memory for the
whole picture, several gigabytes, and could close without warning, losing unsaved work. Large GIF and BMP files
are also measured from their header at import instead of being decoded.
## 2026-10-21
### Sharper resampling in HDR and panorama merges