# Conflicts:
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas
2026-10-14 22:31:40 -04:00
4 changed files with 61 additions and 4 deletions
+34 -1
View File
@@ -156,7 +156,15 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
return Err(RawError::Limit("chunk too large"));
}
match info.compression {
comp::NONE => unpack_chunk(src, order, packing, cw, ch, cpp, bits, float, 1),
comp::NONE => {
// uncompressed: missing rows are filled with zeros, but a chunk holding a small fraction of what its
// samples need is no picture: refused before the nominal chunk is allocated (issue #1512)
let need = (n as u64).saturating_mul(u64::from(bits)) / 8;
if (src.len() as u64).saturating_mul(64) < need {
return Err(RawError::Corrupt(format!("an uncompressed chunk of {n} samples has only {} bytes", src.len())));
}
unpack_chunk(src, order, packing, cw, ch, cpp, bits, float, 1)
}
comp::JPEG => {
let f = ljpeg::decode(src, n.saturating_mul(2).max(1 << 16))?;
if f.data.len() < n {
@@ -405,6 +413,31 @@ mod lossy_tests {
assert!(read_image(&file, &past_end, ByteOrder::Little, Packing::Msb).is_err());
}
/// A small image whose uncompressed tile claims to be enormous (16384 x 16384 nominal, a few bytes of data) is
/// refused before that tile is allocated (issue #1512); an edge tile stored at its full nominal size decodes.
#[test]
fn a_huge_nominal_uncompressed_tile_with_little_data_is_refused() {
let info = |tile: u32, bytes: u64| ImageInfo {
width: 8,
height: 8,
bits_per_sample: vec![16],
samples_per_pixel: 1,
compression: comp::NONE,
photometric: 32803,
planar: 1,
predictor: 1,
sample_format: 1,
new_subfile_type: 0,
layout: Layout::Tiles { tile_width: tile, tile_height: tile },
offsets: vec![0],
byte_counts: vec![bytes],
};
let small = vec![1u8; 2 * 16 * 16];
assert!(read_image(&small, &info(16, small.len() as u64), ByteOrder::Little, Packing::Msb).is_ok());
let data = vec![1u8; 4096];
assert!(matches!(read_image(&data, &info(16384, data.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_))));
}
/// A lossless-JPEG tile may be coded only as tall as the part inside the image, never shorter.
#[test]
fn short_lossless_jpeg_tiles_are_errors() {
+2 -2
View File
@@ -524,10 +524,10 @@ pub(crate) fn decode(strip: &[u8], width: usize, height: usize, mode: crate::Mod
}
return Ok(Vec::new());
}
let decoded: Vec<Result<Vec<u16>>> = tiles.par_iter().enumerate().map(|(i, t)| decode_tile(bytes(i)?, t.width, t.height)).collect();
// a tile that fails is reported before the mosaic is allocated (issue #1512)
let decoded: Vec<Vec<u16>> = tiles.par_iter().enumerate().map(|(i, t)| decode_tile(bytes(i)?, t.width, t.height)).collect::<Result<_>>()?;
let mut out = vec![0u16; width.checked_mul(height).ok_or_else(|| corrupt("image size"))?];
for (t, d) in tiles.iter().zip(decoded) {
let d = d?;
for (r, row) in d.chunks_exact(t.width.max(1)).enumerate() {
let start = (t.y + r).checked_mul(width).and_then(|a| a.checked_add(t.x)).ok_or_else(|| corrupt("tile placement"))?;
let dst = start.checked_add(row.len()).and_then(|end| out.get_mut(start..end)).ok_or_else(|| corrupt("tile outside image"))?;
+19 -1
View File
@@ -143,7 +143,13 @@ pub(crate) fn table(b: &[u8], order: ByteOrder) -> Result<Huffman> {
/// Decode a `w × h` Huffman-coded image.
pub(crate) fn decode_huffman(src: &[u8], h: &Huffman, w: usize, hgt: usize, bits_per: u32) -> Result<Vec<u16>> {
let mut out = vec![0u16; w * hgt];
// every sample takes at least one bit: a stream too short for the declared size is refused before its picture
// is allocated, so a few bytes can't ask for gigabytes (issue #1512)
let samples = w.checked_mul(hgt).ok_or(RawError::Limit("image too large"))?;
if (src.len() as u64).saturating_mul(8) < samples as u64 {
return Err(RawError::Corrupt(format!("PEF: {w} x {hgt} samples need more than the {} bytes of data there", src.len())));
}
let mut out = vec![0u16; samples];
let mut bits = Bits::new(src);
let mut vpred = [[0i32; 2]; 2];
let max = (1i32 << bits_per.min(16)) - 1;
@@ -358,6 +364,18 @@ mod tests {
(b, codes)
}
/// A compressed PEF declaring far more samples than its data could hold (one bit each at least) is refused
/// before its picture is allocated (issue #1512).
#[test]
fn a_stream_too_short_for_its_size_is_refused_before_allocating() {
let (tb, _) = test_table();
let h = table(&tb, ByteOrder::Big).unwrap();
let t = std::time::Instant::now();
let r = decode_huffman(&[0x55; 4096], &h, 30_000, 30_000, 12);
assert!(matches!(r, Err(RawError::Corrupt(ref m)) if m.contains("need more")), "{r:?}");
assert!(t.elapsed() < std::time::Duration::from_secs(1));
}
fn encode(px: &[u16], w: usize, codes: &[(u16, u8)]) -> Vec<u8> {
let mut bits: Vec<bool> = Vec::new();
let put = |v: u32, n: u8, bits: &mut Vec<bool>| (0..n).rev().for_each(|i| bits.push(v >> i & 1 == 1));
+6
View File
@@ -17,6 +17,12 @@
stayed behind the edit. LightCraft now says once that the sidecars are waiting, tries again every few seconds once
the files are reachable, and says when they're written. `library.retrySidecars` does it on request.
### Reliability
- A damaged or crafted raw can no longer make LightCraft set aside gigabytes of memory before finding out the file is
bad (issue #1512): a compressed PEF whose data is too short for the size it declares, an uncompressed TIFF-based
raw whose tile claims far more samples than it holds, and an ARW6 with a broken tile are refused first. All 74
files in the test corpus still decode.
### Hard-edged Heal and Clone spots
- A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity
changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.