Validate fragmented GZip trailers asynchronously using inflater read-ahead

This commit is contained in:
Adam Hathcock
2026-10-10 15:40:39 +01:00
parent b919aaab1f
commit b07e6bc727
5 changed files with 186 additions and 15 deletions

View File

@@ -18,13 +18,15 @@ internal sealed class GZipChecksumValidationStream : Stream
private uint _seed = Crc32Stream.DEFAULT_SEED;
private uint _size;
private bool _validated;
private readonly Func<Stream?>? _bufferedInput;
internal GZipChecksumValidationStream(
Stream source,
Stream rawStream,
string? entryName,
uint? expectedCrc,
uint? expectedSize
uint? expectedSize,
Func<Stream?>? bufferedInput = null
)
{
_source = source;
@@ -32,6 +34,7 @@ internal sealed class GZipChecksumValidationStream : Stream
_entryName = string.IsNullOrEmpty(entryName) ? "Entry" : entryName!;
_expectedCrc = expectedCrc;
_expectedSize = expectedSize;
_bufferedInput = bufferedInput;
_crc32Table = Crc32Stream.InitializeTable(Crc32Stream.DEFAULT_POLYNOMIAL);
}
@@ -101,7 +104,14 @@ internal sealed class GZipChecksumValidationStream : Stream
.ConfigureAwait(false);
if (count != 0)
{
UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read);
if (read == 0)
{
await ValidateAsync(cancellationToken).ConfigureAwait(false);
}
else
{
UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read);
}
}
return read;
}
@@ -115,7 +125,14 @@ internal sealed class GZipChecksumValidationStream : Stream
var read = await _source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (!buffer.IsEmpty)
{
UpdateAndValidateAtEof(buffer.Span[..read], read);
if (read == 0)
{
await ValidateAsync(cancellationToken).ConfigureAwait(false);
}
else
{
UpdateAndValidateAtEof(buffer.Span[..read], read);
}
}
return read;
}
@@ -147,31 +164,77 @@ internal sealed class GZipChecksumValidationStream : Stream
return;
}
_validated = true;
var expectedCrc = _expectedCrc;
var expectedSize = _expectedSize;
if (!expectedCrc.HasValue || !expectedSize.HasValue)
{
Span<byte> trailer = stackalloc byte[8];
_rawStream.ReadFully(trailer);
var bufferedCount = ReadBufferedTrailer(trailer);
if (!_rawStream.ReadFully(trailer.Slice(bufferedCount)))
{
throw new IncompleteArchiveException("Unexpected end of GZIP trailer.");
}
expectedCrc = BinaryPrimitives.ReadUInt32LittleEndian(trailer);
expectedSize = BinaryPrimitives.ReadUInt32LittleEndian(trailer[4..]);
}
ValidateValues(expectedCrc.Value, expectedSize.Value);
}
private async ValueTask ValidateAsync(CancellationToken cancellationToken)
{
if (_validated)
{
return;
}
var expectedCrc = _expectedCrc;
var expectedSize = _expectedSize;
if (!expectedCrc.HasValue || !expectedSize.HasValue)
{
var trailer = new byte[8];
var bufferedCount = ReadBufferedTrailer(trailer);
if (
!await _rawStream
.ReadFullyAsync(
trailer,
bufferedCount,
trailer.Length - bufferedCount,
cancellationToken
)
.ConfigureAwait(false)
)
{
throw new IncompleteArchiveException("Unexpected end of GZIP trailer.");
}
expectedCrc = BinaryPrimitives.ReadUInt32LittleEndian(trailer);
expectedSize = BinaryPrimitives.ReadUInt32LittleEndian(trailer.AsSpan(4));
}
ValidateValues(expectedCrc.Value, expectedSize.Value);
}
private int ReadBufferedTrailer(Span<byte> trailer)
{
// The inflater may have read ahead beyond the final compressed bit.
using var buffered = _bufferedInput?.Invoke();
return buffered?.Read(trailer) ?? 0;
}
private void ValidateValues(uint expectedCrc, uint expectedSize)
{
var actualCrc = ~_seed;
if (actualCrc != expectedCrc.Value)
if (actualCrc != expectedCrc)
{
throw new InvalidFormatException(
$"CRC mismatch for entry '{_entryName}'. Expected 0x{expectedCrc.Value:X8}, actual 0x{actualCrc:X8}."
$"CRC mismatch for entry '{_entryName}'. Expected 0x{expectedCrc:X8}, actual 0x{actualCrc:X8}."
);
}
if (_size != expectedSize.Value)
if (_size != expectedSize)
{
throw new InvalidFormatException(
$"Size mismatch for entry '{_entryName}'. Expected {expectedSize.Value}, actual {_size}."
$"Size mismatch for entry '{_entryName}'. Expected {expectedSize}, actual {_size}."
);
}
_validated = true;
}
}

View File

@@ -44,7 +44,10 @@ internal sealed partial class GZipFilePart
{
// Read and potentially verify the GZIP trailer: CRC32 and size mod 2^32
var trailer = new byte[8];
_ = await _stream.ReadFullyAsync(trailer, 0, 8, cancellationToken).ConfigureAwait(false);
if (!await _stream.ReadFullyAsync(trailer, cancellationToken).ConfigureAwait(false))
{
throw new ZlibException("Unexpected end-of-file reading GZIP trailer.");
}
Crc = BinaryPrimitives.ReadUInt32LittleEndian(trailer);
UncompressedSize = BinaryPrimitives.ReadUInt32LittleEndian(trailer.AsSpan().Slice(4));
@@ -150,8 +153,9 @@ internal sealed partial class GZipFilePart
)
{
// GZip uses Deflate compression
return await _compressionProviders
_decompressor = await _compressionProviders
.CreateDecompressStreamAsync(CompressionType.Deflate, _stream, cancellationToken)
.ConfigureAwait(false);
return _decompressor;
}
}

