mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 05:57:09 -06:00
Merge PR #2607: LR-IMP-FORMATS: GIF and BMP sizes from the header; a canvas the file can't fill is refused (#2553)
This commit is contained in:
@@ -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))?;
|
||||
|
||||
+333
-1
@@ -4,6 +4,291 @@ 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, 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. It bounds the
|
||||
/// decoders' own frame buffers only: `DynamicImage::from_decoder` allocates the canvas without
|
||||
/// consulting it, so the canvas is bounded by `check_size` and [`check_fillable`] beforehand (a
|
||||
/// mostly blank one by [`SPARSE_MAX_PIXELS`]).
|
||||
fn limits(opts: &DecodeOptions) -> image::Limits {
|
||||
let mut l = image::Limits::default();
|
||||
l.max_alloc = Some(opts.max_pixels.saturating_mul(4));
|
||||
l
|
||||
}
|
||||
|
||||
/// The largest canvas allowed that the file's pixel data leaves mostly blank: a GIF screen its
|
||||
/// first frame's codes don't cover, or an RLE BMP whose runs don't. Its decode still allocates the
|
||||
/// whole canvas (16 MB at this size), so it is held well below `max_pixels`.
|
||||
const SPARSE_MAX_PIXELS: u64 = 4_000_000;
|
||||
|
||||
/// Refuses a canvas larger than the file's pixel data can describe, before the decoder allocates it
|
||||
/// (issue #2553). Coverage is measured from the pixel data itself, never from the file's length, so
|
||||
/// padding (comments, bytes after an end code, trailing bytes) covers nothing:
|
||||
///
|
||||
/// - Uncompressed BMP rows are read in full, padding included, from the offset the file header
|
||||
/// gives, so the data must hold every row: anything shorter fails to decode once the canvas is
|
||||
/// allocated, and is refused here instead.
|
||||
/// - An RLE BMP covers the pixels its runs and absolute blocks draw, as the decoder paints them; an
|
||||
/// end-of-row, end-of-bitmap or delta code leaves the rest blank.
|
||||
/// - A GIF covers the pixels its first frame's LZW codes draw up to the end code. A code expands to
|
||||
/// at most 4096 pixels and takes 12 bits once the table holds that many (fewer bits only while its
|
||||
/// strings are shorter), so a frame larger than 2731 pixels per byte of LZW data is refused
|
||||
/// outright. The screen around the frame may be larger, and is blank there.
|
||||
///
|
||||
/// Canvases the data leaves mostly blank are limited to [`SPARSE_MAX_PIXELS`].
|
||||
fn check_fillable(format: Format, bytes: &[u8], w: u32, h: u32) -> Result<()> {
|
||||
let area = |w: u32, h: u32| u64::from(w).saturating_mul(u64::from(h));
|
||||
let canvas = area(w, h);
|
||||
// the pixels the file's data can cover
|
||||
let covered = match format {
|
||||
Format::Gif => {
|
||||
// without a frame there is nothing to decode, and nothing to bound the canvas by
|
||||
let Some(frame) = gif_first_frame(bytes) else {
|
||||
return Err(Error::Malformed(format, "no readable first frame".into()));
|
||||
};
|
||||
let (fw, fh, len) = (frame.width, frame.height, frame.data_len);
|
||||
if area(fw, fh) > (len as u64).saturating_mul(2_731) {
|
||||
return Err(Error::Malformed(format, format!("a {fw} × {fh} frame is far larger than its {len} bytes of pixel data can hold")));
|
||||
}
|
||||
gif_frame_pixels(bytes, frame.codes, area(fw, fh))
|
||||
}
|
||||
_ => match bmp_layout(bytes) {
|
||||
BmpLayout::Rle { offset, rle4 } => bmp_rle_pixels(bytes, offset, rle4, w, h),
|
||||
BmpLayout::Rows { offset, bits } => {
|
||||
let row = u64::from(w).saturating_mul(u64::from(bits)).div_ceil(32).saturating_mul(4);
|
||||
let needed = row.saturating_mul(u64::from(h));
|
||||
let held = bytes.len().saturating_sub(offset) as u64;
|
||||
if held < needed {
|
||||
return Err(Error::Malformed(
|
||||
format,
|
||||
format!("a {w} × {h} canvas is larger than its {held} bytes of pixel data can hold ({needed} needed)"),
|
||||
));
|
||||
}
|
||||
canvas
|
||||
}
|
||||
},
|
||||
};
|
||||
if canvas > covered && canvas > SPARSE_MAX_PIXELS {
|
||||
return Err(Error::Malformed(
|
||||
format,
|
||||
format!(
|
||||
"a {w} × {h} canvas is mostly blank (its pixel data covers at most {covered} pixels), and such pictures are limited to {SPARSE_MAX_PIXELS} pixels"
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where a BMP's pixels start (the offset in its file header, which the decoder seeks to) and how
|
||||
/// they are stored.
|
||||
enum BmpLayout {
|
||||
/// RLE8 or RLE4 commands.
|
||||
Rle { offset: usize, rle4: bool },
|
||||
/// Rows of `bits` per pixel, each padded to 4 bytes.
|
||||
Rows { offset: usize, bits: u16 },
|
||||
}
|
||||
|
||||
/// The layout the decoder reads, from the file header and the bitmap header: a 12-byte core header
|
||||
/// (bits per pixel at 24, never compressed) or a 40-byte or larger one (bits at 28, compression at
|
||||
/// 30). Fields the decoder would have refused are read as 1 bit per pixel at offset 0, the most
|
||||
/// lenient layout.
|
||||
fn bmp_layout(bytes: &[u8]) -> BmpLayout {
|
||||
let u16_at = |i: usize| bytes.get(i..i.checked_add(2)?).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes);
|
||||
let u32_at = |i: usize| bytes.get(i..i.checked_add(4)?).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
|
||||
let offset = u32_at(10).and_then(|o| usize::try_from(o).ok()).unwrap_or(0);
|
||||
let (bits, compression) = match u32_at(14) {
|
||||
Some(12) => (u16_at(24), Some(0)),
|
||||
_ => (u16_at(28), u32_at(30)),
|
||||
};
|
||||
match compression {
|
||||
Some(1) => BmpLayout::Rle { offset, rle4: false },
|
||||
Some(2) => BmpLayout::Rle { offset, rle4: true },
|
||||
_ => BmpLayout::Rows { offset, bits: bits.filter(|&b| b > 0).unwrap_or(1) },
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixels an RLE8 or RLE4 BMP's commands draw on a `w` × `h` canvas, read the way the decoder
|
||||
/// paints them: a run is clipped at the end of its row, an end-of-row code moves to the next row, a
|
||||
/// delta skips pixels (left blank), and an end-of-bitmap code, a truncated command or the last row
|
||||
/// ends the picture. Every command takes at least 2 bytes, so the walk is linear in the file.
|
||||
fn bmp_rle_pixels(bytes: &[u8], offset: usize, rle4: bool, w: u32, h: u32) -> u64 {
|
||||
let (w, h) = (u64::from(w), u64::from(h));
|
||||
let (mut pos, mut x, mut y, mut covered) = (offset, 0u64, 0u64, 0u64);
|
||||
while y < h {
|
||||
let (Some(&count), Some(&value)) = (bytes.get(pos), bytes.get(pos.saturating_add(1))) else {
|
||||
break;
|
||||
};
|
||||
pos = pos.saturating_add(2);
|
||||
match (count, value) {
|
||||
// end of row
|
||||
(0, 0) => (x, y) = (0, y.saturating_add(1)),
|
||||
// end of bitmap
|
||||
(0, 1) => break,
|
||||
// delta: right and down
|
||||
(0, 2) => {
|
||||
let (Some(&dx), Some(&dy)) = (bytes.get(pos), bytes.get(pos.saturating_add(1))) else {
|
||||
break;
|
||||
};
|
||||
pos = pos.saturating_add(2);
|
||||
(x, y) = (x.saturating_add(u64::from(dx)), y.saturating_add(u64::from(dy)));
|
||||
}
|
||||
// absolute: `value` pixels stored one per byte (RLE8) or nibble (RLE4), padded to 2 bytes
|
||||
(0, n) => {
|
||||
let len = if rle4 { usize::from(n).div_ceil(2) } else { usize::from(n) };
|
||||
pos = pos.saturating_add(len + (len & 1));
|
||||
if pos > bytes.len() {
|
||||
break;
|
||||
}
|
||||
covered = covered.saturating_add(u64::from(n).min(w.saturating_sub(x)));
|
||||
x = x.saturating_add(u64::from(n));
|
||||
}
|
||||
// a run of `count` pixels
|
||||
(n, _) => {
|
||||
covered = covered.saturating_add(u64::from(n).min(w.saturating_sub(x)));
|
||||
x = x.saturating_add(u64::from(n));
|
||||
}
|
||||
}
|
||||
}
|
||||
covered
|
||||
}
|
||||
|
||||
/// A GIF's first frame, from its image descriptor.
|
||||
struct GifFrame {
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// Bytes of LZW data present (its palettes, comments and other extensions describe no pixels).
|
||||
data_len: usize,
|
||||
/// Position of its LZW minimum code size, which its data sub-blocks follow.
|
||||
codes: usize,
|
||||
}
|
||||
|
||||
/// A GIF's first frame. `None` when the blocks before the frame can't be walked.
|
||||
fn gif_first_frame(bytes: &[u8]) -> Option<GifFrame> {
|
||||
let u16_at = |i: usize| bytes.get(i..i.checked_add(2)?).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes);
|
||||
let table = |flags: u8| if flags & 0x80 != 0 { 3usize << ((flags & 7) + 1) } else { 0 };
|
||||
let mut pos = 13 + table(*bytes.get(10)?);
|
||||
loop {
|
||||
match *bytes.get(pos)? {
|
||||
// extension: introducer, label, sub-blocks
|
||||
0x21 => pos = sub_blocks(bytes, pos.saturating_add(2)).1,
|
||||
// image descriptor (separator, left, top, width, height, flags), local colour table,
|
||||
// LZW minimum code size, data sub-blocks
|
||||
0x2C => {
|
||||
let (fw, fh) = (u16_at(pos.saturating_add(5))?, u16_at(pos.saturating_add(7))?);
|
||||
let codes = pos.saturating_add(10).saturating_add(table(*bytes.get(pos.saturating_add(9))?));
|
||||
let data_len = sub_blocks(bytes, codes.saturating_add(1)).0;
|
||||
return Some(GifFrame { width: u32::from(fw), height: u32::from(fh), data_len, codes });
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pixels a GIF frame's LZW codes draw, at most `max` (the frame's size): the codes are walked
|
||||
/// up to the end code, an invalid code or the end of the data, keeping only the length of each
|
||||
/// table string, never the pixels. Data after the end code draws nothing. Codes are packed
|
||||
/// least-significant bit first across the data sub-blocks and start one bit wider than the minimum
|
||||
/// code size; a code is one bit wider each time the table fills its width, up to 12 bits, and a
|
||||
/// clear code starts the table again (GIF89a, appendix F).
|
||||
fn gif_frame_pixels(bytes: &[u8], codes: usize, max: u64) -> u64 {
|
||||
// the `gif` decoder accepts minimum code sizes of 1 to 11
|
||||
let Some(min_size) = bytes.get(codes).copied().filter(|s| (1..=11).contains(s)) else {
|
||||
return 0;
|
||||
};
|
||||
let clear = 1usize << min_size;
|
||||
let end = clear + 1;
|
||||
let mut lens = [1u16; 4096]; // string length of each code; literals (below `clear`) are 1
|
||||
let (mut width, mut next, mut prev) = (u32::from(min_size) + 1, end + 1, None::<usize>);
|
||||
let (mut bits, mut held, mut covered) = (0u32, 0u32, 0u64);
|
||||
let mut pos = codes.saturating_add(1);
|
||||
while let Some(&n) = bytes.get(pos) {
|
||||
let start = pos.saturating_add(1);
|
||||
pos = start.saturating_add(usize::from(n));
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
for &byte in bytes.get(start..pos.min(bytes.len())).unwrap_or_default() {
|
||||
bits |= u32::from(byte) << held;
|
||||
held += 8;
|
||||
while held >= width {
|
||||
let code = (bits & ((1 << width) - 1)) as usize;
|
||||
bits >>= width;
|
||||
held -= width;
|
||||
if code == clear {
|
||||
(width, next, prev) = (u32::from(min_size) + 1, end + 1, None);
|
||||
continue;
|
||||
}
|
||||
let len_of = |c: usize| lens.get(c).copied().unwrap_or(1);
|
||||
let len = match prev {
|
||||
_ if code == end => return covered,
|
||||
None if code < clear => 1,
|
||||
Some(_) if code < next => len_of(code),
|
||||
// the string just being defined: the previous one and its first pixel
|
||||
Some(p) if code == next => len_of(p).saturating_add(1),
|
||||
_ => return covered,
|
||||
};
|
||||
// each code after the first defines the next string: the previous one and a pixel
|
||||
let defined = prev.map(|p| len_of(p).saturating_add(1));
|
||||
if let Some(defined) = defined
|
||||
&& let Some(slot) = lens.get_mut(next)
|
||||
{
|
||||
*slot = defined;
|
||||
next += 1;
|
||||
if next == 1 << width && width < 12 {
|
||||
width += 1;
|
||||
}
|
||||
}
|
||||
prev = Some(code);
|
||||
covered = covered.saturating_add(u64::from(len));
|
||||
if covered >= max {
|
||||
return max;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
covered
|
||||
}
|
||||
|
||||
/// The GIF sub-blocks starting at `pos`: the data bytes present, and the position after them.
|
||||
fn sub_blocks(bytes: &[u8], mut pos: usize) -> (usize, usize) {
|
||||
let mut data = 0usize;
|
||||
while let Some(&n) = bytes.get(pos) {
|
||||
let start = pos.saturating_add(1);
|
||||
if n == 0 {
|
||||
return (data, start);
|
||||
}
|
||||
let end = start.saturating_add(usize::from(n));
|
||||
data = data.saturating_add(end.min(bytes.len()).saturating_sub(start));
|
||||
pos = end;
|
||||
}
|
||||
(data, pos)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(bytes: &[u8], format: Format, opts: &DecodeOptions) -> Result<Decoded> {
|
||||
if format == Format::Gif {
|
||||
gated_first_frame(bytes, opts)?;
|
||||
@@ -15,6 +300,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, 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)
|
||||
}
|
||||
@@ -22,6 +309,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, 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)
|
||||
}
|
||||
@@ -88,7 +377,7 @@ fn gated_first_frame(bytes: &[u8], opts: &DecodeOptions) -> Result<()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::{DecodeOptions, Error};
|
||||
use super::*;
|
||||
|
||||
fn gif(blocks: &[u8]) -> Vec<u8> {
|
||||
let mut b = b"GIF89a".to_vec();
|
||||
@@ -118,4 +407,47 @@ mod tests {
|
||||
let r = crate::decode(&bytes, DecodeOptions::default());
|
||||
assert!(matches!(r, Err(Error::Malformed(..)) | Err(Error::TooLarge(..))), "{r:?}");
|
||||
}
|
||||
|
||||
/// A GIF whose blocks can't be walked to a first frame (here a comment that runs to the end of the
|
||||
/// file) is refused, instead of the whole file being counted as pixel data for its 400 MP screen.
|
||||
/// The `image` decoder also stops at such a file when it opens it, but the canvas bound doesn't
|
||||
/// rely on the two walks agreeing.
|
||||
#[test]
|
||||
fn a_gif_with_no_readable_first_frame_is_refused() {
|
||||
let mut b = b"GIF89a".to_vec();
|
||||
b.extend(20_000u16.to_le_bytes());
|
||||
b.extend(20_000u16.to_le_bytes());
|
||||
b.extend([0, 0, 0, 0x21, 0xFE]);
|
||||
for _ in 0..600 {
|
||||
b.push(255);
|
||||
b.extend([b'x'; 255]);
|
||||
}
|
||||
let r = check_fillable(Format::Gif, &b, 20_000, 20_000);
|
||||
assert!(matches!(&r, Err(Error::Malformed(_, m)) if m.contains("no readable first frame")), "{r:?}");
|
||||
}
|
||||
|
||||
/// The LZW walk counts exactly the pixels an encoder wrote up to its end code: a frame of one
|
||||
/// colour (long strings, the smallest codes), a 256-colour gradient and noise (a table that fills
|
||||
/// up and is cleared again).
|
||||
#[test]
|
||||
fn the_lzw_walk_counts_every_pixel_an_encoder_wrote() {
|
||||
let (w, h) = (640u32, 480u32);
|
||||
let mut seed = 2_553u32;
|
||||
let mut noise = move || {
|
||||
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
|
||||
(seed >> 24) as u8
|
||||
};
|
||||
let frames = [
|
||||
("solid", image::RgbaImage::from_pixel(w, h, image::Rgba([90, 90, 90, 255]))),
|
||||
("gradient", image::RgbaImage::from_fn(w, h, |x, y| image::Rgba([(x / 40 * 16) as u8, (y / 30 * 16) as u8, 0, 255]))),
|
||||
("noise", image::RgbaImage::from_fn(w, h, |_, _| image::Rgba([noise(), noise(), noise(), 255]))),
|
||||
];
|
||||
for (name, frame) in frames {
|
||||
let mut gif = Vec::new();
|
||||
image::codecs::gif::GifEncoder::new_with_speed(&mut gif, 30).encode_frame(image::Frame::new(frame)).unwrap();
|
||||
let first = gif_first_frame(&gif).unwrap();
|
||||
assert_eq!((first.width, first.height), (w, h), "{name}");
|
||||
assert_eq!(gif_frame_pixels(&gif, first.codes, u64::MAX), u64::from(w * h), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,212 @@ 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}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 64-byte RLE8 BMP declaring `w` × `h` whose pixels are one end-of-bitmap code: all blank.
|
||||
fn rle_bmp_ending_at_once(w: i32, h: i32) -> Vec<u8> {
|
||||
let mut b = b"BM".to_vec();
|
||||
b.extend(64u32.to_le_bytes()); // file size
|
||||
b.extend(0u32.to_le_bytes());
|
||||
b.extend(62u32.to_le_bytes()); // pixel data offset: 14 + 40 + 2 palette entries
|
||||
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(8u16.to_le_bytes());
|
||||
b.extend(1u32.to_le_bytes()); // BI_RLE8
|
||||
b.extend(2u32.to_le_bytes()); // pixel data size
|
||||
b.extend([0u8; 8]);
|
||||
b.extend(2u32.to_le_bytes()); // colours used
|
||||
b.extend(0u32.to_le_bytes());
|
||||
b.extend([0, 0, 0, 0, 255, 255, 255, 0]);
|
||||
b.extend([0, 1]); // end of bitmap
|
||||
assert_eq!(b.len(), 64);
|
||||
b
|
||||
}
|
||||
|
||||
/// An RLE8 BMP may end its pixels with an end-of-bitmap code that leaves the rest blank, so a file
|
||||
/// of a few dozen bytes is a valid picture: up to 4 MP it opens, at its declared size.
|
||||
#[test]
|
||||
fn an_rle_bmp_that_ends_early_still_opens() {
|
||||
let (w, h) = (2_000i32, 2_000i32);
|
||||
let b = rle_bmp_ending_at_once(w, h);
|
||||
let header = read_header(&b).unwrap();
|
||||
assert_eq!((header.width, header.height), (w as u32, h as u32));
|
||||
let d = decode(&b, DecodeOptions::fit(64, 64)).unwrap();
|
||||
assert_eq!((d.source_width, d.source_height), (w as u32, h as u32));
|
||||
}
|
||||
|
||||
/// A mostly blank canvas still costs its full size to decode, so a tiny RLE BMP can't claim a
|
||||
/// gigapixel one: canvases the file's runs can't cover are limited to 4 MP, refused before the
|
||||
/// decoder allocates them.
|
||||
#[test]
|
||||
fn a_tiny_rle_bmp_declaring_a_gigapixel_canvas_is_refused_before_allocating() {
|
||||
for (w, h) in [(2_001, 2_000), (32_000, 32_000)] {
|
||||
let b = rle_bmp_ending_at_once(w, h);
|
||||
let header = read_header(&b);
|
||||
assert!(matches!(&header, Err(Error::Malformed(_, m)) if m.contains("mostly blank")), "{w} × {h}: {header:?}");
|
||||
let decoded = decode(&b, DecodeOptions::default());
|
||||
assert!(matches!(&decoded, Err(Error::Malformed(_, m)) if m.contains("mostly blank")), "{w} × {h}: {:?}", decoded.err());
|
||||
}
|
||||
}
|
||||
|
||||
/// A `sw` × `sh` GIF screen whose first frame is one pixel (black, from a 2-colour palette).
|
||||
fn gif_with_one_pixel_frame(sw: u16, sh: u16) -> Vec<u8> {
|
||||
let mut b = b"GIF89a".to_vec();
|
||||
b.extend(sw.to_le_bytes());
|
||||
b.extend(sh.to_le_bytes());
|
||||
b.extend([0x80, 0, 0]); // global colour table of 2 entries
|
||||
b.extend([0, 0, 0, 255, 255, 255]);
|
||||
b.extend([b',', 0, 0, 0, 0, 1, 0, 1, 0, 0]); // a 1 × 1 frame at the origin
|
||||
b.extend([2, 2, 0x44, 0x01, 0]); // LZW: clear, index 0, end
|
||||
b.push(b';');
|
||||
b
|
||||
}
|
||||
|
||||
/// Density is judged on the first frame's own size, not the screen's: a valid GIF with a small first
|
||||
/// frame on a larger screen opens, at the screen's size (up to 4 MP, as for other mostly blank
|
||||
/// canvases).
|
||||
#[test]
|
||||
fn a_gif_with_a_small_first_frame_on_a_larger_screen_opens() {
|
||||
let b = gif_with_one_pixel_frame(2_000, 2_000);
|
||||
let header = read_header(&b).unwrap();
|
||||
assert_eq!((header.width, header.height), (2_000, 2_000));
|
||||
let d = decode(&b, DecodeOptions::fit(64, 64)).unwrap();
|
||||
assert_eq!((d.source_width, d.source_height), (2_000, 2_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gif_whose_one_pixel_frame_sits_on_a_gigapixel_screen_is_refused_before_allocating() {
|
||||
let b = gif_with_one_pixel_frame(32_767, 32_767);
|
||||
let header = read_header(&b);
|
||||
assert!(matches!(&header, Err(Error::Malformed(_, m)) if m.contains("mostly blank")), "{header:?}");
|
||||
let decoded = decode(&b, DecodeOptions::default());
|
||||
assert!(matches!(&decoded, Err(Error::Malformed(_, m)) if m.contains("mostly blank")), "{:?}", decoded.err());
|
||||
}
|
||||
|
||||
/// A GIF's comments and other extensions describe no pixels, so padding a tiny GIF with 100 KB of
|
||||
/// comment doesn't let it claim a gigapixel canvas: only the frame's LZW data counts.
|
||||
#[test]
|
||||
fn a_gif_padded_with_comments_still_cannot_claim_a_gigapixel_canvas() {
|
||||
let mut bytes = tiny_gif(32_767, 32_767);
|
||||
let mut comment = vec![0x21, 0xFE];
|
||||
for _ in 0..400 {
|
||||
comment.push(255);
|
||||
comment.extend([b'x'; 255]);
|
||||
}
|
||||
comment.push(0);
|
||||
bytes.splice(13..13, comment);
|
||||
assert!(bytes.len() > 100_000);
|
||||
let header = read_header(&bytes);
|
||||
assert!(matches!(&header, Err(Error::Malformed(_, m)) if m.contains("can hold")), "{header:?}");
|
||||
let decoded = decode(&bytes, DecodeOptions::default());
|
||||
assert!(matches!(&decoded, Err(Error::Malformed(_, m)) if m.contains("can hold")), "{:?}", decoded.err());
|
||||
}
|
||||
|
||||
/// Padding is not pixel data: coverage comes from what the pixel data draws, not from the file's
|
||||
/// length. Each file here is padded past what the old length-based bounds (8 pixels per byte for
|
||||
/// uncompressed BMP, 128 for RLE, 2731 per LZW byte for a GIF frame) needed to accept its 9 MP
|
||||
/// canvas, and each draws far less, so each is refused before the decoder allocates the canvas.
|
||||
#[test]
|
||||
fn files_padded_with_junk_cannot_claim_a_canvas_their_pixel_data_doesnt_draw() {
|
||||
let refused = |name: &str, bytes: &[u8], why: &str| {
|
||||
let header = read_header(bytes);
|
||||
assert!(matches!(&header, Err(Error::Malformed(_, m)) if m.contains(why)), "{name}: {header:?}");
|
||||
let decoded = decode(bytes, DecodeOptions::default());
|
||||
assert!(matches!(&decoded, Err(Error::Malformed(_, m)) if m.contains(why)), "{name}: {:?}", decoded.err());
|
||||
};
|
||||
// a 24-bit BMP needs 3 bytes a pixel (27 MB here); 1.2 MB of zeros used to pass as 8 pixels a byte
|
||||
let mut bmp = tiny_bmp(3_000, 3_000);
|
||||
bmp.resize(bmp.len() + 1_200_000, 0);
|
||||
refused("uncompressed bmp", &bmp, "can hold");
|
||||
// an RLE BMP's picture ends at its end-of-bitmap code; 100 KB of runs after it draw nothing
|
||||
let mut rle = rle_bmp_ending_at_once(3_000, 3_000);
|
||||
rle.extend([255, 1].repeat(50_000));
|
||||
refused("rle bmp padded after its end", &rle, "mostly blank");
|
||||
// runs that never end their row are clipped to it: 100 KB of them draw one 3000-pixel row
|
||||
let mut rle = rle_bmp_ending_at_once(3_000, 3_000);
|
||||
rle.truncate(rle.len() - 2);
|
||||
rle.extend([255, 1].repeat(50_000));
|
||||
refused("rle bmp padded within one row", &rle, "mostly blank");
|
||||
// a GIF frame's picture ends at its LZW end code; 4 KB of data sub-blocks after it draw nothing
|
||||
let mut gif = b"GIF89a".to_vec();
|
||||
gif.extend(3_000u16.to_le_bytes());
|
||||
gif.extend(3_000u16.to_le_bytes());
|
||||
gif.extend([0x80, 0, 0, 0, 0, 0, 255, 255, 255]); // 2-colour global table
|
||||
gif.extend([b',', 0, 0, 0, 0]);
|
||||
gif.extend(3_000u16.to_le_bytes());
|
||||
gif.extend(3_000u16.to_le_bytes());
|
||||
gif.extend([0, 2]); // no local table; LZW minimum code size 2
|
||||
gif.extend([255, 0x2C]); // a full sub-block: 3-bit clear (4) then end (5), then padding
|
||||
gif.extend([0xFF; 254]);
|
||||
for _ in 0..15 {
|
||||
gif.push(255);
|
||||
gif.extend([0xFF; 255]);
|
||||
}
|
||||
gif.extend([0, b';']);
|
||||
refused("gif padded after its end code", &gif, "mostly blank");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_respects_configured_source_pixel_limit() {
|
||||
let image = Rgba8::from_fn(3, 2, |_, _| [40, 80, 120, 255]);
|
||||
|
||||
@@ -32,6 +32,15 @@
|
||||
|
||||
## 2026-10-23
|
||||
|
||||
### 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. At import, a GIF or BMP
|
||||
picture larger than 4 megapixels now has its size read from the header instead of being decoded; smaller ones are
|
||||
still decoded, so a damaged file is caught then. A picture that is mostly blank (a run-length-encoded BMP that ends
|
||||
early, or a GIF whose first frame is smaller than the picture) still opens up to 4 megapixels; a larger one is
|
||||
refused, because opening it would still set aside memory for the whole picture.
|
||||
|
||||
### Tab leaves the name field in New Album, New Smart Album and the name prompts
|
||||
- In New Album, New Smart Album, Save Metadata Preset and the other dialogs with one name field, the field has the
|
||||
keyboard focus when the dialog opens, and Tab now moves on to the check box, Cancel and OK (Shift+Tab goes back).
|
||||
|
||||
Reference in New Issue
Block a user