From 67dd4948f2f868ba719312351703a9632bf2c5cd Mon Sep 17 00:00:00 2001 From: Brant Burnett Date: Sun, 22 Dec 2024 12:55:27 -0500 Subject: [PATCH] Add TryCompress and TryDecompress (#118) These methods handle insufficient output buffer sizes without throwing an exception, instead returning false. --- Snappier.Benchmarks/BlockCompressHtml.cs | 2 + Snappier.Benchmarks/Overview.cs | 2 + Snappier.Tests/SnappyTests.cs | 92 +++++++++++++++++++++ Snappier/Internal/SnappyCompressor.cs | 31 ++++--- Snappier/Internal/SnappyStreamCompressor.cs | 6 +- Snappier/Internal/ThrowHelper.cs | 12 ++- Snappier/Snappy.cs | 77 +++++++++++++---- 7 files changed, 195 insertions(+), 27 deletions(-) diff --git a/Snappier.Benchmarks/BlockCompressHtml.cs b/Snappier.Benchmarks/BlockCompressHtml.cs index ea7bcb8..17dd9c6 100644 --- a/Snappier.Benchmarks/BlockCompressHtml.cs +++ b/Snappier.Benchmarks/BlockCompressHtml.cs @@ -27,7 +27,9 @@ public int Compress() { using var compressor = new SnappyCompressor(); +#pragma warning disable CS0618 // Type or member is obsolete return compressor.Compress(_input.Span, _output.Span); +#pragma warning restore CS0618 // Type or member is obsolete } } } diff --git a/Snappier.Benchmarks/Overview.cs b/Snappier.Benchmarks/Overview.cs index 01c2667..2122c8b 100644 --- a/Snappier.Benchmarks/Overview.cs +++ b/Snappier.Benchmarks/Overview.cs @@ -53,7 +53,9 @@ public int BlockCompress64KbHtml() { using var compressor = new SnappyCompressor(); +#pragma warning disable CS0618 // Type or member is obsolete return compressor.Compress(_htmlMemory.Span, _outputBuffer.Span); +#pragma warning restore CS0618 // Type or member is obsolete } [Benchmark] diff --git a/Snappier.Tests/SnappyTests.cs b/Snappier.Tests/SnappyTests.cs index ab6526b..8db6a3a 100644 --- a/Snappier.Tests/SnappyTests.cs +++ b/Snappier.Tests/SnappyTests.cs @@ -67,6 +67,60 @@ public void CompressAndDecompressFile_LimitedOutputBuffer() Assert.Equal(input, output); } + [Fact] + public void Compress_InsufficientOutputBuffer() + { + using var resource = + typeof(SnappyTests).Assembly.GetManifestResourceStream($"Snappier.Tests.TestData.alice29.txt"); + Assert.NotNull(resource); + + var input = new byte[65536]; + var bytesRead = resource.Read(input, 0, input.Length); + + var compressed = new byte[1024]; + Assert.Throws(() => Snappy.Compress(input.AsSpan(0, bytesRead), compressed)); + } + + [Fact] + public void TryCompressAndDecompress() + { + using var resource = + typeof(SnappyTests).Assembly.GetManifestResourceStream($"Snappier.Tests.TestData.alice29.txt"); + Assert.NotNull(resource); + + var input = new byte[65536]; + var bytesRead = resource.Read(input, 0, input.Length); + + var compressed = new byte[Snappy.GetMaxCompressedLength(bytesRead)]; + var result = Snappy.TryCompress(input.AsSpan(0, bytesRead), compressed, out var compressedLength); + Assert.True(result); + + var compressedSpan = compressed.AsSpan(0, compressedLength); + + var output = new byte[Snappy.GetUncompressedLength(compressedSpan)]; + result = Snappy.TryDecompress(compressedSpan, output, out var outputLength); + Assert.True(result); + + Assert.Equal(input.Length, outputLength); + Assert.Equal(input, output); + } + + [Fact] + public void TryCompress_InsufficientOutputBuffer() + { + using var resource = + typeof(SnappyTests).Assembly.GetManifestResourceStream($"Snappier.Tests.TestData.alice29.txt"); + Assert.NotNull(resource); + + var input = new byte[65536]; + var bytesRead = resource.Read(input, 0, input.Length); + + var compressed = new byte[1024]; + var result = Snappy.TryCompress(input.AsSpan(0, bytesRead), compressed, out _); + + Assert.False(result); + } + #if NET6_0_OR_GREATER [Theory] @@ -167,6 +221,21 @@ public void BadData_InsufficentOutputBuffer_ThrowsArgumentException() }); } + [Fact] + public void TryDecompress_InsufficentOutputBuffer_ReturnsFalse() + { + var input = new byte[100000]; + ArrayFill(input, (byte)'A'); + + var compressed = new byte[Snappy.GetMaxCompressedLength(input.Length)]; + var compressedLength = Snappy.Compress(input, compressed); + + var output = new byte[100]; + var result = Snappy.TryDecompress(compressed.AsSpan(0, compressedLength), output, out _); + + Assert.False(result); + } + [Fact] public void BadData_SimpleCorruption_ThrowsInvalidDataException() { @@ -234,6 +303,29 @@ public void BadData_FromFile_ThrowsInvalidDataException(string filename) }); } + [Theory] + [InlineData("baddata1.snappy")] + [InlineData("baddata2.snappy")] + [InlineData("baddata3.snappy")] + public void BadData_TryDecompress_ThrowsInvalidDataException(string filename) + { + using var resource = + typeof(SnappyTests).Assembly.GetManifestResourceStream($"Snappier.Tests.TestData.{filename}"); + Assert.NotNull(resource); + + var input = new byte[resource.Length]; + var bytesRead = resource.Read(input, 0, input.Length); + + Assert.Throws(() => + { + var length = Snappy.GetUncompressedLength(input.AsSpan(0, bytesRead)); + Assert.InRange(length, 0, 1 << 20); + + var output = new byte[length]; + Snappy.TryDecompress(input.AsSpan(0, bytesRead), output, out _); + }); + } + [Fact] public void DecompressToMemory() { diff --git a/Snappier/Internal/SnappyCompressor.cs b/Snappier/Internal/SnappyCompressor.cs index 7e7fa75..530493f 100644 --- a/Snappier/Internal/SnappyCompressor.cs +++ b/Snappier/Internal/SnappyCompressor.cs @@ -9,7 +9,18 @@ internal class SnappyCompressor : IDisposable { private HashTable? _workingMemory = new(); + [Obsolete("Retained for benchmark comparisons to previous versions")] public int Compress(ReadOnlySpan input, Span output) + { + if (!TryCompress(input, output, out int bytesWritten)) + { + ThrowHelper.ThrowArgumentExceptionInsufficientOutputBuffer(nameof(output)); + } + + return bytesWritten; + } + + public bool TryCompress(ReadOnlySpan input, Span output, out int bytesWritten) { if (_workingMemory == null) { @@ -18,7 +29,7 @@ public int Compress(ReadOnlySpan input, Span output) _workingMemory.EnsureCapacity(input.Length); - int bytesWritten = VarIntEncoding.Write(output, (uint)input.Length); + bytesWritten = VarIntEncoding.Write(output, (uint)input.Length); output = output.Slice(bytesWritten); while (input.Length > 0) @@ -29,15 +40,13 @@ public int Compress(ReadOnlySpan input, Span output) int maxOutput = Helpers.MaxCompressedLength(fragment.Length); + int written; if (output.Length >= maxOutput) { // The output span is large enough to hold the maximum possible compressed output, // compress directly to that span. - int written = CompressFragment(fragment, output, hashTable); - - output = output.Slice(written); - bytesWritten += written; + written = CompressFragment(fragment, output, hashTable); } else { @@ -47,15 +56,14 @@ public int Compress(ReadOnlySpan input, Span output) byte[] scratch = ArrayPool.Shared.Rent(maxOutput); try { - int written = CompressFragment(fragment, scratch.AsSpan(), hashTable); + written = CompressFragment(fragment, scratch.AsSpan(), hashTable); if (output.Length < written) { - ThrowHelper.ThrowArgumentException("Insufficient output buffer", nameof(output)); + bytesWritten = 0; + return false; } scratch.AsSpan(0, written).CopyTo(output); - output = output.Slice(written); - bytesWritten += written; } finally { @@ -63,10 +71,13 @@ public int Compress(ReadOnlySpan input, Span output) } } + output = output.Slice(written); + bytesWritten += written; + input = input.Slice(fragment.Length); } - return bytesWritten; + return true; } public void Compress(ReadOnlySequence input, IBufferWriter bufferWriter) diff --git a/Snappier/Internal/SnappyStreamCompressor.cs b/Snappier/Internal/SnappyStreamCompressor.cs index 6ebb1c5..2e46fce 100644 --- a/Snappier/Internal/SnappyStreamCompressor.cs +++ b/Snappier/Internal/SnappyStreamCompressor.cs @@ -214,7 +214,11 @@ private void CompressBlock(ReadOnlySpan input) // Make room for the header and CRC Span blockBody = output.Slice(headerSize); - int bytesWritten = _compressor.Compress(input, blockBody); + if (!_compressor.TryCompress(input, blockBody, out int bytesWritten)) + { + // Should be unreachable since we're allocating a buffer of the correct size. + ThrowHelper.ThrowInvalidOperationException(); + } if (bytesWritten < input.Length) { diff --git a/Snappier/Internal/ThrowHelper.cs b/Snappier/Internal/ThrowHelper.cs index b16cc84..19d85e6 100644 --- a/Snappier/Internal/ThrowHelper.cs +++ b/Snappier/Internal/ThrowHelper.cs @@ -32,12 +32,22 @@ public static void ThrowIfNull([NotNull] object? argument, [CallerArgumentExpres } #endif + [DoesNotReturn] + [MethodImpl(MethodImplOptions.NoInlining)] // Avoid inlining to reduce code size for a cold path + public static void ThrowArgumentExceptionInsufficientOutputBuffer(string? paramName) => + ThrowArgumentException("Output buffer is too small.", paramName); + [DoesNotReturn] public static void ThrowInvalidDataException(string? message) => throw new InvalidDataException(message); [DoesNotReturn] - public static void ThrowInvalidOperationException(string? message) => + [MethodImpl(MethodImplOptions.NoInlining)] // Avoid inlining to reduce code size for a cold path + public static void ThrowInvalidDataExceptionIncompleteSnappyBlock() => + throw new InvalidDataException("Incomplete Snappy block."); + + [DoesNotReturn] + public static void ThrowInvalidOperationException(string? message = null) => throw new InvalidOperationException(message); [DoesNotReturn] diff --git a/Snappier/Snappy.cs b/Snappier/Snappy.cs index 8d2bbac..1b2e15a 100644 --- a/Snappier/Snappy.cs +++ b/Snappier/Snappy.cs @@ -31,14 +31,39 @@ public static int GetMaxCompressedLength(int inputLength) => /// Data to compress. /// Buffer to receive the compressed data. /// Number of bytes written to . + /// Output buffer is too small. /// /// The output buffer must be large enough to contain the compressed output. /// public static int Compress(ReadOnlySpan input, Span output) { + if (!TryCompress(input, output, out var bytesWritten)) + { + ThrowHelper.ThrowArgumentExceptionInsufficientOutputBuffer(nameof(output)); + } + + return bytesWritten; + } + + /// + /// Attempt to compress the input data into the output buffer. + /// + /// Data to compress. + /// Buffer to receive the compressed data. + /// Number of bytes written to the . + /// true if the compression was successful, false if the output buffer is too small. + public static bool TryCompress(ReadOnlySpan input, Span output, out int bytesWritten) + { + if (output.IsEmpty) + { + // Minimum of 1 byte is required to store a zero-length block, short circuit. + bytesWritten = 0; + return false; + } + using var compressor = new SnappyCompressor(); - return compressor.Compress(input, output); + return compressor.TryCompress(input, output, out bytesWritten); } /// @@ -46,9 +71,13 @@ public static int Compress(ReadOnlySpan input, Span output) /// /// Data to compress. /// Buffer writer to receive the compressed data. + /// is null. + /// is larger than the maximum of 4,294,967,295 bytes. /// - /// For the best performance the input sequence should be comprised of segments some multiple of 64KB - /// in size or a single wrapped in a sequence. + /// + /// For the best performance, sequences with more than one segement should be comprised of segments some multiple of 64KB + /// in size (i.e. 64KB or 128KB or 256KB each) with only the final segment varying. + /// /// public static void Compress(ReadOnlySequence input, IBufferWriter output) { @@ -73,7 +102,11 @@ public static IMemoryOwner CompressToMemory(ReadOnlySpan input) try { - int length = Compress(input, buffer); + if (!TryCompress(input, buffer, out int length)) + { + // Should be unreachable since we're allocating a buffer of the correct size. + ThrowHelper.ThrowInvalidOperationException(); + } return new ByteArrayPoolMemoryOwner(buffer, length); } @@ -123,6 +156,25 @@ public static int GetUncompressedLength(ReadOnlySpan input) => /// Invalid Snappy block. /// Output buffer is too small. public static int Decompress(ReadOnlySpan input, Span output) + { + bool result = TryDecompress(input, output, out int bytesWritten); + if (!result) + { + ThrowHelper.ThrowArgumentExceptionInsufficientOutputBuffer(nameof(output)); + } + + return bytesWritten; + } + + /// + /// Decompress a block of Snappy data. This must be an entire block. + /// + /// Data to decompress. + /// Buffer to receive the decompressed data. + /// Number of bytes written to the . + /// true if the compression was successful, false if the output buffer is too small. + /// Invalid Snappy block. + public static bool TryDecompress(ReadOnlySpan input, Span output, out int bytesWritten) { using var decompressor = new SnappyDecompressor(); @@ -130,17 +182,12 @@ public static int Decompress(ReadOnlySpan input, Span output) if (!decompressor.AllDataDecompressed) { - ThrowHelper.ThrowInvalidDataException("Incomplete Snappy block."); + ThrowHelper.ThrowInvalidDataExceptionIncompleteSnappyBlock(); } - int read = decompressor.Read(output); - - if (!decompressor.EndOfFile) - { - ThrowHelper.ThrowArgumentException("Output buffer is too small.", nameof(output)); - } + bytesWritten = decompressor.Read(output); - return read; + return decompressor.EndOfFile; } /// @@ -165,7 +212,7 @@ public static void Decompress(ReadOnlySequence input, IBufferWriter if (!decompressor.AllDataDecompressed) { - ThrowHelper.ThrowInvalidDataException("Incomplete Snappy block."); + ThrowHelper.ThrowInvalidDataExceptionIncompleteSnappyBlock(); } } @@ -186,7 +233,7 @@ public static IMemoryOwner DecompressToMemory(ReadOnlySpan input) if (!decompressor.AllDataDecompressed) { - ThrowHelper.ThrowInvalidDataException("Incomplete Snappy block."); + ThrowHelper.ThrowInvalidDataExceptionIncompleteSnappyBlock(); } return decompressor.ExtractData(); @@ -212,7 +259,7 @@ public static IMemoryOwner DecompressToMemory(ReadOnlySequence input if (!decompressor.AllDataDecompressed) { - ThrowHelper.ThrowInvalidDataException("Incomplete Snappy block."); + ThrowHelper.ThrowInvalidDataExceptionIncompleteSnappyBlock(); } return decompressor.ExtractData();