View File

@@ -15,6 +15,7 @@ internal sealed partial class GZipFilePart : FilePart
private string? _name;
private readonly Stream _stream;
private readonly CompressionProviderRegistry _compressionProviders;
private Stream? _decompressor;
internal static GZipFilePart Create(
Stream stream,
@@ -64,11 +65,22 @@ internal sealed partial class GZipFilePart : FilePart
internal override Stream GetCompressedStream()
{
//GZip uses Deflate compression, at this point we need a deflate stream
return _compressionProviders.CreateDecompressStream(CompressionType.Deflate, _stream);
_decompressor = _compressionProviders.CreateDecompressStream(
CompressionType.Deflate,
_stream
);
return _decompressor;
}
internal Stream WrapWithChecksumValidation(Stream source, string? entryName) =>
new GZipChecksumValidationStream(source, _stream, entryName, Crc, UncompressedSize);
new GZipChecksumValidationStream(
source,
_stream,
entryName,
Crc,
UncompressedSize,
() => (_decompressor as DeflateStream)?.InputBuffer
);
internal override Stream GetRawStream() => _stream;
@@ -76,7 +88,10 @@ internal sealed partial class GZipFilePart : FilePart
{
// Read and potentially verify the GZIP trailer: CRC32 and size mod 2^32
Span<byte> trailer = stackalloc byte[8];
_stream.ReadFully(trailer);
if (!_stream.ReadFully(trailer))
{
throw new ZlibException("Unexpected end-of-file reading GZIP trailer.");
}
Crc = BinaryPrimitives.ReadUInt32LittleEndian(trailer);
UncompressedSize = BinaryPrimitives.ReadUInt32LittleEndian(trailer.Slice(4));

View File

@@ -2,6 +2,7 @@ using System;
using System.Buffers.Binary;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Archives;
using SharpCompress.Archives.GZip;
@@ -15,6 +16,49 @@ namespace SharpCompress.Test.GZip;
public class GZipCrcExtractionTests : TestBase
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task GZipReader_Async_Only_NonSeekable_Validates_Fragmented_Trailer(bool corrupt)
{
var bytes = corrupt
? ReadCorruptedGZipTrailer(true)
: File.ReadAllBytes(Path.Combine(TEST_ARCHIVES_PATH, "Tar.tar.gz"));
using var fragmented = new ShortReadStream(
new MemoryStream(bytes),
maxReadSize: 7,
seekable: false
);
using var input = new AsyncOnlyStream(fragmented, disposeStream: false);
await using var reader = await GZipReader.OpenAsyncReader(
input,
new ReaderOptions { LeaveStreamOpen = true },
CancellationToken.None
);
Assert.True(await reader.MoveToNextEntryAsync(CancellationToken.None));
var destination = Path.Combine(SCRATCH_FILES_PATH, Guid.NewGuid().ToString());
if (corrupt)
{
await Assert.ThrowsAsync<InvalidFormatException>(async () =>
await reader.WriteEntryToFileAsync(
destination,
cancellationToken: CancellationToken.None
)
);
}
else
{
await reader.WriteEntryToFileAsync(
destination,
cancellationToken: CancellationToken.None
);
Assert.Equal(
File.ReadAllBytes(Path.Combine(TEST_ARCHIVES_PATH, "Tar.tar")),
File.ReadAllBytes(destination)
);
}
}
[Fact]
public void GZipArchive_WriteToFile_Throws_On_Crc_Mismatch()
{

View File

@@ -0,0 +1,45 @@
using System;
using System.Buffers.Binary;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Common;
using SharpCompress.Common.GZip;
using SharpCompress.Crypto;
using SharpCompress.Test.Mocks;
using Xunit;
namespace SharpCompress.Test.GZip;
public class GZipTrailerReadTests
{
[Theory]
[InlineData(8)]
[InlineData(7)]
public async Task GZip_Async_Validation_Reads_Fragmented_Trailer(int length)
{
byte[] payload = [1, 2, 3];
var trailer = new byte[8];
BinaryPrimitives.WriteUInt32LittleEndian(trailer, Crc32Stream.Compute(payload));
BinaryPrimitives.WriteUInt32LittleEndian(trailer.AsSpan(4), 3);
using var input = new MemoryStream(payload);
using var fragmented = new ShortReadStream(
new MemoryStream(trailer.AsSpan(0, length).ToArray()),
seekable: false
);
using var raw = new AsyncOnlyStream(fragmented, disposeStream: false);
using var stream = new GZipChecksumValidationStream(input, raw, "test", null, null);
using var output = new MemoryStream();
if (length == 8)
{
await stream.CopyToAsync(output, 81920, CancellationToken.None);
Assert.Equal(payload, output.ToArray());
}
else
{
await Assert.ThrowsAsync<IncompleteArchiveException>(async () =>
await stream.CopyToAsync(output, 81920, CancellationToken.None)
);
}
}
}