diff --git a/src/SharpCompress/Common/GZip/GZipChecksumValidationStream.cs b/src/SharpCompress/Common/GZip/GZipChecksumValidationStream.cs index 6763ec11..76afaa1d 100644 --- a/src/SharpCompress/Common/GZip/GZipChecksumValidationStream.cs +++ b/src/SharpCompress/Common/GZip/GZipChecksumValidationStream.cs @@ -18,13 +18,15 @@ internal sealed class GZipChecksumValidationStream : Stream private uint _seed = Crc32Stream.DEFAULT_SEED; private uint _size; private bool _validated; + private readonly Func? _bufferedInput; internal GZipChecksumValidationStream( Stream source, Stream rawStream, string? entryName, uint? expectedCrc, - uint? expectedSize + uint? expectedSize, + Func? 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 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 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; } } diff --git a/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs b/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs index 7ff1f854..afc113f8 100644 --- a/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs +++ b/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs @@ -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; } } diff --git a/src/SharpCompress/Common/GZip/GZipFilePart.cs b/src/SharpCompress/Common/GZip/GZipFilePart.cs index 85de0611..19898ebf 100644 --- a/src/SharpCompress/Common/GZip/GZipFilePart.cs +++ b/src/SharpCompress/Common/GZip/GZipFilePart.cs @@ -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 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)); diff --git a/tests/SharpCompress.Test/GZip/GZipCrcExtractionTests.cs b/tests/SharpCompress.Test/GZip/GZipCrcExtractionTests.cs index 2e98c962..2d6b7be8 100644 --- a/tests/SharpCompress.Test/GZip/GZipCrcExtractionTests.cs +++ b/tests/SharpCompress.Test/GZip/GZipCrcExtractionTests.cs @@ -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(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() { diff --git a/tests/SharpCompress.Test/GZip/GZipTrailerReadTests.cs b/tests/SharpCompress.Test/GZip/GZipTrailerReadTests.cs new file mode 100644 index 00000000..3491b3f5 --- /dev/null +++ b/tests/SharpCompress.Test/GZip/GZipTrailerReadTests.cs @@ -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(async () => + await stream.CopyToAsync(output, 81920, CancellationToken.None) + ); + } + } +}