Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,37 @@ namespace SixLabors.ImageSharp.Formats.Tiff.Compression.Decompressors;
/// </summary>
internal sealed class NoneTiffCompression : TiffBaseDecompressor
{
private readonly SegmentIntegrityHandling integrityHandling;

/// <summary>
/// Initializes a new instance of the <see cref="NoneTiffCompression" /> class.
/// </summary>
/// <param name="memoryAllocator">The memory allocator.</param>
/// <param name="width">The width of the image.</param>
/// <param name="bitsPerPixel">The bits per pixel.</param>
public NoneTiffCompression(MemoryAllocator memoryAllocator, int width, int bitsPerPixel)
/// <param name="integrityHandling">The policy for incomplete image data.</param>
public NoneTiffCompression(MemoryAllocator memoryAllocator, int width, int bitsPerPixel, SegmentIntegrityHandling integrityHandling)
: base(memoryAllocator, width, bitsPerPixel)
{
this.integrityHandling = integrityHandling;
}

/// <inheritdoc/>
protected override void Decompress(BufferedReadStream stream, int byteCount, int stripHeight, Span<byte> 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();
}
}

/// <inheritdoc/>
protected override void Dispose(bool disposing)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
14 changes: 9 additions & 5 deletions src/ImageSharp/Formats/Tiff/Ifd/DirectoryReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,17 @@ private static ByteOrder ReadByteOrder(Stream stream)
private List<ExifProfile> ReadIfds(bool isBigTiff)
{
List<EntryReader> 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<ulong> 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);

Expand All @@ -90,11 +99,6 @@ private List<ExifProfile> 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);

Expand Down
226 changes: 165 additions & 61 deletions src/ImageSharp/Formats/Tiff/TiffDecoderCore.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
241 changes: 241 additions & 0 deletions tests/ImageSharp.Tests/Formats/Tiff/TiffDecoderStripLayoutTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests strip allocation and directory traversal with stored TIFF inputs.
/// </summary>
[Trait("Format", "Tiff")]
[ValidateDisposedMemoryAllocations]
public class TiffDecoderStripLayoutTests
{
/// <summary>
/// A missing RowsPerStrip or a value greater than the image height describes one strip per plane.
/// </summary>
/// <param name="path">The stored TIFF input.</param>
[Theory]
[InlineData(ChunkyRowsPerStripMissing)]
[InlineData(ChunkyRowsPerStripLarge)]
[InlineData(ChunkyRowsPerStripMax)]
[InlineData(PlanarRowsPerStripMissing)]
[InlineData(PlanarRowsPerStripLarge)]
[InlineData(PlanarRowsPerStripMax)]
public void Decode_RowsPerStripMissingOrAboveHeight_DecodesAllRows(string path)
{
using Image<Rgb24> expected = Image.Load<Rgb24>(TestFile.Create(StripLayoutRgbReference).Bytes);
byte[] data = TestFile.Create(path).Bytes;

foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
{
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
using Image<Rgb24> actual = Image.Load<Rgb24>(options, data);

ImageComparer.Exact.VerifySimilarity(expected, actual);
}
}

/// <summary>
/// Reading an uncompressed strip in rows preserves every 16-bit component.
/// </summary>
/// <param name="path">The stored single-strip or multiple-strip input.</param>
[Theory]
[InlineData(ChunkyRgb16SingleStrip)]
[InlineData(ChunkyRgb16MultipleStrips)]
[InlineData(PlanarRgb16SingleStrip)]
[InlineData(PlanarRgb16MultipleStrips)]
public void Decode_UncompressedStripAboveBufferLimit_DecodesAllRows(string path)
{
byte[] data = TestFile.Create(path).Bytes;
using Image<Rgb48> expected = Image.Load<Rgb48>(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<SegmentIntegrityHandling>())
{
DecoderOptions options = new() { Configuration = configuration, SegmentIntegrityHandling = integrity };
using Image<Rgb48> actual = Image.Load<Rgb48>(options, data);

ImageComparer.Exact.VerifySimilarity(expected, actual);
}
}

/// <summary>
/// Subsampled chroma planes require fewer stored samples than their expanded destination buffers.
/// </summary>
[Fact]
public void Decode_SubsampledPlanarYCbCr_DoesNotRequireExpandedChromaInFile()
{
byte[] data = TestFile.Create(PlanarYCbCrSubsampled).Bytes;

foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
{
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
using Image<Rgb24> image = Image.Load<Rgb24>(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]);
}
}
}
}

