diff --git a/Cargo.toml b/Cargo.toml index 0df78ad..a57f1de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,8 +23,9 @@ quick-error = "2.0.1" # Rename to avoid confusion with the feature name. fax34 = { package = "fax", version = "0.2.6", optional = true } flate2 = { version = "1.0.20", optional = true } -weezl = { version = "0.1.10", optional = true } +weezl = { version = "0.2.1", optional = true } zstd = { version = "0.13", optional = true } +zrip-decode = { version = "0.8.2", features = ["paranoid"], optional = true } zune-jpeg = { version = "0.5.1", optional = true } image-webp = {version = "0.2.4", optional = true } @@ -45,6 +46,9 @@ jpeg = ["dep:zune-jpeg"] lzw = ["dep:weezl"] webp = ["dep:image-webp"] zstd = ["dep:zstd"] +# Fully safe Rust but somewhat slower Zstd decoding. +# When both zstd and zstd-safe-rust are enabled, zstd takes precedence. +zstd-safe-rust = ["dep:zrip-decode"] [[bench]] name = "lzw" diff --git a/benches/lzw.rs b/benches/lzw.rs index 0c9c73d..0cd5741 100644 --- a/benches/lzw.rs +++ b/benches/lzw.rs @@ -9,9 +9,9 @@ use tiff::decoder::Decoder; fn read_image(image: &[u8]) { let image = std::io::Cursor::new(image); let decoder = Decoder::open(black_box(image)); - let mut reader = decoder.unwrap(); while reader.more_images() { + reader.next_directory().unwrap(); reader.read_image().unwrap(); } } diff --git a/examples/decode.rs b/examples/decode.rs index 9594e40..6b52533 100644 --- a/examples/decode.rs +++ b/examples/decode.rs @@ -2,13 +2,15 @@ use tiff::decoder::{BufferLayoutPreference, Decoder, DecodingSampleBuffer, Decod fn main() -> Result<(), Box> { let Some(image) = std::env::args_os().nth(1) else { - eprintln!("Usage: decode FILE"); + eprintln!("Usage: decode FILE [--lenient]"); return Ok(()); }; + let lenient = std::env::args().nth(2).as_deref() == Some("--lenient"); + let file = std::fs::File::open(image)?; let io = std::io::BufReader::new(file); - let mut reader = Decoder::open(io)?; + let mut reader = Decoder::open(io)?.with_lenient(lenient); let mut data = DecodingSampleBuffer::I8(vec![]); diff --git a/src/decoder/ifd.rs b/src/decoder/ifd.rs index e062ab4..f2bd19d 100644 --- a/src/decoder/ifd.rs +++ b/src/decoder/ifd.rs @@ -363,7 +363,7 @@ impl ::std::fmt::Debug for Entry { fn fmt(&self, fmt: &mut ::std::fmt::Formatter) -> Result<(), ::std::fmt::Error> { fmt.write_str(&format!( "Entry {{ type_: {:?}, count: {:?}, offset: {:?} }}", - self.type_, self.count, &self.offset + self.type_, self.count, self.offset )) } } diff --git a/src/decoder/image.rs b/src/decoder/image.rs index 5aa98c7..0d7d3c4 100644 --- a/src/decoder/image.rs +++ b/src/decoder/image.rs @@ -97,6 +97,7 @@ pub(crate) struct Image { /// run on samples directly and thus skip the outer byte order entirely, except potentially /// they have interior byte order treatment. pub decompression_to_host_endian: bool, + pub lenient: bool, } /// Describes how to read a tile-aligned portion of the image. @@ -158,6 +159,7 @@ impl Image { pub fn from_reader( decoder: &mut ValueReader, ifd: &Directory, + lenient: bool, ) -> TiffResult { let mut tag_reader = TagReader { decoder, ifd }; @@ -500,6 +502,7 @@ impl Image { compression_method, CompressionMethod::SgiLog | CompressionMethod::SgiLog24 ), + lenient, }) } @@ -515,10 +518,13 @@ impl Image { } pub(crate) fn colortype(&self) -> TiffResult { - let is_alpha_extra_samples = matches!( - self.extra_samples.as_slice(), - [ExtraSamples::AssociatedAlpha, ..] | [ExtraSamples::UnassociatedAlpha, ..] - ); + let is_alpha_extra_samples = match self.extra_samples.as_slice().first() { + Some(ExtraSamples::AssociatedAlpha) | Some(ExtraSamples::UnassociatedAlpha) => true, + // In lenient mode, keep unspecified alpha interpretation logic compatible with libtiff + // See Issue #341 + Some(ExtraSamples::Unspecified) if self.lenient && self.samples > 3 => true, + _ => false, + }; match self.photometric_interpretation { PhotometricInterpretation::RGB => match self.photometric_samples { @@ -669,6 +675,12 @@ impl Image { PhotometricInterpretation::TransparencyMask => { Ok(ColorType::Gray(self.bits_per_sample)) } + PhotometricInterpretation::Unknown(_) => Err(TiffError::UnsupportedError( + TiffUnsupportedError::InterpretationWithBits( + self.photometric_interpretation, + vec![self.bits_per_sample; self.samples as usize], + ), + )), } } @@ -715,6 +727,8 @@ impl Image { )), #[cfg(feature = "zstd")] CompressionMethod::ZSTD => Box::new(zstd::Decoder::new(reader)?), + #[cfg(all(not(feature = "zstd"), feature = "zstd-safe-rust"))] + CompressionMethod::ZSTD => Box::new(zrip_decode::streaming::FrameDecoder::new(reader)), CompressionMethod::PackBits => Box::new(PackBitsReader::new(reader, compressed_length)), #[cfg(feature = "deflate")] CompressionMethod::Deflate | CompressionMethod::OldDeflate => { @@ -1195,12 +1209,20 @@ impl Image { super::fix_endianness_and_predict( out_row, color_type.bit_depth(), - samples, + data_samples, ByteOrder::native(), predictor, scratch, ); + // An n-bit predictor delta is stored modulo 2^n, but the + // reconstruction above accumulated it in the promoted u16/u32 + // storage width. Reduce back modulo the declared depth so both + // the inverted and non-inverted paths see exact, in-range + // values (2^n divides the storage modulus, so one mask after + // the row is exact). + super::mask_samples_to_bit_depth(out_row, tiff_bps, out_bps, self.sample_format); + super::post_process_row( out_row, photometric_interpretation, @@ -1224,6 +1246,16 @@ impl Image { ); } + // No-op in this branch: promoted layouts (declared depth < storage + // width) are handled exclusively by the unpacking branch above. + // Called in every integer branch so they cannot diverge. + super::mask_samples_to_bit_depth( + tile, + layout.tiff_bits_per_sample, + layout.output_bits_per_sample, + self.sample_format, + ); + super::post_process_row( tile, photometric_interpretation, @@ -1253,6 +1285,8 @@ impl Image { } _ => unreachable!(), } + // No masking here: the floating point predictor requires + // IEEEFP samples, which are never depth-promoted. super::post_process_row( row, photometric_interpretation, @@ -1285,6 +1319,16 @@ impl Image { scratch, ); + // No-op in this branch: promoted layouts only occur in the + // unpacking branch. Called so the integer branches cannot + // diverge. + super::mask_samples_to_bit_depth( + row, + layout.tiff_bits_per_sample, + layout.output_bits_per_sample, + self.sample_format, + ); + super::post_process_row( row, photometric_interpretation, @@ -1330,6 +1374,16 @@ impl Image { scratch, ); + // No-op in this branch: promoted layouts only occur in the + // unpacking branch. Called so the integer branches cannot + // diverge. + super::mask_samples_to_bit_depth( + row, + layout.tiff_bits_per_sample, + layout.output_bits_per_sample, + self.sample_format, + ); + super::post_process_row( row, photometric_interpretation, diff --git a/src/decoder/logluv.rs b/src/decoder/logluv.rs index 7744736..ff3e742 100644 --- a/src/decoder/logluv.rs +++ b/src/decoder/logluv.rs @@ -92,11 +92,7 @@ fn packbits_decode( let mut i = 0; let mut code = [0u8; 1]; - loop { - let Some(pixels) = buf.get_mut(i..) else { - break; - }; - + while let Some(pixels) = buf.get_mut(i..) { if pixels.is_empty() { break; } diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs index 9ab8d3f..6873658 100644 --- a/src/decoder/mod.rs +++ b/src/decoder/mod.rs @@ -257,6 +257,8 @@ pub struct Decoder { /// Reusable scratch buffer for the floating-point predictor. Stored here so that the /// allocation persists across strips/tiles and images, avoiding per-row heap allocation. scratch: Vec, + /// Leniency flag that allows libtiff-like TIFF color interpretation behavior + lenient: bool, } /// All the information needed to read and interpret byte slices from the underlying file, i.e. to @@ -566,6 +568,56 @@ fn fix_endianness_and_predict( } } +/// Reduce promoted extended-depth samples (9–15 bits → `u16`, 17–31 bits → +/// `u32`) modulo their declared bit depth, i.e. mask with `(1 << n) - 1`. +/// +/// An n-bit horizontal-predicted sample stores its delta in exactly n bits, +/// i.e. modulo `2^n`, so reconstruction is also defined modulo `2^n`. The +/// predictor accumulates `prev + delta` in the promoted storage width +/// (`u16`/`u32`, see `rev_hpredict_nsamp`); since `2^n` divides the storage +/// modulus, masking once after the predictor yields exactly the mod-`2^n` +/// value for every sample — including legitimate wrap-arounds (a 9-bit +/// `511, 0` pair is stored as delta `1`, and `511 + 1 = 512` masks back to +/// `0`). Masking also bounds any out-of-range sample in malformed input, so +/// all downstream consumers (`invert_colors`' `max - v` and the non-inverted +/// output path) see in-range values. For samples already in range it is a +/// no-op. +/// +/// This runs in every integer decode branch of `expand_chunk` so the +/// branches cannot diverge. Outside the unpacking branch it reduces to the +/// two guards below and touches nothing: promoted layouts (`declared < +/// storage`) exist only there — standard depths have `declared == storage`, +/// and sub-byte depths keep byte-granular storage. +#[inline] +fn mask_samples_to_bit_depth( + buf: &mut [u8], + declared_bits: u8, + storage_bits: u8, + sample_format: SampleFormat, +) { + // Only unsigned samples are promoted with zero-extension; signed + // extended depths would need sign extension instead and are not + // supported by the unpacking path. + if sample_format != SampleFormat::Uint { + return; + } + match storage_bits { + 16 if declared_bits < 16 => { + let mask = (1u16 << declared_bits) - 1; + for chunk in buf.as_chunks_mut::<2>().0 { + *chunk = (u16::from_ne_bytes(*chunk) & mask).to_ne_bytes(); + } + } + 32 if declared_bits < 32 => { + let mask = (1u32 << declared_bits) - 1; + for chunk in buf.as_chunks_mut::<4>().0 { + *chunk = (u32::from_ne_bytes(*chunk) & mask).to_ne_bytes(); + } + } + _ => {} + } +} + fn invert_colors( buf: &mut [u8], color_type: ColorType, @@ -664,8 +716,8 @@ fn invert_colors( /// CIELab (photometric=8) stores a/b as int8 where 0 is neutral. /// ICCLab (photometric=9) stores them as uint8 where 128 is neutral. /// Adding 128 (wrapping) converts between the two representations. -/// See libtiff's TIFFCIELabToXYZ for reference: -/// https://gitlab.com/libtiff/libtiff/-/blob/master/libtiff/tif_color.c +/// See libtiff's `TIFFCIELabToXYZ` for reference (tif_color.c line 45 +/// @ 04884181e8903915038be553bfab404302d98ea0). fn cielab_to_icclab(buf: &mut [u8]) { for [_, a, b] in buf.as_chunks_mut::<3>().0 { *a = a.wrapping_add(128); @@ -784,6 +836,7 @@ impl TiffHeader { seen_ifds: cycles::IfdCycles::new(), image: None, scratch: Vec::new(), + lenient: false, } } } @@ -866,6 +919,15 @@ impl Decoder { self } + /// Use a lenient decoder. + /// + /// Configuring this make the parser respect libtiff's behavior to maintain compatibility with + /// some older softwares (e.g. some old Adobe suite products). + pub fn with_lenient(mut self, lenient: bool) -> Decoder { + self.lenient = lenient; + self + } + // FIXME: this tuple would be a good candidate for a proxy, like IfdDecoder. That has been // requested in some form or another in a number of issues. #[expect(clippy::unnecessary_unwrap)] // Lifetimes would overlap due to return. @@ -880,7 +942,7 @@ impl Decoder { )); } - let image = Image::from_reader(&mut self.value_reader, &self.directory)?; + let image = Image::from_reader(&mut self.value_reader, &self.directory, self.lenient)?; Ok(( self.image.insert(image), &mut self.value_reader, @@ -1016,6 +1078,7 @@ impl Decoder { current_ifd_pointer: self.current_ifd_pointer, image: core::mem::take(&mut self.image), scratch: core::mem::take(&mut self.scratch), + lenient: self.lenient, } } @@ -1064,7 +1127,11 @@ impl Decoder { /// error is returned. pub fn seek_to_image(&mut self, ifd_index: usize) -> TiffResult<()> { let (offset, directory) = self.find_nth_ifd(ifd_index)?; - self.image = Some(Image::from_reader(&mut self.value_reader, &directory)?); + self.image = Some(Image::from_reader( + &mut self.value_reader, + &directory, + self.lenient, + )?); self.current_ifd_pointer = Some(offset); self.directory = directory; Ok(()) @@ -1157,7 +1224,11 @@ impl Decoder { pub fn next_image(&mut self) -> TiffResult<()> { let next_ifd = self.directory.next(); let (offset, directory) = self.read_chained_ifd(self.current_ifd_pointer, next_ifd)?; - self.image = Some(Image::from_reader(&mut self.value_reader, &directory)?); + self.image = Some(Image::from_reader( + &mut self.value_reader, + &directory, + self.lenient, + )?); self.current_ifd_pointer = Some(offset); self.directory = directory; Ok(()) diff --git a/src/tags.rs b/src/tags.rs index 8a3f481..c0eff16 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -295,7 +295,10 @@ pub enum CompressionMethod(u16) unknown( } tags! { -pub enum PhotometricInterpretation(u16) { +pub enum PhotometricInterpretation(u16) unknown( + /// A private or extension tag + unknown +) { WhiteIsZero = 0, BlackIsZero = 1, RGB = 2, diff --git a/tests/decode_images.rs b/tests/decode_images.rs index 7810359..7ea52a3 100644 --- a/tests/decode_images.rs +++ b/tests/decode_images.rs @@ -16,7 +16,9 @@ macro_rules! test_image_sum { fn $name(file: &str, expected_type: ColorType, expected_sum: $sum_ty) { let path = PathBuf::from(TEST_IMAGE_DIR).join(file); let img_file = File::open(path).expect("Cannot find test image!"); - let mut decoder = Decoder::open(img_file).expect("Cannot create decoder"); + let mut decoder = Decoder::open(img_file) + .expect("Cannot create decoder") + .with_lenient(true); decoder.next_image().expect("Cannot read image IFD"); assert_eq!(decoder.colortype().unwrap(), expected_type); @@ -825,7 +827,7 @@ fn test_predictor_3_gray_f32() { } #[test] -#[cfg(feature = "zstd")] +#[cfg(any(feature = "zstd", feature = "zstd-safe-rust"))] fn test_zstd_compression() { // gdal_translate -co COMPRESS=ZSTD -co ZSTD_LEVEL=20 int16.tif int16_zstd.tif test_image_sum_i16("int16_zstd.tif", ColorType::Gray(16), 354396); @@ -975,7 +977,7 @@ fn extra_bits_gray() { #[test] fn extra_bits_rgb() { - test_image_sum_u8("extra_bits_rgb_8b.tiff", ColorType::RGB(8), 0); + test_image_sum_u8("extra_bits_rgb_8b.tiff", ColorType::RGBA(8), 64); } #[test] diff --git a/tests/fuzz_images/oor_panic/graydepth-invert-overflow-5a0aad5024cc4ff3.tiff b/tests/fuzz_images/oor_panic/graydepth-invert-overflow-5a0aad5024cc4ff3.tiff new file mode 100644 index 0000000..21c7a2c Binary files /dev/null and b/tests/fuzz_images/oor_panic/graydepth-invert-overflow-5a0aad5024cc4ff3.tiff differ diff --git a/tests/fuzz_images/oor_panic/graydepth-invert-overflow-9f2f479685f84884.tiff b/tests/fuzz_images/oor_panic/graydepth-invert-overflow-9f2f479685f84884.tiff new file mode 100644 index 0000000..86824ec Binary files /dev/null and b/tests/fuzz_images/oor_panic/graydepth-invert-overflow-9f2f479685f84884.tiff differ diff --git a/tests/predict.rs b/tests/predict.rs index 98f6125..50e596f 100644 --- a/tests/predict.rs +++ b/tests/predict.rs @@ -233,3 +233,167 @@ fn test_rgb_u64_predict_roundtrip() { fn test_ycbcr_u8_predict_roundtrip() { test_u8_predict_roundtrip::("tiled-jpeg-ycbcr.tif", ColorType::YCbCr(8)); } + +// --------------------------------------------------------------------------- +// Extended bit depths (9-15 promoted to u16, 17-31 promoted to u32). +// +// A horizontal-predicted n-bit sample stores its delta in exactly n bits, +// i.e. modulo 2^n, so reconstruction must also be modulo 2^n. The decoder +// accumulates in the promoted storage width and reduces back to n bits; +// these tests pin that reduction down with exact pixel values, including +// deltas that wrap modulo 2^n. The encoder does not support extended +// depths, so the TIFFs are hand-built. +// --------------------------------------------------------------------------- + +/// Hand-build a minimal little-endian, single-strip, uncompressed grayscale +/// TIFF with `Predictor::Horizontal` and the given (possibly non-standard) +/// bit depth. `pixels` are the intended sample values; they are converted to +/// horizontal differences modulo `2^bits` and bit-packed MSB-first with each +/// row padded to a byte boundary — the only representation an n-bit field +/// can hold. +fn build_gray_hpredict_tiff( + width: u32, + height: u32, + bits: u8, + photometric: u16, + pixels: &[u32], +) -> Vec { + let max = ((1u64 << bits) - 1) as u32; + let row_samples = width as usize; + assert_eq!(pixels.len(), row_samples * height as usize); + assert!(pixels.iter().all(|&v| v <= max)); + + // Per-row horizontal differencing modulo 2^bits. + let mut deltas = Vec::with_capacity(pixels.len()); + for row in pixels.chunks(row_samples) { + for (i, &v) in row.iter().enumerate() { + deltas.push(if i == 0 { + v + } else { + v.wrapping_sub(row[i - 1]) & max + }); + } + } + + // Bit-pack MSB-first, each row padded to a byte boundary. + let row_bytes = (row_samples * bits as usize).div_ceil(8); + let mut data = vec![0u8; row_bytes * height as usize]; + for (r, row) in deltas.chunks(row_samples).enumerate() { + for (i, &d) in row.iter().enumerate() { + let start = r * row_bytes * 8 + i * bits as usize; + for (off, b) in (start..).zip((0..bits).rev()) { + if (d >> b) & 1 == 1 { + data[off / 8] |= 0x80 >> (off % 8); + } + } + } + } + + // Classic little-endian TIFF: header, strip data, then a single IFD. + let mut out = Vec::new(); + out.extend_from_slice(b"II"); + out.extend_from_slice(&42u16.to_le_bytes()); + let data_offset = 8u32; + let ifd_offset = data_offset + data.len() as u32; + out.extend_from_slice(&ifd_offset.to_le_bytes()); + out.extend_from_slice(&data); + + const SHORT: u16 = 3; + const LONG: u16 = 4; + let entries: &[(u16, u16, u32)] = &[ + (256, LONG, width), // ImageWidth + (257, LONG, height), // ImageLength + (258, SHORT, u32::from(bits)), // BitsPerSample + (259, SHORT, 1), // Compression: none + (262, SHORT, u32::from(photometric)), // PhotometricInterpretation + (273, LONG, data_offset), // StripOffsets + (277, SHORT, 1), // SamplesPerPixel + (278, LONG, height), // RowsPerStrip + (279, LONG, data.len() as u32), // StripByteCounts + (317, SHORT, 2), // Predictor: horizontal + (339, SHORT, 1), // SampleFormat: unsigned integer + ]; + out.extend_from_slice(&(entries.len() as u16).to_le_bytes()); + for &(tag, ty, value) in entries { + out.extend_from_slice(&tag.to_le_bytes()); + out.extend_from_slice(&ty.to_le_bytes()); + out.extend_from_slice(&1u32.to_le_bytes()); + // A count-1 SHORT payload occupies the low bytes of the value field. + out.extend_from_slice(&value.to_le_bytes()); + } + out.extend_from_slice(&0u32.to_le_bytes()); // no next IFD + out +} + +fn decode_gray_u16(tiff: Vec, bits: u8) -> Vec { + let mut decoder = Decoder::open(Cursor::new(tiff)).expect("Cannot create decoder"); + decoder.next_image().unwrap(); + assert_eq!(decoder.colortype().unwrap(), ColorType::Gray(bits)); + match decoder.read_image().unwrap() { + DecodingSampleBuffer::U16(res) => res, + _ => panic!("Wrong data type"), + } +} + +fn decode_gray_u32(tiff: Vec, bits: u8) -> Vec { + let mut decoder = Decoder::open(Cursor::new(tiff)).expect("Cannot create decoder"); + decoder.next_image().unwrap(); + assert_eq!(decoder.colortype().unwrap(), ColorType::Gray(bits)); + match decoder.read_image().unwrap() { + DecodingSampleBuffer::U32(res) => res, + _ => panic!("Wrong data type"), + } +} + +#[test] +fn test_gray12_hpredict_wraparound_exact() { + // Pixel row [4095, 0, 1] encodes as deltas [4095, 1, 1] (mod 2^12). + // u16 accumulation yields [4095, 4096, 4097]; reduction modulo 2^12 + // must restore [4095, 0, 1]. Saturating instead would corrupt the two + // wrapped samples to 4095. + let tiff = build_gray_hpredict_tiff(3, 1, 12, 1, &[4095, 0, 1]); + assert_eq!(decode_gray_u16(tiff, 12), vec![4095, 0, 1]); +} + +#[test] +fn test_gray12_hpredict_wraparound_white_is_zero_exact() { + // Same stored samples with PhotometricInterpretation = WhiteIsZero: + // after the mod-2^12 reduction the decoder inverts as (2^12 - 1) - v. + let tiff = build_gray_hpredict_tiff(3, 1, 12, 0, &[4095, 0, 1]); + assert_eq!(decode_gray_u16(tiff, 12), vec![0, 4095, 4094]); +} + +#[test] +fn test_gray20_hpredict_wraparound_exact() { + // The same wrap-around shape on the u32 storage path (17-31 bits). + let tiff = build_gray_hpredict_tiff(3, 1, 20, 1, &[0xF_FFFF, 0, 1]); + assert_eq!(decode_gray_u32(tiff, 20), vec![0xF_FFFF, 0, 1]); +} + +#[test] +fn test_extended_depth_hpredict_random_roundtrip() { + // Deterministic pseudo-random pixels across a spread of extended depths; + // encode-then-decode must be the identity for every sample, including + // every mod-2^n wrap-around the differencing produces. Three rows verify + // that prediction restarts per row. + let mut state = 0x243F_6A88_85A3_08D3u64; + let mut next = move || { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + (state >> 33) as u32 + }; + for &bits in &[9u8, 11, 12, 15] { + let max = (1u32 << bits) - 1; + let pixels: Vec = (0..16 * 3).map(|_| next() & max).collect(); + let tiff = build_gray_hpredict_tiff(16, 3, bits, 1, &pixels); + let expected: Vec = pixels.iter().map(|&v| v as u16).collect(); + assert_eq!(decode_gray_u16(tiff, bits), expected, "bits={bits}"); + } + for &bits in &[17u8, 20, 25, 31] { + let max = ((1u64 << bits) - 1) as u32; + let pixels: Vec = (0..16 * 3).map(|_| next() & max).collect(); + let tiff = build_gray_hpredict_tiff(16, 3, bits, 1, &pixels); + assert_eq!(decode_gray_u32(tiff, bits), pixels, "bits={bits}"); + } +}