diff --git a/src/SharpCompress/Common/Entry.cs b/src/SharpCompress/Common/Entry.cs index 6c724c32..43095b71 100644 --- a/src/SharpCompress/Common/Entry.cs +++ b/src/SharpCompress/Common/Entry.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using SharpCompress.Common.Options; @@ -86,6 +87,17 @@ public abstract class Entry : IEntry internal virtual ChecksumDescriptor Checksum => default; + internal virtual Stream WrapWithChecksumValidation(Stream source, ExtractionOptions options) + { + var checksum = Checksum; + if (!checksum.IsAvailable) + { + return source; + } + + return new ChecksumValidationStream(source, checksum, Key); + } + /// /// Entry file attribute. /// diff --git a/src/SharpCompress/Common/GZip/GZipChecksumValidationStream.cs b/src/SharpCompress/Common/GZip/GZipChecksumValidationStream.cs new file mode 100644 index 00000000..512d7cc2 --- /dev/null +++ b/src/SharpCompress/Common/GZip/GZipChecksumValidationStream.cs @@ -0,0 +1,165 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Crypto; + +namespace SharpCompress.Common.GZip; + +internal sealed class GZipChecksumValidationStream : Stream +{ + private readonly Stream _source; + private readonly Stream _rawStream; + private readonly string _entryName; + private readonly uint? _expectedCrc; + private readonly uint? _expectedSize; + private readonly uint[] _crc32Table; + private uint _seed = Crc32Stream.DEFAULT_SEED; + private uint _size; + private bool _validated; + + internal GZipChecksumValidationStream( + Stream source, + Stream rawStream, + string? entryName, + uint? expectedCrc, + uint? expectedSize + ) + { + _source = source; + _rawStream = rawStream; + _entryName = string.IsNullOrEmpty(entryName) ? "Entry" : entryName!; + _expectedCrc = expectedCrc; + _expectedSize = expectedSize; + _crc32Table = Crc32Stream.InitializeTable(Crc32Stream.DEFAULT_POLYNOMIAL); + } + + public override bool CanRead => _source.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _source.Length; + + public override long Position + { + get => _source.Position; + set => throw new NotSupportedException(); + } + + public override void Flush() => _source.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => + _source.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) + { + var read = _source.Read(buffer, offset, count); + UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read); + return read; + } + +#if !LEGACY_DOTNET + public override int Read(Span buffer) + { + var read = _source.Read(buffer); + UpdateAndValidateAtEof(buffer[..read], read); + return read; + } +#endif + + public override int ReadByte() + { + var value = _source.ReadByte(); + if (value == -1) + { + Validate(); + } + else + { + _seed = Crc32Stream.CalculateCrc(_crc32Table, _seed, (byte)value); + _size++; + } + + return value; + } + + public override async Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) + { + var read = await _source + .ReadAsync(buffer, offset, count, cancellationToken) + .ConfigureAwait(false); + UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read); + return read; + } + +#if !LEGACY_DOTNET + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) + { + var read = await _source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + UpdateAndValidateAtEof(buffer.Span[..read], read); + return read; + } +#endif + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + private void UpdateAndValidateAtEof(ReadOnlySpan buffer, int read) + { + if (read > 0) + { + _seed = Crc32Stream.CalculateCrc(_crc32Table, _seed, buffer); + _size += unchecked((uint)read); + return; + } + + Validate(); + } + + private void Validate() + { + if (_validated) + { + return; + } + + _validated = true; + + var expectedCrc = _expectedCrc; + var expectedSize = _expectedSize; + if (!expectedCrc.HasValue || !expectedSize.HasValue) + { + Span trailer = stackalloc byte[8]; + _rawStream.ReadFully(trailer); + expectedCrc = BinaryPrimitives.ReadUInt32LittleEndian(trailer); + expectedSize = BinaryPrimitives.ReadUInt32LittleEndian(trailer[4..]); + } + + var actualCrc = ~_seed; + if (actualCrc != expectedCrc.Value) + { + throw new InvalidFormatException( + $"CRC mismatch for entry '{_entryName}'. Expected 0x{expectedCrc.Value:X8}, actual 0x{actualCrc:X8}." + ); + } + + if (_size != expectedSize.Value) + { + throw new InvalidFormatException( + $"Size mismatch for entry '{_entryName}'. Expected {expectedSize.Value}, actual {_size}." + ); + } + } +} diff --git a/src/SharpCompress/Common/GZip/GZipEntry.cs b/src/SharpCompress/Common/GZip/GZipEntry.cs index 635fc4cd..48f08d46 100644 --- a/src/SharpCompress/Common/GZip/GZipEntry.cs +++ b/src/SharpCompress/Common/GZip/GZipEntry.cs @@ -20,6 +20,9 @@ public partial class GZipEntry : Entry public override long Crc => _filePart?.Crc ?? 0; + internal override Stream WrapWithChecksumValidation(Stream source, ExtractionOptions options) => + _filePart?.WrapWithChecksumValidation(source, Key) ?? source; + public override string? Key => _filePart?.FilePartName; public override string? LinkTarget => null; diff --git a/src/SharpCompress/Common/GZip/GZipFilePart.cs b/src/SharpCompress/Common/GZip/GZipFilePart.cs index e8d89a5d..13a1c968 100644 --- a/src/SharpCompress/Common/GZip/GZipFilePart.cs +++ b/src/SharpCompress/Common/GZip/GZipFilePart.cs @@ -5,6 +5,7 @@ using System.IO; using SharpCompress.Common.Tar.Headers; using SharpCompress.Compressors; using SharpCompress.Compressors.Deflate; +using SharpCompress.IO; using SharpCompress.Providers; namespace SharpCompress.Common.GZip; @@ -48,7 +49,7 @@ internal sealed partial class GZipFilePart : FilePart ) : base(archiveEncoding) { - _stream = stream; + _stream = SharpCompressStream.Create(stream); _compressionProviders = compressionProviders; } @@ -66,6 +67,9 @@ internal sealed partial class GZipFilePart : FilePart return _compressionProviders.CreateDecompressStream(CompressionType.Deflate, _stream); } + internal Stream WrapWithChecksumValidation(Stream source, string? entryName) => + new GZipChecksumValidationStream(source, _stream, entryName, Crc, UncompressedSize); + internal override Stream GetRawStream() => _stream; private void ReadTrailer() diff --git a/src/SharpCompress/Common/IEntryExtensions.cs b/src/SharpCompress/Common/IEntryExtensions.cs index 84645910..492ad85d 100644 --- a/src/SharpCompress/Common/IEntryExtensions.cs +++ b/src/SharpCompress/Common/IEntryExtensions.cs @@ -11,13 +11,10 @@ internal static partial class IEntryExtensions ExtractionOptions? options ) { - if (options?.CheckCrc != false && entry is Entry typedEntry) + options ??= new ExtractionOptions(); + if (options.CheckCrc && entry is Entry typedEntry) { - var checksum = typedEntry.Checksum; - if (checksum.IsAvailable) - { - return new ChecksumValidationStream(source, checksum, entry.Key); - } + return typedEntry.WrapWithChecksumValidation(source, options); } return source; diff --git a/tests/SharpCompress.Test/BZip2/BZip2StreamTests.cs b/tests/SharpCompress.Test/BZip2/BZip2StreamTests.cs new file mode 100644 index 00000000..9bd7f44a --- /dev/null +++ b/tests/SharpCompress.Test/BZip2/BZip2StreamTests.cs @@ -0,0 +1,45 @@ +using System.IO; +using System.Text; +using SharpCompress.Common; +using SharpCompress.Compressors.BZip2; +using Xunit; + +namespace SharpCompress.Test.BZip2; + +public class BZip2StreamTests +{ + [Fact] + public void BZip2Stream_Throws_On_Corrupt_Checksum() + { + var compressed = Compress("BZip2 checksum validation test data."); + compressed[^5] ^= 1; + + using var stream = BZip2Stream.Create( + new MemoryStream(compressed), + SharpCompress.Compressors.CompressionMode.Decompress, + false + ); + using var output = new MemoryStream(); + + Assert.Throws(() => stream.CopyTo(output)); + } + + private static byte[] Compress(string value) + { + using var memoryStream = new MemoryStream(); + using ( + var bzip2Stream = BZip2Stream.Create( + memoryStream, + SharpCompress.Compressors.CompressionMode.Compress, + false, + leaveOpen: true + ) + ) + { + var bytes = Encoding.ASCII.GetBytes(value); + bzip2Stream.Write(bytes, 0, bytes.Length); + } + + return memoryStream.ToArray(); + } +} diff --git a/tests/SharpCompress.Test/GZip/GZipCrcExtractionTests.cs b/tests/SharpCompress.Test/GZip/GZipCrcExtractionTests.cs new file mode 100644 index 00000000..4f908b3c --- /dev/null +++ b/tests/SharpCompress.Test/GZip/GZipCrcExtractionTests.cs @@ -0,0 +1,90 @@ +using System; +using System.Buffers.Binary; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Archives.GZip; +using SharpCompress.Common; +using SharpCompress.Readers; +using SharpCompress.Readers.GZip; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test.GZip; + +public class GZipCrcExtractionTests : TestBase +{ + [Fact] + public void GZipArchive_WriteToFile_Throws_On_Crc_Mismatch() + { + using var stream = new MemoryStream(ReadCorruptedGZipTrailer(corruptCrc: true)); + using var archive = GZipArchive.OpenArchive(stream); + var entry = archive.Entries.Single(); + var destination = Path.Combine(SCRATCH_FILES_PATH, Guid.NewGuid().ToString()); + + Assert.Throws(() => entry.WriteToFile(destination)); + } + + [Fact] + public void GZipArchive_WriteToFile_Throws_On_Size_Mismatch() + { + using var stream = new MemoryStream(ReadCorruptedGZipTrailer(corruptCrc: false)); + using var archive = GZipArchive.OpenArchive(stream); + var entry = archive.Entries.Single(); + var destination = Path.Combine(SCRATCH_FILES_PATH, Guid.NewGuid().ToString()); + + Assert.Throws(() => entry.WriteToFile(destination)); + } + + [Fact] + public void GZipReader_WriteEntryToFile_Throws_On_NonSeekable_Crc_Mismatch() + { + using var stream = new MemoryStream(ReadCorruptedGZipTrailer(corruptCrc: true)); + using var nonSeekableStream = new ForwardOnlyStream(stream); + using var reader = GZipReader.OpenReader(nonSeekableStream); + var destination = Path.Combine(SCRATCH_FILES_PATH, Guid.NewGuid().ToString()); + + Assert.True(reader.MoveToNextEntry()); + Assert.Throws(() => reader.WriteEntryToFile(destination)); + } + + [Fact] + public void GZipArchive_WriteToFile_Skips_Trailer_Validation_When_CheckCrc_Is_False() + { + using var stream = new MemoryStream(ReadCorruptedGZipTrailer(corruptCrc: true)); + using var archive = GZipArchive.OpenArchive(stream); + var entry = archive.Entries.Single(); + var destination = Path.Combine(SCRATCH_FILES_PATH, Guid.NewGuid().ToString()); + + entry.WriteToFile(destination, new ExtractionOptions { CheckCrc = false }); + + Assert.Equal( + new FileInfo(Path.Combine(TEST_ARCHIVES_PATH, "Tar.tar")).Length, + new FileInfo(destination).Length + ); + } + + [Fact] + public async Task GZipArchive_WriteToFileAsync_Throws_On_Crc_Mismatch() + { + await using var stream = new MemoryStream(ReadCorruptedGZipTrailer(corruptCrc: true)); + await using var archive = await GZipArchive.OpenAsyncArchive(stream); + var entry = await archive.EntriesAsync.SingleAsync(); + var destination = Path.Combine(SCRATCH_FILES_PATH, Guid.NewGuid().ToString()); + + await Assert.ThrowsAsync(async () => + await entry.WriteToFileAsync(destination) + ); + } + + private static byte[] ReadCorruptedGZipTrailer(bool corruptCrc) + { + var bytes = File.ReadAllBytes(Path.Combine(TEST_ARCHIVES_PATH, "Tar.tar.gz")); + var trailer = bytes.AsSpan(bytes.Length - 8); + var offset = corruptCrc ? 0 : 4; + var value = BinaryPrimitives.ReadUInt32LittleEndian(trailer[offset..]); + BinaryPrimitives.WriteUInt32LittleEndian(trailer[offset..], value + 1); + return bytes; + } +} diff --git a/tests/SharpCompress.Test/Xz/XZStreamTests.cs b/tests/SharpCompress.Test/Xz/XZStreamTests.cs index 80ad5dc3..00cd2f27 100644 --- a/tests/SharpCompress.Test/Xz/XZStreamTests.cs +++ b/tests/SharpCompress.Test/Xz/XZStreamTests.cs @@ -1,4 +1,5 @@ using System.IO; +using SharpCompress.Common; using SharpCompress.Compressors.Xz; using SharpCompress.IO; using SharpCompress.Test.Mocks; @@ -54,4 +55,15 @@ public class XzStreamTests : XzTestsBase var uncompressed = sr.ReadToEnd(); Assert.Equal(OriginalEmpty, uncompressed); } + + [Fact] + public void Throws_On_Corrupt_Block_Check() + { + var compressed = (byte[])Compressed.Clone(); + compressed[compressed.Length - 29] ^= 1; + using var xz = new XZStream(new MemoryStream(compressed)); + using var output = new MemoryStream(); + + Assert.Throws(() => xz.CopyTo(output)); + } }