/// <summary>
/// A zero strip height is invalid in every integrity mode.
/// </summary>
/// <param name="path">The stored TIFF with RowsPerStrip set to zero.</param>
[Theory]
[InlineData(ChunkyRowsPerStripZero)]
[InlineData(PlanarRowsPerStripZero)]
public void Decode_ZeroRowsPerStrip_ThrowsImageFormatException(string path)
{
byte[] data = TestFile.Create(path).Bytes;

foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
{
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
Assert.Throws<ImageFormatException>(() =>
{
using Image<Rgb24> image = Image.Load<Rgb24>(options, data);
});
}
}

/// <summary>
/// Short image data follows the integrity policy without reading past the declared strip or reusing old samples.
/// </summary>
/// <param name="path">The stored TIFF whose strip ends halfway through its second row.</param>
/// <param name="limitBuffer">Whether to force row-by-row reads.</param>
[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<SegmentIntegrityHandling>())
{
DecoderOptions options = new() { Configuration = configuration, SegmentIntegrityHandling = integrity };
if (integrity != SegmentIntegrityHandling.IgnoreImageData)
{
Assert.Throws<InvalidImageContentException>(() =>
{
using Image<Rgb48> image = Image.Load<Rgb48>(options, data);
});
}
else
{
using Image<Rgb48> image = Image.Load<Rgb48>(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]);
}
}
}

/// <summary>
/// A failed image allocation reports the declared dimensions.
/// </summary>
[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<InvalidImageContentException>(() =>
{
using Image<L8> image = Image.Load<L8>(options, data);
});

Assert.Contains("20000x15000", ex.Message, StringComparison.Ordinal);
}

/// <summary>
/// Directory links determine frame order even when their file offsets decrease.
/// </summary>
/// <param name="path">The stored TIFF or BigTIFF with reversed directory offsets.</param>
[Theory]
[InlineData(ReverseDirectories)]
[InlineData(BigTiffReverseDirectories)]
public void Decode_DirectoriesStoredInReverseFileOrder_DecodesAllFrames(string path)
{
byte[] data = TestFile.Create(path).Bytes;

foreach (SegmentIntegrityHandling integrity in Enum.GetValues<SegmentIntegrityHandling>())
{
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
using Image<L8> image = Image.Load<L8>(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]);
}
}

/// <summary>
/// A repeated directory offset is a structural error in every integrity mode.
/// </summary>
/// <param name="path">The stored TIFF or BigTIFF with a circular directory chain.</param>
[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<SegmentIntegrityHandling>())
{
DecoderOptions options = new() { SegmentIntegrityHandling = integrity };
ImageFormatException identifyError = Assert.Throws<ImageFormatException>(() => Image.Identify(options, data));
ImageFormatException decodeError = Assert.Throws<ImageFormatException>(() =>
{
using Image<L8> image = Image.Load<L8>(options, data);
});

Assert.Contains("circular", identifyError.Message, StringComparison.Ordinal);
Assert.Contains("circular", decodeError.Message, StringComparison.Ordinal);
}
}
}
28 changes: 28 additions & 0 deletions tests/ImageSharp.Tests/TestImages.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions tests/Images/Input/Tiff/strips/chunky-rgb16-600-rows.tiff
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/Images/Input/Tiff/strips/chunky-rgb16-700-rows.tiff
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/Images/Input/Tiff/strips/chunky-rgb16-short-strip.tiff
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/Images/Input/Tiff/strips/chunky-rowsperstrip-large.tiff
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/Images/Input/Tiff/strips/chunky-rowsperstrip-max.tiff
Git LFS file not shown
Git LFS file not shown
3 changes: 3 additions & 0 deletions tests/Images/Input/Tiff/strips/chunky-rowsperstrip-zero.tiff
Git LFS file not shown
Git LFS file not shown
Binary file not shown.
Loading
Loading