fix(codecs): one JPEG frame-size guard for truncated and complete files

main gained a guard refusing truncated scans too short for their
frame; this branch's own header check refused those files first with a
different error. Drop it and widen main's guard instead: a complete
file is measured on its length (the scan walk may stop early on odd
marker layouts), a truncated one on its scan data as before. A frame
needs at least a one-bit DC code per 8x8 block, so 2048 pixels a byte
stays far above any real JPEG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Finn NewickandClaude Opus 5.5 committed 2026-10-23 23:57:23 +01:00
1 parent 81d2f3d651
commit 07f91c6d37
2 files changed
+20 -14

No files matched your search

+14 -13
View File
@@ -190,9 +190,10 @@ fn err(e: impl std::fmt::Display) -> CodecError {
CodecError::malformed(F, e)
}
/// Most pixels one byte of truncated scan data may account for. Even a flat image costs a few
/// bits per 8×8 block, and truncated photographs are far above this.
const MAX_TRUNCATED_PIXELS_PER_BYTE: u64 = 2048;
/// Most pixels one byte of a JPEG may account for. Every 8×8 block of a frame needs at least a
/// one-bit DC code (512 pixels a byte for a flat image under ideal Huffman tables), and real files,
/// truncated or not, are far above this.
const MAX_PIXELS_PER_BYTE: u64 = 2048;
pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError> {
let (end, scan_bytes) = data_end(bytes);
@@ -202,10 +203,6 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
let meta = scan_metadata(bytes);
if let Some((w, h, nc)) = meta.frame {
limits.check_bytes(w, h, u64::from(nc.max(1)))?;
// Huffman coding spends at least a bit per 8x8 block per component (about 1024:1 for
// a flat 4:2:0 image), so a file far smaller than that cannot hold the frame. zune-jpeg
// only needs one byte of scan data before it allocates the whole output (#8597).
Limits::check_plausible(u64::from(w) * u64::from(h) * u64::from(nc.max(1)), bytes.len(), 1 << 12)?;
}
let options = DecoderOptions::default().set_strict_mode(false).set_max_width(65535).set_max_height(65535);
let mut dec = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), options);
@@ -220,12 +217,16 @@ pub(crate) fn decode(bytes: &[u8], limits: &Limits) -> Result<Image, CodecError>
_ => (ColorSpace::RGB, ChannelLayout::Rgb),
};
limits.check(w, h, layout, SampleType::U8)?;
// The backend fills whatever the scan no longer describes, so a short scan under a large
// frame is a gigabyte of synthesized output from a few kilobytes of file. The budget is
// The backend fills whatever the scan no longer describes, and allocates the whole output
// once a scan holds a byte, so a short scan under a large frame is a gigabyte of synthesized
// output from a few kilobytes of file (#8597), truncated or not. A truncated file's budget is
// measured on the scan data itself: APP padding can inflate `bytes.len()` arbitrarily
// without describing one more pixel.
if end == DataEnd::Truncated && u64::from(w) * u64::from(h) / MAX_TRUNCATED_PIXELS_PER_BYTE > scan_bytes {
return Err(err("the truncated scan is too short for the frame it declares"));
// without describing one more pixel. A complete one is measured on the file length, since
// the walk may stop early on odd marker layouts (`DataEnd::Complete`).
let budget = if end == DataEnd::Truncated { scan_bytes } else { bytes.len() as u64 };
if u64::from(w) * u64::from(h) / MAX_PIXELS_PER_BYTE > budget {
let what = if end == DataEnd::Truncated { "the truncated scan" } else { "the file" };
return Err(err(format!("{what} is too short for the frame it declares")));
}
dec.set_options(dec.options().jpeg_set_out_colorspace(out_cs));
let mut px = dec.decode().map_err(err)?;
@@ -495,7 +496,7 @@ mod tests {
assert_eq!(data_end(&padded).0, DataEnd::Truncated);
// Well above the frame's pixels / budget, yet far above the scan data: the old
// file-length bound admitted this frame.
assert!(padded.len() > (u64::from(20_000u32) * u64::from(20_000u32) / MAX_TRUNCATED_PIXELS_PER_BYTE) as usize, "padding too small");
assert!(padded.len() > (u64::from(20_000u32) * u64::from(20_000u32) / MAX_PIXELS_PER_BYTE) as usize, "padding too small");
assert!(crate::decode_as(Format::Jpeg, &padded).is_ok());
let sof = padded.windows(2).position(|w| w == [0xFF, 0xC0] || w == [0xFF, 0xC2]).unwrap();
let mut huge = padded.clone();
+6 -1
View File
@@ -477,7 +477,12 @@ fn jpeg_header_declaring_gigabytes_needs_a_plausible_file_size() {
let sof = find(&b, &[0xFF, 0xC0]);
b[sof + 5..sof + 7].copy_from_slice(&16000u16.to_be_bytes());
b[sof + 7..sof + 9].copy_from_slice(&65000u16.to_be_bytes());
refused_as_implausible(Format::Jpeg, &b);
// The decoder's own scan-size guard refuses it (one refusal path for truncated and complete
// files alike), before the backend allocates the frame.
match decode(&b) {
Err(e) => assert!(e.to_string().contains("too short for the frame"), "{e}"),
Ok(i) => panic!("decoded a {}×{} frame from {} bytes", i.width(), i.height(), b.len()),
}
}
#[test]