From fbeb87537f847d22f79499500fdf5a576b537c7d Mon Sep 17 00:00:00 2001 From: VailElla <63608800+VailElla@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:48:53 +0800 Subject: [PATCH 1/5] fix(dng): honor repeated black levels in linear raws --- rawler/src/imgop/raw.rs | 136 +++++++++++++++++++++++++++++- rawler/src/rawimage.rs | 13 ++- rawler/tests/linear_blacklevel.rs | 72 ++++++++++++++++ 3 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 rawler/tests/linear_blacklevel.rs diff --git a/rawler/src/imgop/raw.rs b/rawler/src/imgop/raw.rs index b58f2b130..5b154b8ea 100644 --- a/rawler/src/imgop/raw.rs +++ b/rawler/src/imgop/raw.rs @@ -2,9 +2,9 @@ // Copyright 2021 Daniel Vogelbacher use super::xyz::Illuminant; -use crate::imgop::Rect; use crate::imgop::matrix::{multiply, normalize, pseudo_inverse}; use crate::imgop::xyz::SRGB_TO_XYZ_D65; +use crate::imgop::{Point, Rect}; use crate::pixarray::{Color2D, RgbF32}; use crate::rawimage::{BlackLevel, RawPhotometricInterpretation, WhiteLevel}; @@ -151,6 +151,140 @@ pub fn correct_blacklevel(raw: &mut [f32], blacklevel: &[f32], whitelevel: &[f32 } } +/// Correct black and white levels for an interleaved linear raw image. +/// +/// DNG black levels are stored in row-column-sample order and may repeat over +/// multiple pixels, while white levels are stored once per sample plane. The +/// repeat pattern is relative to `blacklevel_origin` (the top-left corner of +/// the DNG ActiveArea). +pub fn correct_blacklevel_linear( + raw: &mut [f32], + width: usize, + height: usize, + cpp: usize, + blacklevel: &BlackLevel, + whitelevel: &WhiteLevel, + blacklevel_origin: Point, +) -> crate::Result<()> { + if width == 0 || height == 0 || cpp == 0 { + return Err(format!("Invalid linear raw dimensions: {width}x{height} with {cpp} components per pixel").into()); + } + + let row_len = width + .checked_mul(cpp) + .ok_or_else(|| format!("Linear raw row size overflow: width {width}, cpp {cpp}"))?; + let expected_raw_len = row_len + .checked_mul(height) + .ok_or_else(|| format!("Linear raw image size overflow: {width}x{height} with {cpp} components per pixel"))?; + if raw.len() != expected_raw_len { + return Err(format!("Linear raw data length mismatch: expected {expected_raw_len}, found {}", raw.len()).into()); + } + + if blacklevel.width == 0 || blacklevel.height == 0 || blacklevel.cpp == 0 { + return Err( + format!( + "Invalid black level repeat dimensions: {}x{} with {} components per pixel", + blacklevel.width, blacklevel.height, blacklevel.cpp + ) + .into(), + ); + } + if blacklevel.cpp != 1 && blacklevel.cpp != cpp { + return Err(format!("Black level component count mismatch: expected 1 or {cpp}, found {}", blacklevel.cpp).into()); + } + + let expected_blacklevel_len = blacklevel + .width + .checked_mul(blacklevel.height) + .and_then(|count| count.checked_mul(blacklevel.cpp)) + .ok_or_else(|| { + format!( + "Black level repeat size overflow: {}x{} with {} components per pixel", + blacklevel.width, blacklevel.height, blacklevel.cpp + ) + })?; + if blacklevel.levels.len() != expected_blacklevel_len { + return Err( + format!( + "Black level data length mismatch: expected {expected_blacklevel_len}, found {}", + blacklevel.levels.len() + ) + .into(), + ); + } + + let whitelevels = match whitelevel.0.as_slice() { + [level] => vec![*level as f32; cpp], + levels if levels.len() == cpp => levels.iter().map(|level| *level as f32).collect(), + levels => { + return Err(format!("White level component count mismatch: expected 1 or {cpp}, found {}", levels.len()).into()); + } + }; + let blacklevels = blacklevel.as_vec(); + if blacklevels.iter().any(|level| !level.is_finite()) { + return Err("Black level contains a non-finite value".into()); + } + + // DNG normalization uses the maximum computed black level for each sample + // plane, even when the repeating pattern contains different local values. + let mut max_blacklevels = vec![f32::NEG_INFINITY; cpp]; + for cell in blacklevels.chunks_exact(blacklevel.cpp) { + for channel in 0..cpp { + let black_channel = if blacklevel.cpp == 1 { 0 } else { channel }; + max_blacklevels[channel] = max_blacklevels[channel].max(cell[black_channel]); + } + } + let scales: Vec = whitelevels + .iter() + .zip(&max_blacklevels) + .enumerate() + .map(|(channel, (white, black))| { + let scale = *white - *black; + if scale.is_finite() && scale > 0.0 { + Ok(scale) + } else { + Err(format!("Invalid black/white level range for channel {channel}: black {black}, white {white}")) + } + }) + .collect::>()?; + + // Most linear DNGs, including Samsung's 2x2 patterns, repeat identical + // values. Keep that common case on the vectorized per-channel path. + let first_cell = &blacklevels[..blacklevel.cpp]; + if blacklevels.chunks_exact(blacklevel.cpp).all(|cell| cell == first_cell) { + let flat_blacklevels: Vec = (0..cpp).map(|channel| first_cell[if blacklevel.cpp == 1 { 0 } else { channel }]).collect(); + correct_blacklevel(raw, &flat_blacklevels, &whitelevels); + return Ok(()); + } + + let origin_x = blacklevel_origin.x % blacklevel.width; + let origin_y = blacklevel_origin.y % blacklevel.height; + raw.par_chunks_exact_mut(row_len).enumerate().for_each(|(y, row)| { + let repeat_y = repeat_position(y, origin_y, blacklevel.height); + row.chunks_exact_mut(cpp).enumerate().for_each(|(x, pixel)| { + let repeat_x = repeat_position(x, origin_x, blacklevel.width); + let cell_offset = (repeat_y * blacklevel.width + repeat_x) * blacklevel.cpp; + for channel in 0..cpp { + let black_channel = if blacklevel.cpp == 1 { 0 } else { channel }; + let corrected = pixel[channel] - blacklevels[cell_offset + black_channel]; + pixel[channel] = if corrected.is_sign_negative() { 0.0 } else { corrected / scales[channel] }; + } + }); + }); + + Ok(()) +} + +#[inline] +fn repeat_position(position: usize, origin: usize, repeat: usize) -> usize { + let position = position % repeat; + if position >= origin { + position - origin + } else { + repeat - (origin - position) + } +} + /// Correct data by blacklevel and whitelevel on CFA (bayer) data. /// /// The output is between 0.0 .. 1.0. diff --git a/rawler/src/rawimage.rs b/rawler/src/rawimage.rs index d41435e80..133912f98 100644 --- a/rawler/src/rawimage.rs +++ b/rawler/src/rawimage.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use crate::Result; use crate::cfa::PlaneColor; -use crate::imgop::raw::{correct_blacklevel, correct_blacklevel_cfa}; +use crate::imgop::raw::{correct_blacklevel_cfa, correct_blacklevel_linear}; use crate::imgop::{convert_from_f32_scaled_u16, convert_to_f32_unscaled}; use crate::pixarray::SubPixel; use crate::{ @@ -519,7 +519,16 @@ impl RawImage { self.data = RawImageData::Float(pixels.into_owned()); } RawPhotometricInterpretation::LinearRaw => { - correct_blacklevel(pixels.to_mut(), &self.blacklevel.as_vec(), &self.whitelevel.as_vec()); + let blacklevel_origin = self.active_area.map(|area| area.p).unwrap_or_else(Point::zero); + correct_blacklevel_linear( + pixels.to_mut(), + self.width, + self.height, + self.cpp, + &self.blacklevel, + &self.whitelevel, + blacklevel_origin, + )?; self.data = RawImageData::Float(pixels.into_owned()); } } diff --git a/rawler/tests/linear_blacklevel.rs b/rawler/tests/linear_blacklevel.rs new file mode 100644 index 000000000..684ed96c8 --- /dev/null +++ b/rawler/tests/linear_blacklevel.rs @@ -0,0 +1,72 @@ +use approx::assert_abs_diff_eq; +use rawler::formats::tiff::Rational; +use rawler::imgop::Point; +use rawler::imgop::raw::correct_blacklevel_linear; +use rawler::rawimage::{BlackLevel, WhiteLevel}; + +#[test] +fn uses_row_column_sample_repeat_order() { + let blacklevel = BlackLevel::new(&[10_u32, 20, 30, 11, 21, 31, 12, 22, 32, 13, 23, 33], 2, 2, 3); + let whitelevel = WhiteLevel::new(vec![113, 223, 333]); + let mut raw = vec![60.0, 120.0, 180.0, 61.0, 121.0, 181.0, 62.0, 122.0, 182.0, 63.0, 123.0, 183.0]; + + correct_blacklevel_linear(&mut raw, 2, 2, 3, &blacklevel, &whitelevel, Point::zero()).unwrap(); + + for value in raw { + assert_abs_diff_eq!(value, 0.5, epsilon = 1e-6); + } +} + +#[test] +fn repeat_is_relative_to_active_area_origin() { + let blacklevel = BlackLevel::new(&[10_u32, 20, 30, 40], 2, 2, 1); + let whitelevel = WhiteLevel::new(vec![140]); + let mut raw = vec![90.0, 80.0, 70.0, 60.0]; + + correct_blacklevel_linear(&mut raw, 2, 2, 1, &blacklevel, &whitelevel, Point::new(1, 1)).unwrap(); + + for value in raw { + assert_abs_diff_eq!(value, 0.5, epsilon = 1e-6); + } +} + +#[test] +fn broadcasts_single_component_levels() { + let blacklevel = BlackLevel::new(&[10_u32], 1, 1, 1); + let whitelevel = WhiteLevel::new(vec![110]); + let mut raw = vec![60.0, 60.0, 60.0]; + + correct_blacklevel_linear(&mut raw, 1, 1, 3, &blacklevel, &whitelevel, Point::zero()).unwrap(); + + assert_eq!(raw, vec![0.5, 0.5, 0.5]); +} + +#[test] +fn rejects_inconsistent_metadata_without_mutating_pixels() { + let blacklevel = BlackLevel { + levels: vec![Rational::from(0_u32)], + width: 2, + height: 2, + cpp: 3, + }; + let whitelevel = WhiteLevel::new(vec![100, 100, 100]); + let mut raw = vec![50.0; 12]; + let original = raw.clone(); + + let error = correct_blacklevel_linear(&mut raw, 2, 2, 3, &blacklevel, &whitelevel, Point::zero()).unwrap_err(); + + assert!(error.to_string().contains("Black level data length mismatch")); + assert_eq!(raw, original); +} + +#[test] +fn rejects_non_positive_normalization_range() { + let blacklevel = BlackLevel::new(&[100_u32], 1, 1, 1); + let whitelevel = WhiteLevel::new(vec![100]); + let mut raw = vec![100.0]; + + let error = correct_blacklevel_linear(&mut raw, 1, 1, 1, &blacklevel, &whitelevel, Point::zero()).unwrap_err(); + + assert!(error.to_string().contains("Invalid black/white level range")); + assert_eq!(raw, vec![100.0]); +} From 18f6b7847119c3c66e05e4c19fb25acb7bf2d701 Mon Sep 17 00:00:00 2001 From: VailElla <63608800+VailElla@users.noreply.github.com> Date: Wed, 15 Jul 2026 09:48:53 +0800 Subject: [PATCH 2/5] fix(ljpeg): decode restart-marked lossless JPEG --- .../src/decompressors/ljpeg/decompressors.rs | 34 ++++++++- rawler/src/decompressors/ljpeg/mod.rs | 29 ++++++- rawler/src/pumps.rs | 64 ++++++++++++---- rawler/tests/jpeg_restart.rs | 76 +++++++++++++++++++ 4 files changed, 185 insertions(+), 18 deletions(-) create mode 100644 rawler/tests/jpeg_restart.rs diff --git a/rawler/src/decompressors/ljpeg/decompressors.rs b/rawler/src/decompressors/ljpeg/decompressors.rs index 5f9ea4cad..37729e2d5 100644 --- a/rawler/src/decompressors/ljpeg/decompressors.rs +++ b/rawler/src/decompressors/ljpeg/decompressors.rs @@ -21,11 +21,19 @@ pub fn decode_ljpeg(ljpeg: &LjpegDecompressor, out: &mut [u16], x: usize, stripw let htable = |index: usize| -> &HuffTable { &ljpeg.dhts[ljpeg.sof.components[index].dc_tbl_num] }; let mut pump = BitPumpJPEG::new(ljpeg.buffer); let base_prediction = 1 << (ljpeg.sof.precision - ljpeg.point_transform - 1); + let total_mcus = ljpeg + .sof + .width + .checked_mul(height) + .ok_or_else(|| format!("ljpeg: MCU count overflow for {}x{height}", ljpeg.sof.width))?; + let mut next_restart_marker = 0; // initialize first pixel components for c in 0..ncomp { out[x + c] = (base_prediction + htable(c).huff_decode(&mut pump)?) as u16; } + let mut decoded_mcus = 1; + let mut reset_prediction = consume_restart_if_needed(ljpeg, &mut pump, decoded_mcus, total_mcus, &mut next_restart_marker)?; let skip_x = ljpeg.sof.width - width / ncomp; @@ -33,7 +41,9 @@ pub fn decode_ljpeg(ljpeg: &LjpegDecompressor, out: &mut [u16], x: usize, stripw let startcol = if row == 0 { x + ncomp } else { x }; // skip first pixel in first row for col in (startcol..(width + x)).step_by(ncomp) { for c in 0..ncomp { - let p: i32 = if col == x { + let p: i32 = if reset_prediction { + base_prediction + } else if col == x { // At start of line predictor starts with start of previous line out[(row - 1) * stripwidth + x + c] as i32 } else { @@ -83,18 +93,40 @@ pub fn decode_ljpeg(ljpeg: &LjpegDecompressor, out: &mut [u16], x: usize, stripw let diff = htable(c).huff_decode(&mut pump)?; out[row * stripwidth + col + c] = (p + diff) as u16; } + decoded_mcus += 1; + reset_prediction = consume_restart_if_needed(ljpeg, &mut pump, decoded_mcus, total_mcus, &mut next_restart_marker)?; } for _ in 0..skip_x { + // This MCU is outside the requested output width, but it still counts + // towards the restart interval and its entropy data must be consumed. for c in 0..ncomp { // Skip extra encoded differences if the ljpeg frame is wider than the output htable(c).huff_decode(&mut pump)?; } + decoded_mcus += 1; + reset_prediction = consume_restart_if_needed(ljpeg, &mut pump, decoded_mcus, total_mcus, &mut next_restart_marker)?; } } Ok(()) } +fn consume_restart_if_needed( + ljpeg: &LjpegDecompressor, + pump: &mut BitPumpJPEG<'_>, + decoded_mcus: usize, + total_mcus: usize, + next_restart_marker: &mut u8, +) -> Result { + if ljpeg.restart_interval != 0 && decoded_mcus % ljpeg.restart_interval == 0 && decoded_mcus < total_mcus { + pump.consume_restart_marker(*next_restart_marker)?; + *next_restart_marker = (*next_restart_marker + 1) % 8; + Ok(true) + } else { + Ok(false) + } +} + fn set_yuv_420(out: &mut [u16], row: usize, col: usize, width: usize, y1: i32, y2: i32, y3: i32, y4: i32, cb: i32, cr: i32) { let pix1 = row * width + col; let pix2 = pix1 + 3; diff --git a/rawler/src/decompressors/ljpeg/mod.rs b/rawler/src/decompressors/ljpeg/mod.rs index 1c93b5568..6c4f12acc 100644 --- a/rawler/src/decompressors/ljpeg/mod.rs +++ b/rawler/src/decompressors/ljpeg/mod.rs @@ -16,6 +16,7 @@ enum Marker { EOI = 0xd9, // end of image SOS = 0xda, // start of scan DQT = 0xdb, // quantization tables + DRI = 0xdd, // restart interval Fill = 0xff, } @@ -128,6 +129,7 @@ pub struct LjpegDecompressor<'a> { sof: SOFInfo, predictor: usize, point_transform: usize, + restart_interval: usize, dhts: Vec, } @@ -148,6 +150,7 @@ impl<'a> LjpegDecompressor<'a> { let mut dht_huffval = [[0_u32; 256]; 4]; let pred; let pt; + let mut restart_interval = 0; loop { let marker = LjpegDecompressor::get_next_marker(&mut input, true)?; if marker == m(Marker::SOF3) { @@ -159,6 +162,8 @@ impl<'a> LjpegDecompressor<'a> { } else if marker == m(Marker::DHT) { // Huffman table settings LjpegDecompressor::parse_dht(&mut input, &mut dht_init, &mut dht_bits, &mut dht_huffval)?; + } else if marker == m(Marker::DRI) { + restart_interval = LjpegDecompressor::parse_dri(&mut input)?; } else if marker == m(Marker::SOS) { // Start of the actual stream, we can decode after this let (a, b) = sof.parse_sos(&mut input)?; @@ -183,13 +188,14 @@ impl<'a> LjpegDecompressor<'a> { } log::debug!( - "LJPEGDecompressor: super_h: {}, super_v: {}, pred: {}, pt: {}, prec: {}, cps: {}", + "LJPEGDecompressor: super_h: {}, super_v: {}, pred: {}, pt: {}, prec: {}, cps: {}, restart_interval: {}", sof.components[0].super_h, sof.components[0].super_v, pred, pt, sof.precision, sof.cps, + restart_interval, ); if sof.components[0].super_h == 2 && sof.components[0].super_v == 2 { @@ -204,6 +210,7 @@ impl<'a> LjpegDecompressor<'a> { sof, predictor: pred, point_transform: pt, + restart_interval, dhts, }) } @@ -269,11 +276,20 @@ impl<'a> LjpegDecompressor<'a> { Ok(()) } + fn parse_dri(input: &mut ByteStream) -> Result { + let length = input.get_u16(); + if length != 4 { + return Err(format!("ljpeg: invalid DRI length {length}")); + } + Ok(input.get_u16() as usize) + } + /// Handle special SONY YUV 4:2:0 encoding in ILCE-7RM5 pub fn decode_sony(&self, out: &mut [u16], x: usize, stripwidth: usize, width: usize, height: usize, dummy: bool) -> Result<(), String> { if dummy { return Ok(()); } + self.validate_restart_support()?; log::debug!("LJPEG decode with special Sony mode"); if self.sof.components[0].super_h == 2 && self.sof.components[0].super_v == 2 { decode_sony_ljpeg_420(self, out, width, height) @@ -297,6 +313,7 @@ impl<'a> LjpegDecompressor<'a> { if dummy { return Ok(()); } + self.validate_restart_support()?; if self.sof.components[0].super_h == 2 && self.sof.components[0].super_v == 2 { decode_ljpeg_420(self, out, width, height) @@ -349,6 +366,16 @@ impl<'a> LjpegDecompressor<'a> { ) } + fn validate_restart_support(&self) -> Result<(), String> { + if self.restart_interval != 0 && (self.sof.components[0].super_h != 1 || self.sof.components[0].super_v != 1 || !(1..=7).contains(&self.predictor)) { + return Err(format!( + "ljpeg: restart markers are not supported for sampling {}x{} with predictor {}", + self.sof.components[0].super_h, self.sof.components[0].super_v, self.predictor + )); + } + Ok(()) + } + pub fn width(&self) -> usize { self.sof.width * self.sof.cps } diff --git a/rawler/src/pumps.rs b/rawler/src/pumps.rs index 07bb02f8b..072ff00fd 100644 --- a/rawler/src/pumps.rs +++ b/rawler/src/pumps.rs @@ -145,6 +145,38 @@ impl<'a> BitPumpJPEG<'a> { finished: false, } } + + /// Discard entropy padding and resume after an expected JPEG restart marker. + pub fn consume_restart_marker(&mut self, expected: u8) -> Result<(), String> { + if expected > 7 { + return Err(format!("Invalid JPEG restart marker index: {expected}")); + } + if self.pos >= self.buffer.len() || self.buffer[self.pos] != 0xff { + return Err(format!("Expected JPEG restart marker RST{expected} at byte {}", self.pos)); + } + + // JPEG permits extra 0xff fill bytes before a marker. + while self.pos < self.buffer.len() && self.buffer[self.pos] == 0xff { + self.pos += 1; + } + if self.pos >= self.buffer.len() { + return Err(format!("Truncated JPEG restart marker RST{expected}")); + } + + let marker = self.buffer[self.pos]; + let expected_marker = 0xd0 + expected; + if marker != expected_marker { + return Err(format!( + "Unexpected JPEG marker 0x{marker:02x}, expected RST{expected} (0x{expected_marker:02x})" + )); + } + + self.pos += 1; + self.bits = 0; + self.nbits = 0; + self.finished = false; + Ok(()) + } } pub trait BitPump { @@ -277,22 +309,22 @@ impl<'a> BitPump for BitPumpJPEG<'a> { // Read 32 bits the hard way let mut read_bytes = 0; while read_bytes < 4 && !self.finished { - let byte = { - if self.pos >= self.buffer.len() { - self.finished = true; - 0 - } else { - let nextbyte = self.buffer[self.pos]; - if nextbyte != 0xff { - nextbyte - } else if self.buffer[self.pos + 1] == 0x00 { - self.pos += 1; // Skip the extra byte used to mark 255 - nextbyte - } else { - self.finished = true; - 0 - } - } + if self.pos >= self.buffer.len() { + self.finished = true; + break; + } + + let nextbyte = self.buffer[self.pos]; + let byte = if nextbyte != 0xff { + nextbyte + } else if self.pos + 1 < self.buffer.len() && self.buffer[self.pos + 1] == 0x00 { + self.pos += 1; // Skip the extra byte used to mark 255 + nextbyte + } else { + // Leave the marker untouched so a restart-aware decoder can + // validate and consume it after the current MCU. + self.finished = true; + break; }; self.bits = (self.bits << 8) | (byte as u64); self.pos += 1; diff --git a/rawler/tests/jpeg_restart.rs b/rawler/tests/jpeg_restart.rs new file mode 100644 index 000000000..2c221a369 --- /dev/null +++ b/rawler/tests/jpeg_restart.rs @@ -0,0 +1,76 @@ +use rawler::decompressors::ljpeg::LjpegDecompressor; +use rawler::pumps::{BitPump, BitPumpJPEG}; + +#[test] +fn lossless_jpeg_resets_prediction_after_restart() { + // Four 12-bit samples, one component, predictor 1, restart interval 2. + // The first interval decodes to [2049, 2049]. After RST0, a zero + // difference must use the initial predictor again and produce 2048. + let jpeg = [ + 0xff, 0xd8, // SOI + 0xff, 0xc3, 0x00, 0x0b, 0x0c, 0x00, 0x01, 0x00, 0x04, 0x01, 0x01, 0x11, 0x00, // SOF3 + 0xff, 0xc4, 0x00, 0x15, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // DHT: category 0 = code 0, category 1 = code 1 + 0xff, 0xdd, 0x00, 0x04, 0x00, 0x02, // DRI: two MCUs + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, // SOS + 0xdf, // +1, then 0, followed by entropy padding + 0xff, 0xd0, // RST0 + 0x3f, // 0, then 0, followed by entropy padding + 0xff, 0xd9, // EOI + ]; + + let decompressor = LjpegDecompressor::new(&jpeg).unwrap(); + let mut output = [0_u16; 4]; + decompressor.decode(&mut output, 0, 4, 4, 1, false).unwrap(); + + assert_eq!(output, [2049, 2049, 2048, 2048]); +} + +#[test] +fn rejects_restart_markers_for_unimplemented_sampling_modes() { + let jpeg = [ + 0xff, 0xd8, // SOI + 0xff, 0xc3, 0x00, 0x0b, 0x0c, 0x00, 0x01, 0x00, 0x04, 0x01, 0x01, 0x21, 0x00, // SOF3 with 2x1 sampling + 0xff, 0xc4, 0x00, 0x15, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // DHT + 0xff, 0xdd, 0x00, 0x04, 0x00, 0x02, // DRI + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, // SOS + 0xdf, 0xff, 0xd0, 0x3f, 0xff, 0xd9, + ]; + + let decompressor = LjpegDecompressor::new(&jpeg).unwrap(); + let mut output = [0_u16; 8]; + let error = decompressor.decode(&mut output, 0, 8, 8, 1, false).unwrap_err(); + + assert!(error.contains("restart markers are not supported for sampling 2x1")); +} + +#[test] +fn jpeg_bit_pump_resumes_after_restart_markers() { + let data = [0x12, 0xff, 0xd0, 0x34, 0xff, 0xff, 0xd1, 0x56]; + let mut pump = BitPumpJPEG::new(&data); + + assert_eq!(pump.get_bits(8), 0x12); + pump.consume_restart_marker(0).unwrap(); + assert_eq!(pump.get_bits(8), 0x34); + pump.consume_restart_marker(1).unwrap(); + assert_eq!(pump.get_bits(8), 0x56); +} + +#[test] +fn jpeg_bit_pump_preserves_stuffed_ff_before_restart() { + let data = [0xff, 0x00, 0xaa, 0xff, 0xd0, 0xbb]; + let mut pump = BitPumpJPEG::new(&data); + + assert_eq!(pump.get_bits(16), 0xffaa); + pump.consume_restart_marker(0).unwrap(); + assert_eq!(pump.get_bits(8), 0xbb); +} + +#[test] +fn jpeg_bit_pump_rejects_wrong_restart_sequence() { + let data = [0x12, 0xff, 0xd1, 0x34]; + let mut pump = BitPumpJPEG::new(&data); + + assert_eq!(pump.get_bits(8), 0x12); + let error = pump.consume_restart_marker(0).unwrap_err(); + assert!(error.contains("expected RST0")); +} From 8b6a0a2a6325e78a2fd274dea4484c22061c8a56 Mon Sep 17 00:00:00 2001 From: VailElla <63608800+VailElla@users.noreply.github.com> Date: Wed, 15 Jul 2026 10:15:32 +0800 Subject: [PATCH 3/5] fix(dng): reject malformed decoding metadata --- rawler/src/decoders/dng.rs | 35 +++++++++++++++++++++++++++ rawler/src/decompressors/ljpeg/mod.rs | 3 +++ rawler/tests/jpeg_restart.rs | 11 +++++++++ 3 files changed, 49 insertions(+) diff --git a/rawler/src/decoders/dng.rs b/rawler/src/decoders/dng.rs index fed2f4b08..5b5209410 100644 --- a/rawler/src/decoders/dng.rs +++ b/rawler/src/decoders/dng.rs @@ -18,6 +18,24 @@ pub struct DngDecoder<'a> { tiff: GenericTiffReader, } +fn validate_blacklevel_count(level_count: usize, repeat: (usize, usize), cpp: usize) -> Result<()> { + let expected_levels = repeat + .0 + .checked_mul(repeat.1) + .and_then(|count| count.checked_mul(cpp)) + .ok_or_else(|| format!("BlackLevel repeat size overflow: {}x{} with {cpp} samples per pixel", repeat.0, repeat.1))?; + if level_count != expected_levels { + return Err( + format!( + "BlackLevel count mismatch: expected {expected_levels} values for repeat {}x{} with {cpp} samples per pixel, found {level_count}", + repeat.0, repeat.1 + ) + .into(), + ); + } + Ok(()) +} + impl<'a> DngDecoder<'a> { pub fn new(_file: &RawSource, tiff: GenericTiffReader, rawloader: &'a RawLoader) -> Result> { Ok(DngDecoder { tiff, rawloader }) @@ -327,6 +345,7 @@ impl<'a> DngDecoder<'a> { log::warn!("File has BlackLevelRepeatDim tag but with invalid length: {}", value.len()); } } + validate_blacklevel_count(levels.len(), repeat, cpp)?; Ok(Some(BlackLevel::new(&levels, repeat.1, repeat.0, cpp))) } else { Ok(None) @@ -434,3 +453,19 @@ impl<'a> DngDecoder<'a> { Ok(result) } } + +#[cfg(test)] +mod tests { + use super::validate_blacklevel_count; + + #[test] + fn rejects_blacklevel_count_mismatch_before_construction() { + let error = validate_blacklevel_count(1, (2, 2), 3).unwrap_err(); + assert!(error.to_string().contains("expected 12 values")); + } + + #[test] + fn accepts_row_column_sample_blacklevel_count() { + validate_blacklevel_count(12, (2, 2), 3).unwrap(); + } +} diff --git a/rawler/src/decompressors/ljpeg/mod.rs b/rawler/src/decompressors/ljpeg/mod.rs index 6c4f12acc..f840cdbb6 100644 --- a/rawler/src/decompressors/ljpeg/mod.rs +++ b/rawler/src/decompressors/ljpeg/mod.rs @@ -277,6 +277,9 @@ impl<'a> LjpegDecompressor<'a> { } fn parse_dri(input: &mut ByteStream) -> Result { + if input.remaining_bytes() < 4 { + return Err(format!("ljpeg: truncated DRI segment ({} bytes remain)", input.remaining_bytes())); + } let length = input.get_u16(); if length != 4 { return Err(format!("ljpeg: invalid DRI length {length}")); diff --git a/rawler/tests/jpeg_restart.rs b/rawler/tests/jpeg_restart.rs index 2c221a369..d4a232ce9 100644 --- a/rawler/tests/jpeg_restart.rs +++ b/rawler/tests/jpeg_restart.rs @@ -74,3 +74,14 @@ fn jpeg_bit_pump_rejects_wrong_restart_sequence() { let error = pump.consume_restart_marker(0).unwrap_err(); assert!(error.contains("expected RST0")); } + +#[test] +fn rejects_truncated_restart_interval_segment_without_panicking() { + let jpeg = [ + 0xff, 0xd8, // SOI + 0xff, 0xdd, 0x00, 0x04, 0x00, // DRI with one interval byte missing + ]; + + let error = LjpegDecompressor::new(&jpeg).unwrap_err(); + assert!(error.contains("truncated DRI segment")); +} From 6ac6ec2342a5b98970a9d700bcaa8134ae62e060 Mon Sep 17 00:00:00 2001 From: VailElla <63608800+VailElla@users.noreply.github.com> Date: Thu, 16 Jul 2026 05:52:36 +0800 Subject: [PATCH 4/5] fix(ljpeg): reject truncated restart segments --- .../src/decompressors/ljpeg/decompressors.rs | 6 +- rawler/src/decompressors/ljpeg/mod.rs | 15 ++- rawler/src/pumps.rs | 42 ++++++- rawler/tests/jpeg_restart.rs | 106 +++++++++++++++++- 4 files changed, 163 insertions(+), 6 deletions(-) diff --git a/rawler/src/decompressors/ljpeg/decompressors.rs b/rawler/src/decompressors/ljpeg/decompressors.rs index 37729e2d5..1a557198d 100644 --- a/rawler/src/decompressors/ljpeg/decompressors.rs +++ b/rawler/src/decompressors/ljpeg/decompressors.rs @@ -108,7 +108,11 @@ pub fn decode_ljpeg(ljpeg: &LjpegDecompressor, out: &mut [u16], x: usize, stripw } } - Ok(()) + if ljpeg.restart_interval != 0 && height == ljpeg.sof.height { + pump.validate_end_of_scan() + } else { + Ok(()) + } } fn consume_restart_if_needed( diff --git a/rawler/src/decompressors/ljpeg/mod.rs b/rawler/src/decompressors/ljpeg/mod.rs index f840cdbb6..2b31c15b3 100644 --- a/rawler/src/decompressors/ljpeg/mod.rs +++ b/rawler/src/decompressors/ljpeg/mod.rs @@ -370,12 +370,21 @@ impl<'a> LjpegDecompressor<'a> { } fn validate_restart_support(&self) -> Result<(), String> { - if self.restart_interval != 0 && (self.sof.components[0].super_h != 1 || self.sof.components[0].super_v != 1 || !(1..=7).contains(&self.predictor)) { + if self.restart_interval == 0 { + return Ok(()); + } + + if let Some(component) = self.sof.components.iter().find(|component| component.super_h != 1 || component.super_v != 1) { return Err(format!( - "ljpeg: restart markers are not supported for sampling {}x{} with predictor {}", - self.sof.components[0].super_h, self.sof.components[0].super_v, self.predictor + "ljpeg: restart markers are not supported for component {} sampling {}x{}", + component.id, component.super_h, component.super_v )); } + + if !(1..=7).contains(&self.predictor) { + return Err(format!("ljpeg: restart markers are not supported with predictor {}", self.predictor)); + } + Ok(()) } diff --git a/rawler/src/pumps.rs b/rawler/src/pumps.rs index 072ff00fd..6da817f6f 100644 --- a/rawler/src/pumps.rs +++ b/rawler/src/pumps.rs @@ -132,6 +132,8 @@ pub struct BitPumpJPEG<'a> { pos: usize, bits: u64, nbits: u32, + zero_fill_bits: u32, + consumed_zero_fill: bool, finished: bool, } @@ -142,10 +144,36 @@ impl<'a> BitPumpJPEG<'a> { pos: 0, bits: 0, nbits: 0, + zero_fill_bits: 0, + consumed_zero_fill: false, finished: false, } } + fn validate_entropy_padding(&self, context: &str) -> Result<(), String> { + if self.consumed_zero_fill { + return Err(format!("Truncated JPEG entropy data {context}")); + } + + let padding_bits = self.nbits.saturating_sub(self.zero_fill_bits); + if padding_bits > 7 { + return Err(format!("Unexpected trailing JPEG entropy data {context}")); + } + if padding_bits != 0 { + let padding = (self.bits >> self.zero_fill_bits) & ((1_u64 << padding_bits) - 1); + if padding != (1_u64 << padding_bits) - 1 { + return Err(format!("Invalid JPEG entropy padding {context}")); + } + } + + Ok(()) + } + + /// Validate the padding after the final decoded MCU in the scan. + pub fn validate_end_of_scan(&self) -> Result<(), String> { + self.validate_entropy_padding("at end of scan") + } + /// Discard entropy padding and resume after an expected JPEG restart marker. pub fn consume_restart_marker(&mut self, expected: u8) -> Result<(), String> { if expected > 7 { @@ -154,6 +182,7 @@ impl<'a> BitPumpJPEG<'a> { if self.pos >= self.buffer.len() || self.buffer[self.pos] != 0xff { return Err(format!("Expected JPEG restart marker RST{expected} at byte {}", self.pos)); } + self.validate_entropy_padding(&format!("before RST{expected}"))?; // JPEG permits extra 0xff fill bytes before a marker. while self.pos < self.buffer.len() && self.buffer[self.pos] == 0xff { @@ -174,6 +203,8 @@ impl<'a> BitPumpJPEG<'a> { self.pos += 1; self.bits = 0; self.nbits = 0; + self.zero_fill_bits = 0; + self.consumed_zero_fill = false; self.finished = false; Ok(()) } @@ -334,9 +365,12 @@ impl<'a> BitPump for BitPumpJPEG<'a> { } } if num > self.nbits && self.finished { - // Stuff with zeroes to not fail to read + // Huffman lookup may read ahead of the marker. Preserve the fast, + // infallible BitPump interface, but remember these synthetic bits so the + // restart-aware decoder can reject a segment that actually consumes them. self.bits <<= 32; self.nbits += 32; + self.zero_fill_bits += 32; } (self.bits >> (self.nbits - num)) as u32 @@ -345,6 +379,12 @@ impl<'a> BitPump for BitPumpJPEG<'a> { #[inline(always)] fn consume_bits(&mut self, num: u32) { debug_assert!(num <= self.nbits); + let real_bits = self.nbits.saturating_sub(self.zero_fill_bits); + let consumed_zero_fill = num.saturating_sub(real_bits); + if consumed_zero_fill != 0 { + self.consumed_zero_fill = true; + self.zero_fill_bits -= consumed_zero_fill; + } self.nbits -= num; self.bits &= (1 << self.nbits) - 1; } diff --git a/rawler/tests/jpeg_restart.rs b/rawler/tests/jpeg_restart.rs index d4a232ce9..be29d79b3 100644 --- a/rawler/tests/jpeg_restart.rs +++ b/rawler/tests/jpeg_restart.rs @@ -40,7 +40,27 @@ fn rejects_restart_markers_for_unimplemented_sampling_modes() { let mut output = [0_u16; 8]; let error = decompressor.decode(&mut output, 0, 8, 8, 1, false).unwrap_err(); - assert!(error.contains("restart markers are not supported for sampling 2x1")); + assert!(error.contains("restart markers are not supported for component 1 sampling 2x1")); +} + +#[test] +fn rejects_restart_markers_when_a_later_component_is_subsampled() { + let jpeg = [ + 0xff, 0xd8, // SOI + 0xff, 0xc3, 0x00, 0x0e, 0x0c, 0x00, 0x01, 0x00, 0x02, 0x02, // SOF3 header + 0x01, 0x11, 0x00, // component 1: 1x1 sampling + 0x02, 0x21, 0x00, // component 2: 2x1 sampling + 0xff, 0xc4, 0x00, 0x15, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // DHT + 0xff, 0xdd, 0x00, 0x04, 0x00, 0x01, // DRI: one MCU + 0xff, 0xda, 0x00, 0x0a, 0x02, 0x01, 0x00, 0x02, 0x00, 0x01, 0x00, 0x00, // SOS + 0xff, 0xd9, // EOI + ]; + + let decompressor = LjpegDecompressor::new(&jpeg).unwrap(); + let mut output = [0_u16; 4]; + let error = decompressor.decode(&mut output, 0, 4, 4, 1, false).unwrap_err(); + + assert!(error.contains("restart markers are not supported for component 2 sampling 2x1")); } #[test] @@ -85,3 +105,87 @@ fn rejects_truncated_restart_interval_segment_without_panicking() { let error = LjpegDecompressor::new(&jpeg).unwrap_err(); assert!(error.contains("truncated DRI segment")); } + +#[test] +fn rejects_truncated_entropy_before_restart_marker() { + // The first entropy byte is missing entirely. The decoder must not use + // synthetic zeroes to produce two plausible samples before RST0. + let jpeg = [ + 0xff, 0xd8, // SOI + 0xff, 0xc3, 0x00, 0x0b, 0x0c, 0x00, 0x01, 0x00, 0x04, 0x01, 0x01, 0x11, 0x00, // SOF3 + 0xff, 0xc4, 0x00, 0x15, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // DHT + 0xff, 0xdd, 0x00, 0x04, 0x00, 0x02, // DRI: two MCUs + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, // SOS + 0xff, 0xd0, // RST0 without preceding entropy data + 0x3f, // second interval + 0xff, 0xd9, // EOI + ]; + + let decompressor = LjpegDecompressor::new(&jpeg).unwrap(); + let mut output = [0_u16; 4]; + let error = decompressor.decode(&mut output, 0, 4, 4, 1, false).unwrap_err(); + + assert!(error.contains("Truncated JPEG entropy data before RST0")); +} + +#[test] +fn rejects_invalid_entropy_padding_before_restart_marker() { + let jpeg = [ + 0xff, 0xd8, // SOI + 0xff, 0xc3, 0x00, 0x0b, 0x0c, 0x00, 0x01, 0x00, 0x04, 0x01, 0x01, 0x11, 0x00, // SOF3 + 0xff, 0xc4, 0x00, 0x15, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // DHT + 0xff, 0xdd, 0x00, 0x04, 0x00, 0x02, // DRI: two MCUs + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, // SOS + 0xd7, // +1, then 0, followed by a zero among the padding bits + 0xff, 0xd0, // RST0 + 0x3f, // second interval + 0xff, 0xd9, // EOI + ]; + + let decompressor = LjpegDecompressor::new(&jpeg).unwrap(); + let mut output = [0_u16; 4]; + let error = decompressor.decode(&mut output, 0, 4, 4, 1, false).unwrap_err(); + + assert!(error.contains("Invalid JPEG entropy padding before RST0")); +} + +#[test] +fn rejects_unexpected_trailing_entropy_before_restart_marker() { + let jpeg = [ + 0xff, 0xd8, // SOI + 0xff, 0xc3, 0x00, 0x0b, 0x0c, 0x00, 0x01, 0x00, 0x04, 0x01, 0x01, 0x11, 0x00, // SOF3 + 0xff, 0xc4, 0x00, 0x15, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // DHT + 0xff, 0xdd, 0x00, 0x04, 0x00, 0x02, // DRI: two MCUs + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, // SOS + 0xdf, 0xaa, // valid first interval followed by an extra entropy byte + 0xff, 0xd0, // RST0 + 0x3f, // second interval + 0xff, 0xd9, // EOI + ]; + + let decompressor = LjpegDecompressor::new(&jpeg).unwrap(); + let mut output = [0_u16; 4]; + let error = decompressor.decode(&mut output, 0, 4, 4, 1, false).unwrap_err(); + + assert!(error.contains("Unexpected trailing JPEG entropy data before RST0")); +} + +#[test] +fn rejects_truncated_final_entropy_segment() { + let jpeg = [ + 0xff, 0xd8, // SOI + 0xff, 0xc3, 0x00, 0x0b, 0x0c, 0x00, 0x01, 0x00, 0x04, 0x01, 0x01, 0x11, 0x00, // SOF3 + 0xff, 0xc4, 0x00, 0x15, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // DHT + 0xff, 0xdd, 0x00, 0x04, 0x00, 0x02, // DRI: two MCUs + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, // SOS + 0xdf, // first interval + 0xff, 0xd0, // RST0 + 0xff, 0xd9, // EOI without second-interval entropy data + ]; + + let decompressor = LjpegDecompressor::new(&jpeg).unwrap(); + let mut output = [0_u16; 4]; + let error = decompressor.decode(&mut output, 0, 4, 4, 1, false).unwrap_err(); + + assert!(error.contains("Truncated JPEG entropy data at end of scan")); +} From ee86c32e3d85ff6057fdc8b3c3eed38923712d91 Mon Sep 17 00:00:00 2001 From: VailElla <63608800+VailElla@users.noreply.github.com> Date: Thu, 16 Jul 2026 07:10:11 +0800 Subject: [PATCH 5/5] fix(ljpeg): reject undeclared restart markers --- .../src/decompressors/ljpeg/decompressors.rs | 6 +-- rawler/src/pumps.rs | 24 ++++++++++- rawler/tests/jpeg_restart.rs | 42 +++++++++++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/rawler/src/decompressors/ljpeg/decompressors.rs b/rawler/src/decompressors/ljpeg/decompressors.rs index 1a557198d..4f04c6088 100644 --- a/rawler/src/decompressors/ljpeg/decompressors.rs +++ b/rawler/src/decompressors/ljpeg/decompressors.rs @@ -108,11 +108,7 @@ pub fn decode_ljpeg(ljpeg: &LjpegDecompressor, out: &mut [u16], x: usize, stripw } } - if ljpeg.restart_interval != 0 && height == ljpeg.sof.height { - pump.validate_end_of_scan() - } else { - Ok(()) - } + if height == ljpeg.sof.height { pump.validate_end_of_scan() } else { Ok(()) } } fn consume_restart_if_needed( diff --git a/rawler/src/pumps.rs b/rawler/src/pumps.rs index 6da817f6f..0fc482b7c 100644 --- a/rawler/src/pumps.rs +++ b/rawler/src/pumps.rs @@ -171,7 +171,29 @@ impl<'a> BitPumpJPEG<'a> { /// Validate the padding after the final decoded MCU in the scan. pub fn validate_end_of_scan(&self) -> Result<(), String> { - self.validate_entropy_padding("at end of scan") + self.validate_entropy_padding("at end of scan")?; + + if self.pos == self.buffer.len() { + return Ok(()); + } + if self.buffer[self.pos] != 0xff { + return Err(format!("Unexpected trailing JPEG entropy data at byte {}", self.pos)); + } + + let mut marker_pos = self.pos; + while marker_pos < self.buffer.len() && self.buffer[marker_pos] == 0xff { + marker_pos += 1; + } + if marker_pos == self.buffer.len() { + return Err("Truncated JPEG marker at end of scan".to_string()); + } + + let marker = self.buffer[marker_pos]; + if marker != 0xd9 { + return Err(format!("Unexpected JPEG marker 0x{marker:02x} at end of scan")); + } + + Ok(()) } /// Discard entropy padding and resume after an expected JPEG restart marker. diff --git a/rawler/tests/jpeg_restart.rs b/rawler/tests/jpeg_restart.rs index be29d79b3..1d18b3492 100644 --- a/rawler/tests/jpeg_restart.rs +++ b/rawler/tests/jpeg_restart.rs @@ -1,6 +1,26 @@ use rawler::decompressors::ljpeg::LjpegDecompressor; use rawler::pumps::{BitPump, BitPumpJPEG}; +fn restart_marked_jpeg(dri: Option) -> Vec { + let mut jpeg = vec![ + 0xff, 0xd8, // SOI + 0xff, 0xc3, 0x00, 0x0b, 0x0c, 0x00, 0x01, 0x00, 0x04, 0x01, 0x01, 0x11, 0x00, // SOF3 + 0xff, 0xc4, 0x00, 0x15, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, // DHT: category 0 = code 0, category 1 = code 1 + ]; + if let Some(interval) = dri { + jpeg.extend_from_slice(&[0xff, 0xdd, 0x00, 0x04]); + jpeg.extend_from_slice(&interval.to_be_bytes()); + } + jpeg.extend_from_slice(&[ + 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x01, 0x00, 0x00, // SOS + 0xdf, // +1, then 0, followed by entropy padding + 0xff, 0xd0, // RST0 + 0x3f, // 0, then 0, followed by entropy padding + 0xff, 0xd9, // EOI + ]); + jpeg +} + #[test] fn lossless_jpeg_resets_prediction_after_restart() { // Four 12-bit samples, one component, predictor 1, restart interval 2. @@ -189,3 +209,25 @@ fn rejects_truncated_final_entropy_segment() { assert!(error.contains("Truncated JPEG entropy data at end of scan")); } + +#[test] +fn rejects_restart_marker_without_dri() { + let jpeg = restart_marked_jpeg(None); + let decompressor = LjpegDecompressor::new(&jpeg).unwrap(); + let mut output = [0_u16; 4]; + + let error = decompressor.decode(&mut output, 0, 4, 4, 1, false).unwrap_err(); + + assert!(error.contains("Unexpected JPEG marker 0xd0 at end of scan")); +} + +#[test] +fn rejects_restart_marker_with_zero_dri() { + let jpeg = restart_marked_jpeg(Some(0)); + let decompressor = LjpegDecompressor::new(&jpeg).unwrap(); + let mut output = [0_u16; 4]; + + let error = decompressor.decode(&mut output, 0, 4, 4, 1, false).unwrap_err(); + + assert!(error.contains("Unexpected JPEG marker 0xd0 at end of scan")); +}