diff --git a/src/ImageSharp/Formats/Tiff/Compression/Decompressors/NoneTiffCompression.cs b/src/ImageSharp/Formats/Tiff/Compression/Decompressors/NoneTiffCompression.cs index 0c601e9ddb..335c0ae635 100644 --- a/src/ImageSharp/Formats/Tiff/Compression/Decompressors/NoneTiffCompression.cs +++ b/src/ImageSharp/Formats/Tiff/Compression/Decompressors/NoneTiffCompression.cs @@ -11,20 +11,37 @@ namespace SixLabors.ImageSharp.Formats.Tiff.Compression.Decompressors; /// internal sealed class NoneTiffCompression : TiffBaseDecompressor { + private readonly SegmentIntegrityHandling integrityHandling; + /// /// Initializes a new instance of the class. /// /// The memory allocator. /// The width of the image. /// The bits per pixel. - public NoneTiffCompression(MemoryAllocator memoryAllocator, int width, int bitsPerPixel) + /// The policy for incomplete image data. + public NoneTiffCompression(MemoryAllocator memoryAllocator, int width, int bitsPerPixel, SegmentIntegrityHandling integrityHandling) : base(memoryAllocator, width, bitsPerPixel) { + this.integrityHandling = integrityHandling; } /// protected override void Decompress(BufferedReadStream stream, int byteCount, int stripHeight, Span buffer, CancellationToken cancellationToken) - => _ = stream.Read(buffer, 0, Math.Min(buffer.Length, byteCount)); + { + // The caller supplies only the rows needed for this read. Respect both the declared byte count and end of file. + int bytesRead = stream.Read(buffer, 0, Math.Min(buffer.Length, byteCount)); + if (bytesRead < buffer.Length) + { + if (this.integrityHandling != SegmentIntegrityHandling.IgnoreImageData) + { + throw new InvalidImageContentException("TIFF strip or tile contains incomplete image data."); + } + + // The buffer is reused. Missing samples must not retain bytes from a previous strip, row, or pooled allocation. + buffer[bytesRead..].Clear(); + } + } /// protected override void Dispose(bool disposing) diff --git a/src/ImageSharp/Formats/Tiff/Compression/TiffDecompressorsFactory.cs b/src/ImageSharp/Formats/Tiff/Compression/TiffDecompressorsFactory.cs index 1eaa64c43b..bb3d7e18b6 100644 --- a/src/ImageSharp/Formats/Tiff/Compression/TiffDecompressorsFactory.cs +++ b/src/ImageSharp/Formats/Tiff/Compression/TiffDecompressorsFactory.cs @@ -40,7 +40,7 @@ public static TiffBaseDecompressor Create( case TiffDecoderCompressionType.None: DebugGuard.IsTrue(predictor == TiffPredictor.None, "Predictor should only be used with lzw or deflate compression"); DebugGuard.IsTrue(faxOptions == FaxCompressionOptions.None, "No fax compression options are expected"); - return new NoneTiffCompression(allocator, width, bitsPerPixel); + return new NoneTiffCompression(allocator, width, bitsPerPixel, options.SegmentIntegrityHandling); case TiffDecoderCompressionType.PackBits: DebugGuard.IsTrue(predictor == TiffPredictor.None, "Predictor should only be used with lzw or deflate compression"); diff --git a/src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs b/src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs index 18c0f2e0d3..fb9d6d5d7b 100644 --- a/src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs +++ b/src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs @@ -74,8 +74,17 @@ private static ByteOrder ReadByteOrder(Stream stream) private List ReadIfds(bool isBigTiff) { List readers = []; + + // TIFF allows directories at any offset, so a chain that goes backwards in the file is valid. + // Only a directory offset that has already been read means the chain loops. + HashSet visitedOffsets = new(4); while (this.nextIfdOffset != 0 && this.nextIfdOffset < (ulong)this.stream.Length) { + if (!visitedOffsets.Add(this.nextIfdOffset)) + { + TiffThrowHelper.ThrowImageFormatException("TIFF image contains circular directory offsets"); + } + EntryReader reader = new(this.stream, this.ByteOrder, this.allocator); reader.ReadTags(isBigTiff, this.nextIfdOffset); @@ -90,11 +99,6 @@ private List ReadIfds(bool isBigTiff) } } - if (this.nextIfdOffset >= reader.NextIfdOffset && reader.NextIfdOffset != 0) - { - TiffThrowHelper.ThrowImageFormatException("TIFF image contains circular directory offsets"); - } - this.nextIfdOffset = reader.NextIfdOffset; readers.Add(reader); diff --git a/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs b/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs index 413e970836..dbd844faec 100644 --- a/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs +++ b/src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs @@ -184,7 +184,6 @@ protected override Image Decode(BufferedReadStream stream, Cance } } - this.Dimensions = frames[0].Size; ImageMetadata metadata = TiffDecoderMetadataCreator.Create(framesMetadata, this.skipMetadata, reader.ByteOrder, reader.IsBigTiff); return new Image(this.configuration, metadata, frames); } @@ -253,30 +252,38 @@ private ImageFrame DecodeFrame(ExifProfile tags, Size? size, Can } else { + // Record the root size before allocation so allocation errors report the image dimensions. size = new Size(width, height); + this.Dimensions = size.Value; } ImageFrame frame = new(this.configuration, size.Value.Width, size.Value.Height, metadata.FrameMetadata); - if (isTiled) - { - this.DecodeImageWithTiles(tags, frame, width, height, cancellationToken); - } - else + try { - this.DecodeImageWithStrips(tags, frame, width, height, cancellationToken); - } + if (isTiled) + { + this.DecodeImageWithTiles(tags, frame, width, height, cancellationToken); + } + else + { + this.DecodeImageWithStrips(tags, frame, width, height, cancellationToken); + } + + // This shared conversion accepts RGB-compatible pixels. Other TIFF color models convert while decoding their samples. + if (this.ColorType is >= TiffColorType.PaletteColor and <= TiffColorType.Rgba32323232Planar) + { + _ = this.TryConvertIccProfile(frame); + } - // Only RGB-compatible color types can be converted here because the TPixel-based ICC profile conversion - // expects RGB-like pixel data; other photometric interpretations (YCbCr, CMYK, Lab, etc.) would require - // dedicated transforms. We do this once at the frame level to avoid duplicating conversion logic - // across all color decoders and to keep their decode paths focused on raw pixel unpacking. - if (this.ColorType is >= TiffColorType.PaletteColor and <= TiffColorType.Rgba32323232Planar) + return frame; + } + catch { - _ = this.TryConvertIccProfile(frame); + // The caller takes ownership only after this method returns, including any ICC conversion. + frame.Dispose(); + throw; } - - return frame; } private (ImageFrameMetadata FrameMetadata, TiffFrameMetadata TiffMetadata) CreateFrameMetadata(ExifProfile tags) @@ -323,14 +330,18 @@ private ImageFrame DecodeFrame(ExifProfile tags, Size? size, Can private void DecodeImageWithStrips(ExifProfile tags, ImageFrame frame, int width, int height, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { - int rowsPerStrip; + // TIFF 6.0 defaults RowsPerStrip to 2^32 - 1 (page 39). A value at least as large as height describes one strip. + // Both layouts allocate storage for the rows that exist, not the larger declared strip height. + int rowsPerStrip = height; if (tags.TryGetValue(ExifTag.RowsPerStrip, out IExifValue value)) { - rowsPerStrip = (int)value.Value; + rowsPerStrip = Math.Min((int)value.Value, height); } - else + + // TIFF 6.0 requires at least one row per strip. Zero cannot define row offsets or a strip count. + if (rowsPerStrip <= 0) { - rowsPerStrip = TiffConstants.RowsPerStripInfinity; + TiffThrowHelper.ThrowImageFormatException("RowsPerStrip must be greater than zero."); } Array stripOffsetsArray = (Array)tags.GetValueInternal(ExifTag.StripOffsets).GetValue(); @@ -451,13 +462,31 @@ private void DecodeStripsPlanar( Buffer2D pixels = frame.PixelBuffer; + using TiffBaseDecompressor decompressor = this.CreateDecompressor(width, bitsPerPixel, frame.Metadata); + + // Read an uncompressed plane in rows when its strip exceeds the single-buffer limit. + // Each row has a fixed byte count, so its file offset does not depend on decoding earlier rows. + bool readRows = false; + if (decompressor is NoneTiffCompression && this.CanReadStripRows()) + { + for (int planeIndex = 0; planeIndex < stripsPerPixel; planeIndex++) + { + if (this.CalculateStripBufferSize(width, rowsPerStrip, planeIndex) > (ulong)this.memoryAllocator.SingleBufferAllocationLimitBytes) + { + readRows = true; + break; + } + } + } + + int rowsPerRead = readRows ? 1 : rowsPerStrip; IMemoryOwner[] stripBuffers = new IMemoryOwner[stripsPerPixel]; try { for (int stripIndex = 0; stripIndex < stripBuffers.Length; stripIndex++) { - ulong uncompressedStripSize = this.CalculateStripBufferSize(width, rowsPerStrip, stripIndex); + ulong uncompressedStripSize = this.CalculateStripBufferSize(width, rowsPerRead, stripIndex); if (uncompressedStripSize > int.MaxValue) { @@ -467,7 +496,6 @@ private void DecodeStripsPlanar( stripBuffers[stripIndex] = this.memoryAllocator.Allocate((int)uncompressedStripSize); } - using TiffBaseDecompressor decompressor = this.CreateDecompressor(width, bitsPerPixel, frame.Metadata); TiffBasePlanarColorDecoder colorDecoder = this.CreatePlanarColorDecoder(frame.Metadata); for (int i = 0; i < stripsPerPlane; i++) @@ -476,21 +504,46 @@ private void DecodeStripsPlanar( int stripHeight = i < stripsPerPlane - 1 || height % rowsPerStrip == 0 ? rowsPerStrip : height % rowsPerStrip; - int stripIndex = i; - for (int planeIndex = 0; planeIndex < stripsPerPixel; planeIndex++) + for (int row = 0; row < stripHeight; row += rowsPerRead) { - decompressor.Decompress( - this.inputStream, - stripOffsets[stripIndex], - stripByteCounts[stripIndex], - stripHeight, - stripBuffers[planeIndex].GetSpan(), - cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); - stripIndex += stripsPerPlane; - } + int readHeight = Math.Min(rowsPerRead, stripHeight - row); + int stripIndex = i; + for (int planeIndex = 0; planeIndex < stripsPerPixel; planeIndex++) + { + ulong offset = stripOffsets[stripIndex]; + ulong byteCount = stripByteCounts[stripIndex]; + if (readRows) + { + // Read the one row at its position in the strip of this plane. A strip that is shorter + // than its rows is read only up to its end, as when the strip is read whole. + ulong bytesPerRow = this.CalculateStripBufferSize(width, 1, planeIndex); + ulong rowStart = (ulong)row * bytesPerRow; + offset += rowStart; + byteCount = byteCount > rowStart ? Math.Min(byteCount - rowStart, bytesPerRow) : 0; + } + + Span stripBuffer = stripBuffers[planeIndex].GetSpan(); + if (decompressor is NoneTiffCompression) + { + // The last strip can be shorter than the reusable buffer. Padding is not required image data. + stripBuffer = stripBuffer[..(int)this.CalculateUncompressedDataSize(width, readHeight, planeIndex)]; + } + + decompressor.Decompress( + this.inputStream, + offset, + byteCount, + readHeight, + stripBuffer, + cancellationToken); + + stripIndex += stripsPerPlane; + } - colorDecoder.Decode(stripBuffers, pixels, 0, rowsPerStrip * i, width, stripHeight); + colorDecoder.Decode(stripBuffers, pixels, 0, (rowsPerStrip * i) + row, width, readHeight); + } } } finally @@ -523,12 +576,6 @@ private void DecodeStripsChunky( CancellationToken cancellationToken) where TPixel : unmanaged, IPixel { - // If the rowsPerStrip has the default value, which is effectively infinity. That is, the entire image is one strip. - if (rowsPerStrip == TiffConstants.RowsPerStripInfinity) - { - rowsPerStrip = height; - } - ulong uncompressedStripSize = this.CalculateStripBufferSize(width, rowsPerStrip); int bitsPerPixel = this.BitsPerPixel; @@ -536,13 +583,11 @@ private void DecodeStripsChunky( TiffBaseColorDecoder colorDecoder = this.CreateChunkyColorDecoder(frame.Metadata); Buffer2D pixels = frame.PixelBuffer; - // There exists in this world TIFF files with uncompressed strips larger than Int32.MaxValue. - // We can read them, but we cannot allocate a buffer that large to hold the uncompressed data. - // In this scenario we fall back to reading and decoding one row at a time. - // - // The NoneTiffCompression decompressor can be used to read individual rows since we have - // a guarantee that each row required the same number of bytes. - if (decompressor is NoneTiffCompression none && uncompressedStripSize > int.MaxValue) + // An uncompressed strip can exceed the single-buffer limit even when the decoded image fits in memory. + // Read it in rows. Each row has a fixed byte count and uses the same reusable buffer. + if (decompressor is NoneTiffCompression none + && uncompressedStripSize > (ulong)this.memoryAllocator.SingleBufferAllocationLimitBytes + && this.CanReadStripRows()) { ulong bytesPerRowU = this.CalculateStripBufferSize(width, 1); @@ -571,23 +616,19 @@ private void DecodeStripsChunky( ulong baseOffset = stripOffsets[stripIndex]; ulong available = stripByteCounts[stripIndex]; - ulong required = (ulong)bytesPerRow * (ulong)stripHeight; - if (available < required) - { - break; - } - for (int r = 0; r < stripHeight; r++) { cancellationToken.ThrowIfCancellationRequested(); - ulong rowOffset = baseOffset + ((ulong)r * (ulong)bytesPerRow); + ulong rowStart = (ulong)r * (ulong)bytesPerRow; + ulong rowOffset = baseOffset + rowStart; + ulong rowByteCount = available > rowStart ? Math.Min(available - rowStart, (ulong)bytesPerRow) : 0; - // Use the NoneTiffCompression decompressor to read exactly one row. + // Keep reads within this strip. The decompressor applies the integrity policy to missing row samples. none.Decompress( this.inputStream, rowOffset, - (ulong)bytesPerRow, + rowByteCount, 1, rowBuffer, cancellationToken); @@ -631,12 +672,19 @@ private void DecodeStripsChunky( break; } + Span stripData = stripBufferSpan; + if (decompressor is NoneTiffCompression) + { + // Only the final strip's actual rows must be present. The buffer can also hold a full strip. + stripData = stripData[..(int)this.CalculateUncompressedDataSize(width, stripHeight, -1)]; + } + decompressor.Decompress( this.inputStream, stripOffsets[stripIndex], stripByteCounts[stripIndex], stripHeight, - stripBufferSpan, + stripData, cancellationToken); colorDecoder.Decode(stripBufferSpan, pixels, 0, top, width, stripHeight); @@ -686,11 +734,16 @@ private void DecodeTilesPlanar( try { - int bytesPerTileRow = RoundUpToMultipleOfEight(tileWidth * bitsPerPixel); - int uncompressedTilesSize = bytesPerTileRow * tileLength; for (int i = 0; i < tilesBuffers.Length; i++) { - tilesBuffers[i] = this.memoryAllocator.Allocate(uncompressedTilesSize, AllocationOptions.Clean); + // Each buffer holds one plane, not every component of a pixel. Tile padding still uses the full tile dimensions. + ulong uncompressedTileSize = this.CalculateStripBufferSize(tileWidth, tileLength, i); + if (uncompressedTileSize > int.MaxValue) + { + TiffThrowHelper.ThrowNotSupported("Tiles larger than Int32.MaxValue bytes are not supported."); + } + + tilesBuffers[i] = this.memoryAllocator.Allocate((int)uncompressedTileSize, AllocationOptions.Clean); } using TiffBaseDecompressor decompressor = this.CreateDecompressor(frame.Width, bitsPerPixel, frame.Metadata, true, tileWidth, tileLength); @@ -712,12 +765,18 @@ private void DecodeTilesPlanar( { cancellationToken.ThrowIfCancellationRequested(); + Span tileBuffer = tilesBuffers[i].GetSpan(); + if (decompressor is NoneTiffCompression) + { + tileBuffer = tileBuffer[..(int)this.CalculateUncompressedDataSize(tileWidth, tileLength, i)]; + } + decompressor.Decompress( this.inputStream, tileOffsets[tileIndexForChannel], tileByteCounts[tileIndexForChannel], tileLength, - tilesBuffers[i].GetSpan(), + tileBuffer, cancellationToken); tileIndexForChannel += tilesPerChannel; @@ -938,6 +997,12 @@ private ulong CalculateStripBufferSize(int width, int height, int plane = -1) if (this.PlanarConfiguration == TiffPlanarConfiguration.Chunky) { DebugGuard.IsTrue(plane == -1, "Expected Chunky planar."); + + if (this.CompressionType == TiffDecoderCompressionType.None && !this.CanReadStripRows()) + { + return this.CalculateUncompressedDataSize(width, height, plane); + } + bitsPerPixel = this.BitsPerPixel; } else @@ -966,6 +1031,45 @@ private ulong CalculateStripBufferSize(int width, int height, int plane = -1) return bytesPerRow * (ulong)height; } + /// + /// Calculates the encoded byte count for an uncompressed strip, tile, or individual row. + /// + /// The stored row width, including tile padding when applicable. + /// The number of stored rows. + /// The planar component index, or -1 for chunky data. + /// The number of encoded bytes required for these samples. + private ulong CalculateUncompressedDataSize(int width, int height, int plane) + { + if (this.CanReadStripRows()) + { + return this.CalculateStripBufferSize(width, height, plane); + } + + ulong horizontal = this.YcbcrSubSampling[0]; + ulong vertical = this.YcbcrSubSampling[1]; + ulong groupsAcross = ((ulong)width + horizontal - 1) / horizontal; + ulong groupsDown = ((ulong)height + vertical - 1) / vertical; + + // Chunky YCbCr stores horizontal * vertical luminance samples, then one Cb and one Cr sample per group. + // Planar YCbCr stores full-resolution luminance and one sample per group in each separate chroma plane. + // Chroma expansion needs larger plane buffers, but those extra bytes are not required in the file. + return this.PlanarConfiguration == TiffPlanarConfiguration.Chunky + ? groupsAcross * groupsDown * ((horizontal * vertical) + 2) + : plane == 0 ? (ulong)width * (ulong)height : groupsAcross * groupsDown; + } + + /// + /// Determines whether each uncompressed row can be read and decoded separately. + /// + /// if the rows of a strip can be decoded one at a time. + private bool CanReadStripRows() + { + // Subsampled YCbCr groups can span rows. Read the complete strip before expanding its chroma samples. + return this.PhotometricInterpretation != TiffPhotometricInterpretation.YCbCr + || this.YcbcrSubSampling is null + || (this.YcbcrSubSampling[0] == 1 && this.YcbcrSubSampling[1] == 1); + } + [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int RoundUpToMultipleOfEight(int value) => (int)(((uint)value + 7) / 8); } diff --git a/tests/ImageSharp.Tests/Formats/Tiff/Compression/NoneTiffCompressionTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/Compression/NoneTiffCompressionTests.cs index b911d1b177..e059ff3c5d 100644 --- a/tests/ImageSharp.Tests/Formats/Tiff/Compression/NoneTiffCompressionTests.cs +++ b/tests/ImageSharp.Tests/Formats/Tiff/Compression/NoneTiffCompressionTests.cs @@ -18,7 +18,7 @@ public void Decompress_ReadsData(byte[] inputData, uint byteCount, byte[] expect using BufferedReadStream stream = new(Configuration.Default, memoryStream); byte[] buffer = new byte[expectedResult.Length]; - using NoneTiffCompression decompressor = new(default, default, default); + using NoneTiffCompression decompressor = new(default, default, default, default); decompressor.Decompress(stream, 0, byteCount, 1, buffer, default); Assert.Equal(expectedResult, buffer); diff --git a/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderStripLayoutTests.cs b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderStripLayoutTests.cs new file mode 100644 index 0000000000..9a5bb40a8d --- /dev/null +++ b/tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderStripLayoutTests.cs @@ -0,0 +1,241 @@ +// Copyright (c) Six Labors. +// Licensed under the Six Labors Split License. + +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Memory; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; +using static SixLabors.ImageSharp.Tests.TestImages.Tiff; + +namespace SixLabors.ImageSharp.Tests.Formats.Tiff; + +/// +/// Tests strip allocation and directory traversal with stored TIFF inputs. +/// +[Trait("Format", "Tiff")] +[ValidateDisposedMemoryAllocations] +public class TiffDecoderStripLayoutTests +{ + /// + /// A missing RowsPerStrip or a value greater than the image height describes one strip per plane. + /// + /// The stored TIFF input. + [Theory] + [InlineData(ChunkyRowsPerStripMissing)] + [InlineData(ChunkyRowsPerStripLarge)] + [InlineData(ChunkyRowsPerStripMax)] + [InlineData(PlanarRowsPerStripMissing)] + [InlineData(PlanarRowsPerStripLarge)] + [InlineData(PlanarRowsPerStripMax)] + public void Decode_RowsPerStripMissingOrAboveHeight_DecodesAllRows(string path) + { + using Image expected = Image.Load(TestFile.Create(StripLayoutRgbReference).Bytes); + byte[] data = TestFile.Create(path).Bytes; + + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + using Image actual = Image.Load(options, data); + + ImageComparer.Exact.VerifySimilarity(expected, actual); + } + } + + /// + /// Reading an uncompressed strip in rows preserves every 16-bit component. + /// + /// The stored single-strip or multiple-strip input. + [Theory] + [InlineData(ChunkyRgb16SingleStrip)] + [InlineData(ChunkyRgb16MultipleStrips)] + [InlineData(PlanarRgb16SingleStrip)] + [InlineData(PlanarRgb16MultipleStrips)] + public void Decode_UncompressedStripAboveBufferLimit_DecodesAllRows(string path) + { + byte[] data = TestFile.Create(path).Bytes; + using Image expected = Image.Load(data); + Configuration configuration = Configuration.Default.Clone(); + configuration.MemoryAllocator = MemoryAllocator.Create(new MemoryAllocatorOptions { SingleBufferAllocationLimitMegabytes = 1 }); + + // Each full strip exceeds 1 MiB, including each separate plane. A row fits within the limit. + // The final strip in the multiple-strip inputs contains only the remaining 100 rows. + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { Configuration = configuration, SegmentIntegrityHandling = integrity }; + using Image actual = Image.Load(options, data); + + ImageComparer.Exact.VerifySimilarity(expected, actual); + } + } + + /// + /// Subsampled chroma planes require fewer stored samples than their expanded destination buffers. + /// + [Fact] + public void Decode_SubsampledPlanarYCbCr_DoesNotRequireExpandedChromaInFile() + { + byte[] data = TestFile.Create(PlanarYCbCrSubsampled).Bytes; + + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + using Image image = Image.Load(options, data); + + // Y = 64 with neutral Cb and Cr = 128 represents gray 64 in every pixel. + Assert.Equal(new Size(4, 2), image.Size); + for (int y = 0; y < image.Height; y++) + { + for (int x = 0; x < image.Width; x++) + { + Assert.Equal(new Rgb24(64, 64, 64), image[x, y]); + } + } + } + } + + /// + /// A zero strip height is invalid in every integrity mode. + /// + /// The stored TIFF with RowsPerStrip set to zero. + [Theory] + [InlineData(ChunkyRowsPerStripZero)] + [InlineData(PlanarRowsPerStripZero)] + public void Decode_ZeroRowsPerStrip_ThrowsImageFormatException(string path) + { + byte[] data = TestFile.Create(path).Bytes; + + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + Assert.Throws(() => + { + using Image image = Image.Load(options, data); + }); + } + } + + /// + /// Short image data follows the integrity policy without reading past the declared strip or reusing old samples. + /// + /// The stored TIFF whose strip ends halfway through its second row. + /// Whether to force row-by-row reads. + [Theory] + [InlineData(ChunkyRgb16ShortStrip, false)] + [InlineData(ChunkyRgb16ShortStrip, true)] + [InlineData(PlanarRgb16ShortStrip, false)] + [InlineData(PlanarRgb16ShortStrip, true)] + public void Decode_ShortUncompressedStrip_UsesImageDataIntegrityPolicy(string path, bool limitBuffer) + { + byte[] data = TestFile.Create(path).Bytes; + Configuration configuration = Configuration.Default.Clone(); + if (limitBuffer) + { + configuration.MemoryAllocator = MemoryAllocator.Create(new MemoryAllocatorOptions { SingleBufferAllocationLimitMegabytes = 1 }); + } + + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { Configuration = configuration, SegmentIntegrityHandling = integrity }; + if (integrity != SegmentIntegrityHandling.IgnoreImageData) + { + Assert.Throws(() => + { + using Image image = Image.Load(options, data); + }); + } + else + { + using Image image = Image.Load(options, data); + + // The fixtures contain more bytes after StripByteCounts. Those bytes must not become image samples. + // Samples before the declared end survive. Missing samples are zero in both read paths. + Assert.Equal(new Rgb48(0, 13621, 27242), image[0, 0]); + Assert.Equal(new Rgb48(25957, 39578, 53199), image[0, 1]); + Assert.Equal(default, image[1023, 1]); + Assert.Equal(default, image[0, 2]); + Assert.Equal(default, image[1023, 699]); + } + } + } + + /// + /// A failed image allocation reports the declared dimensions. + /// + [Fact] + public void Decode_FrameAboveAllocationLimit_ThrowsWithRealDimensions() + { + byte[] data = TestFile.Create(FrameAboveAllocationLimit).Bytes; + Configuration configuration = Configuration.Default.Clone(); + configuration.MemoryAllocator = MemoryAllocator.Create(new MemoryAllocatorOptions { AllocationLimitMegabytes = 100 }); + DecoderOptions options = new() { Configuration = configuration }; + + InvalidImageContentException ex = Assert.Throws(() => + { + using Image image = Image.Load(options, data); + }); + + Assert.Contains("20000x15000", ex.Message, StringComparison.Ordinal); + } + + /// + /// Directory links determine frame order even when their file offsets decrease. + /// + /// The stored TIFF or BigTIFF with reversed directory offsets. + [Theory] + [InlineData(ReverseDirectories)] + [InlineData(BigTiffReverseDirectories)] + public void Decode_DirectoriesStoredInReverseFileOrder_DecodesAllFrames(string path) + { + byte[] data = TestFile.Create(path).Bytes; + + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + using Image image = Image.Load(options, data); + ImageInfo info = Image.Identify(options, data); + + Assert.Equal(2, image.Frames.Count); + Assert.Equal(2, info.FrameMetadataCollection.Count); + Assert.Equal(new Size(2, 2), image.Size); + Assert.Equal(new L8(10), image.Frames[0][0, 0]); + Assert.Equal(new L8(11), image.Frames[0][1, 0]); + Assert.Equal(new L8(12), image.Frames[0][0, 1]); + Assert.Equal(new L8(13), image.Frames[0][1, 1]); + Assert.Equal(new L8(20), image.Frames[1][0, 0]); + Assert.Equal(new L8(21), image.Frames[1][1, 0]); + Assert.Equal(new L8(22), image.Frames[1][0, 1]); + Assert.Equal(new L8(23), image.Frames[1][1, 1]); + } + } + + /// + /// A repeated directory offset is a structural error in every integrity mode. + /// + /// The stored TIFF or BigTIFF with a circular directory chain. + [Theory] + [InlineData(DirectoryLoopOne)] + [InlineData(DirectoryLoopTwo)] + [InlineData(DirectoryLoopTwoReverse)] + [InlineData(DirectoryLoopThreeReverse)] + [InlineData(BigTiffDirectoryLoopOne)] + [InlineData(BigTiffDirectoryLoopTwo)] + [InlineData(BigTiffDirectoryLoopTwoReverse)] + [InlineData(BigTiffDirectoryLoopThreeReverse)] + public void Decode_DirectoryChainRevisitsDirectory_ThrowsImageFormatException(string path) + { + byte[] data = TestFile.Create(path).Bytes; + + foreach (SegmentIntegrityHandling integrity in Enum.GetValues()) + { + DecoderOptions options = new() { SegmentIntegrityHandling = integrity }; + ImageFormatException identifyError = Assert.Throws(() => Image.Identify(options, data)); + ImageFormatException decodeError = Assert.Throws(() => + { + using Image image = Image.Load(options, data); + }); + + Assert.Contains("circular", identifyError.Message, StringComparison.Ordinal); + Assert.Contains("circular", decodeError.Message, StringComparison.Ordinal); + } + } +} diff --git a/tests/ImageSharp.Tests/TestImages.cs b/tests/ImageSharp.Tests/TestImages.cs index e4c3fffff6..b30226ee87 100644 --- a/tests/ImageSharp.Tests/TestImages.cs +++ b/tests/ImageSharp.Tests/TestImages.cs @@ -1002,6 +1002,34 @@ public static class Tiff public const string Benchmark_RgbPackbits = "medium_rgb_packbits.tiff"; public const string Benchmark_RgbUncompressed = "medium_rgb_uncompressed.tiff"; + public const string StripLayoutRgbReference = "Tiff/strips/rgb-4000x2.png"; + public const string PlanarYCbCrSubsampled = "Tiff/strips/planar-ycbcr-subsampled.tiff"; + public const string ChunkyRowsPerStripMissing = "Tiff/strips/chunky-rowsperstrip-missing.tiff"; + public const string ChunkyRowsPerStripLarge = "Tiff/strips/chunky-rowsperstrip-large.tiff"; + public const string ChunkyRowsPerStripMax = "Tiff/strips/chunky-rowsperstrip-max.tiff"; + public const string ChunkyRowsPerStripZero = "Tiff/strips/chunky-rowsperstrip-zero.tiff"; + public const string PlanarRowsPerStripMissing = "Tiff/strips/planar-rowsperstrip-missing.tiff"; + public const string PlanarRowsPerStripLarge = "Tiff/strips/planar-rowsperstrip-large.tiff"; + public const string PlanarRowsPerStripMax = "Tiff/strips/planar-rowsperstrip-max.tiff"; + public const string PlanarRowsPerStripZero = "Tiff/strips/planar-rowsperstrip-zero.tiff"; + public const string ChunkyRgb16SingleStrip = "Tiff/strips/chunky-rgb16-700-rows.tiff"; + public const string ChunkyRgb16MultipleStrips = "Tiff/strips/chunky-rgb16-600-rows.tiff"; + public const string PlanarRgb16SingleStrip = "Tiff/strips/planar-rgb16-700-rows.tiff"; + public const string PlanarRgb16MultipleStrips = "Tiff/strips/planar-rgb16-600-rows.tiff"; + public const string ChunkyRgb16ShortStrip = "Tiff/strips/chunky-rgb16-short-strip.tiff"; + public const string PlanarRgb16ShortStrip = "Tiff/strips/planar-rgb16-short-strip.tiff"; + public const string FrameAboveAllocationLimit = "Tiff/strips/frame-above-allocation-limit.tiff"; + public const string ReverseDirectories = "Tiff/strips/reverse-directories.tiff"; + public const string BigTiffReverseDirectories = "Tiff/strips/reverse-directories.bigtiff"; + public const string DirectoryLoopOne = "Tiff/strips/loop-1-forward.tiff"; + public const string DirectoryLoopTwo = "Tiff/strips/loop-2-forward.tiff"; + public const string DirectoryLoopTwoReverse = "Tiff/strips/loop-2-reverse.tiff"; + public const string DirectoryLoopThreeReverse = "Tiff/strips/loop-3-reverse.tiff"; + public const string BigTiffDirectoryLoopOne = "Tiff/strips/loop-1-forward.bigtiff"; + public const string BigTiffDirectoryLoopTwo = "Tiff/strips/loop-2-forward.bigtiff"; + public const string BigTiffDirectoryLoopTwoReverse = "Tiff/strips/loop-2-reverse.bigtiff"; + public const string BigTiffDirectoryLoopThreeReverse = "Tiff/strips/loop-3-reverse.bigtiff"; + public const string BilevelWithoutBitsPerSampleUncompressed = "Tiff/defaults/bilevel-no-bitspersample-none.tiff"; public const string BilevelWithoutBitsPerSampleFax4 = "Tiff/defaults/bilevel-no-bitspersample-g4.tiff"; public const string BilevelWithoutSampleTags = "Tiff/defaults/bilevel-no-bitspersample-or-samplesperpixel.tiff"; diff --git a/tests/Images/Input/Tiff/strips/chunky-rgb16-600-rows.tiff b/tests/Images/Input/Tiff/strips/chunky-rgb16-600-rows.tiff new file mode 100644 index 0000000000..355a61ed5e --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rgb16-600-rows.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b47a52f17b1f1b56984f46264c5daa4b023ff85195ef5ceedf61bfaeb86d18c8 +size 4300944 diff --git a/tests/Images/Input/Tiff/strips/chunky-rgb16-700-rows.tiff b/tests/Images/Input/Tiff/strips/chunky-rgb16-700-rows.tiff new file mode 100644 index 0000000000..83e730d48c --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rgb16-700-rows.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:386e773612f8673784d243e251c99657bd5e92204bd8b3fed0c7ec1b3ef16b16 +size 4300928 diff --git a/tests/Images/Input/Tiff/strips/chunky-rgb16-short-strip.tiff b/tests/Images/Input/Tiff/strips/chunky-rgb16-short-strip.tiff new file mode 100644 index 0000000000..c25ed053c3 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rgb16-short-strip.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:240011d8f11a91720343ef6878d9fe1b89018507edcfc48954cd25b89b4bd6b8 +size 4300928 diff --git a/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-large.tiff b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-large.tiff new file mode 100644 index 0000000000..725563b927 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-large.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f826e3e26cc5a6b47b7ba186dd05dbe8f3b30faaae055722986426902d8f23db +size 24128 diff --git a/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-max.tiff b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-max.tiff new file mode 100644 index 0000000000..4d9b73ad13 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-max.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:164916f345450cc883b4ed3017ed21f0027543a4ca7d20476fe4e2e27b2e35f6 +size 24128 diff --git a/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-missing.tiff b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-missing.tiff new file mode 100644 index 0000000000..1b2e814aa3 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-missing.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:398b7f818dcd20576d7027797ee83871d50b532f31ff3c45339b0e13255a89be +size 24116 diff --git a/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-zero.tiff b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-zero.tiff new file mode 100644 index 0000000000..65e0390f5d --- /dev/null +++ b/tests/Images/Input/Tiff/strips/chunky-rowsperstrip-zero.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48267753ca7aa7e4ce87a6c117c2c6fe201a1db7993334bd7e671a10cb116fc2 +size 24128 diff --git a/tests/Images/Input/Tiff/strips/frame-above-allocation-limit.tiff b/tests/Images/Input/Tiff/strips/frame-above-allocation-limit.tiff new file mode 100644 index 0000000000..97f825e5c2 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/frame-above-allocation-limit.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:129a0ebbb2c88516825de8cd6528f1c1c95a9798eef8f4068c398638ef757bd9 +size 8310 diff --git a/tests/Images/Input/Tiff/strips/loop-1-forward.bigtiff b/tests/Images/Input/Tiff/strips/loop-1-forward.bigtiff new file mode 100644 index 0000000000..c342c77dc9 Binary files /dev/null and b/tests/Images/Input/Tiff/strips/loop-1-forward.bigtiff differ diff --git a/tests/Images/Input/Tiff/strips/loop-1-forward.tiff b/tests/Images/Input/Tiff/strips/loop-1-forward.tiff new file mode 100644 index 0000000000..7ca8ad93fe --- /dev/null +++ b/tests/Images/Input/Tiff/strips/loop-1-forward.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:274d9263569569b70b329d73f705c5e3ad160ca30331cd7acf90be3f38ed8af7 +size 126 diff --git a/tests/Images/Input/Tiff/strips/loop-2-forward.bigtiff b/tests/Images/Input/Tiff/strips/loop-2-forward.bigtiff new file mode 100644 index 0000000000..0b64a98a04 Binary files /dev/null and b/tests/Images/Input/Tiff/strips/loop-2-forward.bigtiff differ diff --git a/tests/Images/Input/Tiff/strips/loop-2-forward.tiff b/tests/Images/Input/Tiff/strips/loop-2-forward.tiff new file mode 100644 index 0000000000..32593a425e --- /dev/null +++ b/tests/Images/Input/Tiff/strips/loop-2-forward.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f3ec05f949c46b7394e7a404b9d0c475af63110ce1e8a410dc3dbc3333af1d6c +size 244 diff --git a/tests/Images/Input/Tiff/strips/loop-2-reverse.bigtiff b/tests/Images/Input/Tiff/strips/loop-2-reverse.bigtiff new file mode 100644 index 0000000000..8fb7ebc027 Binary files /dev/null and b/tests/Images/Input/Tiff/strips/loop-2-reverse.bigtiff differ diff --git a/tests/Images/Input/Tiff/strips/loop-2-reverse.tiff b/tests/Images/Input/Tiff/strips/loop-2-reverse.tiff new file mode 100644 index 0000000000..b265f3d112 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/loop-2-reverse.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d380758cdc90a0c6c007c3e1de356e517b2ffb3848b238062a5426a8359829f +size 244 diff --git a/tests/Images/Input/Tiff/strips/loop-3-reverse.bigtiff b/tests/Images/Input/Tiff/strips/loop-3-reverse.bigtiff new file mode 100644 index 0000000000..74c8a86e2b Binary files /dev/null and b/tests/Images/Input/Tiff/strips/loop-3-reverse.bigtiff differ diff --git a/tests/Images/Input/Tiff/strips/loop-3-reverse.tiff b/tests/Images/Input/Tiff/strips/loop-3-reverse.tiff new file mode 100644 index 0000000000..7a6f06d766 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/loop-3-reverse.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:610beb51d8bdd3ea149ae42bdf0cec72b1d3722f213b42ba6abd3cc680a88a40 +size 362 diff --git a/tests/Images/Input/Tiff/strips/planar-rgb16-600-rows.tiff b/tests/Images/Input/Tiff/strips/planar-rgb16-600-rows.tiff new file mode 100644 index 0000000000..58a55d3ca5 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/planar-rgb16-600-rows.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:72d37f93a003a09a0321c850b324bf5923acacee445ae24651ccfd8d81081dad +size 4300988 diff --git a/tests/Images/Input/Tiff/strips/planar-rgb16-700-rows.tiff b/tests/Images/Input/Tiff/strips/planar-rgb16-700-rows.tiff new file mode 100644 index 0000000000..76a82b5b76 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/planar-rgb16-700-rows.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6fabfd386b261df9bc40de7ff6fe2633b1c2e5d449afd4436496e3f7355fa5b1 +size 4300964 diff --git a/tests/Images/Input/Tiff/strips/planar-rgb16-short-strip.tiff b/tests/Images/Input/Tiff/strips/planar-rgb16-short-strip.tiff new file mode 100644 index 0000000000..9774488a5c --- /dev/null +++ b/tests/Images/Input/Tiff/strips/planar-rgb16-short-strip.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8149227403c242ef34feb08b15805665d4c3f14b9faa779570b785b63c8f423a +size 4300964 diff --git a/tests/Images/Input/Tiff/strips/planar-rowsperstrip-large.tiff b/tests/Images/Input/Tiff/strips/planar-rowsperstrip-large.tiff new file mode 100644 index 0000000000..425d1d6b42 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/planar-rowsperstrip-large.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f2d0b42daa1edc3bf85ae6a5d3c49d24f5749d7e99f056efd4f82971f8a49b30 +size 24164 diff --git a/tests/Images/Input/Tiff/strips/planar-rowsperstrip-max.tiff b/tests/Images/Input/Tiff/strips/planar-rowsperstrip-max.tiff new file mode 100644 index 0000000000..33af217696 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/planar-rowsperstrip-max.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b03ad3cfa2506a91f8f12f468d7e91906b2f11522e8b49a117ca3678bf215860 +size 24164 diff --git a/tests/Images/Input/Tiff/strips/planar-rowsperstrip-missing.tiff b/tests/Images/Input/Tiff/strips/planar-rowsperstrip-missing.tiff new file mode 100644 index 0000000000..2ec0f958cb --- /dev/null +++ b/tests/Images/Input/Tiff/strips/planar-rowsperstrip-missing.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ca696692d7a4f0a8e4ef9a74b30017bf94e277c810dd2f2effa0448d1e69cfd +size 24152 diff --git a/tests/Images/Input/Tiff/strips/planar-rowsperstrip-zero.tiff b/tests/Images/Input/Tiff/strips/planar-rowsperstrip-zero.tiff new file mode 100644 index 0000000000..0f6987b086 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/planar-rowsperstrip-zero.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70d0b345720dacfa3f66db0921f6e001c3bef40f19d05f8386a6d710b14a1888 +size 24164 diff --git a/tests/Images/Input/Tiff/strips/planar-ycbcr-subsampled.tiff b/tests/Images/Input/Tiff/strips/planar-ycbcr-subsampled.tiff new file mode 100644 index 0000000000..d26e8d79c1 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/planar-ycbcr-subsampled.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:929b7c625e1ae3c1b917ae2ce200e621fd9d71d61917fb9898af4094a236064b +size 188 diff --git a/tests/Images/Input/Tiff/strips/reverse-directories.bigtiff b/tests/Images/Input/Tiff/strips/reverse-directories.bigtiff new file mode 100644 index 0000000000..f54a4e9c55 Binary files /dev/null and b/tests/Images/Input/Tiff/strips/reverse-directories.bigtiff differ diff --git a/tests/Images/Input/Tiff/strips/reverse-directories.tiff b/tests/Images/Input/Tiff/strips/reverse-directories.tiff new file mode 100644 index 0000000000..222f6da2d3 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/reverse-directories.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f9336ef717b1ec742ad861be3c7a9430effc8021af3b9c80e649644d2e239f5e +size 244 diff --git a/tests/Images/Input/Tiff/strips/rgb-4000x2.png b/tests/Images/Input/Tiff/strips/rgb-4000x2.png new file mode 100644 index 0000000000..b8ca08efb6 --- /dev/null +++ b/tests/Images/Input/Tiff/strips/rgb-4000x2.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8a8e3252c92cc864d24b18f65bddafc9f7eff09c1018f1325dfccd6982baef6 +size 118