From 2866bfbd540a794a72d780cb16273de299b09662 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 15 Jun 2026 09:17:05 +0100 Subject: [PATCH] First pass with zip --- docs/API.md | 11 +- docs/USAGE.md | 3 +- .../Archives/IArchiveEntryExtensions.cs | 31 ++- .../Common/ChecksumDescriptor.cs | 12 ++ .../Common/ChecksumValidationStream.cs | 140 +++++++++++++ src/SharpCompress/Common/Entry.cs | 2 + src/SharpCompress/Common/ExtractionOptions.cs | 9 + src/SharpCompress/Common/IEntryExtensions.cs | 18 ++ .../Zip/Headers/DirectoryEntryHeader.Async.cs | 1 + .../Zip/Headers/DirectoryEntryHeader.cs | 1 + .../Zip/Headers/LocalEntryHeader.Async.cs | 1 + .../Common/Zip/Headers/LocalEntryHeader.cs | 1 + .../Common/Zip/Headers/ZipFileEntry.cs | 2 + .../Common/Zip/SeekableZipHeaderFactory.cs | 1 + .../Zip/StreamingZipHeaderFactory.Async.cs | 3 + .../Common/Zip/StreamingZipHeaderFactory.cs | 3 + src/SharpCompress/Common/Zip/ZipEntry.cs | 43 ++++ .../Common/Zip/ZipHeaderFactory.Async.cs | 1 + .../Common/Zip/ZipHeaderFactory.cs | 1 + src/SharpCompress/Crypto/Crc32Stream.cs | 6 +- .../Readers/IAsyncReaderExtensions.cs | 27 ++- .../Readers/IReaderExtensions.cs | 25 ++- .../OptionsUsabilityTests.cs | 1 + .../Zip/ZipCrcExtractionTests.cs | 196 ++++++++++++++++++ 24 files changed, 509 insertions(+), 30 deletions(-) create mode 100644 src/SharpCompress/Common/ChecksumDescriptor.cs create mode 100644 src/SharpCompress/Common/ChecksumValidationStream.cs create mode 100644 tests/SharpCompress.Test/Zip/ZipCrcExtractionTests.cs diff --git a/docs/API.md b/docs/API.md index 19edd6ed..79745918 100644 --- a/docs/API.md +++ b/docs/API.md @@ -320,7 +320,11 @@ var flatOptions = ExtractionOptions.FlatExtract; // No directory structure var metadataOptions = ExtractionOptions.PreserveMetadata; // Keep timestamps and attributes // Tune extraction copy buffering -var extractionOptions = new ExtractionOptions { BufferSize = 131072 }; +var extractionOptions = new ExtractionOptions +{ + BufferSize = 131072, + CheckCrc = true, // Default: validate entry checksums when archive metadata provides one +}; // Factory defaults: // - file path / FileInfo overloads use LeaveStreamOpen = false @@ -429,7 +433,8 @@ var options = new ExtractionOptions { ExtractFullPath = true, // Recreate directory structure Overwrite = true, // Overwrite existing files - PreserveFileTime = true // Keep original timestamps + PreserveFileTime = true, // Keep original timestamps + CheckCrc = true // Validate payload checksums when available }; using (var archive = ZipArchive.OpenArchive("file.zip")) @@ -438,6 +443,8 @@ using (var archive = ZipArchive.OpenArchive("file.zip")) } ``` +`CheckCrc` validates archive-level payload checksums when the format stores reliable metadata, such as ZIP CRC32 values. Formats without payload checksums skip this validation. Decompressor integrity checks that are required to decode a stream may still fail even when `CheckCrc` is disabled. + ### Options matrix ```text diff --git a/docs/USAGE.md b/docs/USAGE.md index 72356614..9666d43f 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -105,6 +105,7 @@ using (var archive = RarArchive.OpenArchive("Test.rar", ReaderOptions.ForFilePat ExtractFullPath = true, Overwrite = true, BufferSize = 131072, + CheckCrc = true, // Default: validate payload checksums when available } ); } @@ -141,7 +142,7 @@ using (var archive = RarArchive.OpenArchive("archive.rar", { archive.WriteToDirectory( @"D:\output", - new ExtractionOptions { ExtractFullPath = true, Overwrite = true } + new ExtractionOptions { ExtractFullPath = true, Overwrite = true, CheckCrc = true } ); } ``` diff --git a/src/SharpCompress/Archives/IArchiveEntryExtensions.cs b/src/SharpCompress/Archives/IArchiveEntryExtensions.cs index 0de4b21c..a9c3a054 100644 --- a/src/SharpCompress/Archives/IArchiveEntryExtensions.cs +++ b/src/SharpCompress/Archives/IArchiveEntryExtensions.cs @@ -18,11 +18,12 @@ public static class IArchiveEntryExtensions /// The stream to write the entry content to. /// Optional progress reporter for tracking extraction progress. public void WriteTo(Stream streamToWriteTo, IProgress? progress = null) => - archiveEntry.WriteTo(streamToWriteTo, null, progress); + archiveEntry.WriteTo(streamToWriteTo, null, progress: progress); private void WriteTo( Stream streamToWriteTo, int? bufferSize, + ExtractionOptions? options = null, IProgress? progress = null ) { @@ -32,7 +33,10 @@ public static class IArchiveEntryExtensions } using var entryStream = archiveEntry.OpenEntryStream(); - var sourceStream = WrapWithProgress(entryStream, archiveEntry, progress); + var checkedStream = options is null + ? entryStream + : IEntryExtensions.WrapWithChecksumValidation(archiveEntry, entryStream, options); + var sourceStream = WrapWithProgress(checkedStream, archiveEntry, progress); sourceStream.CopyTo(streamToWriteTo, bufferSize ?? Constants.BufferSize); } @@ -49,13 +53,19 @@ public static class IArchiveEntryExtensions ) { await archiveEntry - .WriteToAsync(streamToWriteTo, Constants.BufferSize, progress, cancellationToken) + .WriteToAsync( + streamToWriteTo, + Constants.BufferSize, + progress: progress, + cancellationToken: cancellationToken + ) .ConfigureAwait(false); } private async ValueTask WriteToAsync( Stream streamToWriteTo, int? bufferSize, + ExtractionOptions? options = null, IProgress? progress = null, CancellationToken cancellationToken = default ) @@ -74,7 +84,10 @@ public static class IArchiveEntryExtensions .OpenEntryStreamAsync(cancellationToken) .ConfigureAwait(false); #endif - var sourceStream = WrapWithProgress(entryStream, archiveEntry, progress); + var checkedStream = options is null + ? entryStream + : IEntryExtensions.WrapWithChecksumValidation(archiveEntry, entryStream, options); + var sourceStream = WrapWithProgress(checkedStream, archiveEntry, progress); await sourceStream .CopyToAsync(streamToWriteTo, bufferSize ?? Constants.BufferSize, cancellationToken) .ConfigureAwait(false); @@ -154,13 +167,14 @@ public static class IArchiveEntryExtensions /// public void WriteToFile(string destinationFileName, ExtractionOptions? options = null) { + options ??= new ExtractionOptions(); entry.WriteEntryToFile( destinationFileName, options, (x, fm) => { using var fs = File.Open(x, fm); - entry.WriteTo(fs, options?.BufferSize ?? Constants.BufferSize); + entry.WriteTo(fs, options?.BufferSize ?? Constants.BufferSize, options, null); } ); } @@ -172,7 +186,9 @@ public static class IArchiveEntryExtensions string destinationFileName, ExtractionOptions? options = null, CancellationToken cancellationToken = default - ) => + ) + { + options ??= new ExtractionOptions(); await entry .WriteEntryToFileAsync( destinationFileName, @@ -181,11 +197,12 @@ public static class IArchiveEntryExtensions { using var fs = File.Open(x, fm); await entry - .WriteToAsync(fs, options?.BufferSize, null, ct) + .WriteToAsync(fs, options.BufferSize, options, null, ct) .ConfigureAwait(false); }, cancellationToken ) .ConfigureAwait(false); + } } } diff --git a/src/SharpCompress/Common/ChecksumDescriptor.cs b/src/SharpCompress/Common/ChecksumDescriptor.cs new file mode 100644 index 00000000..2dbb6a45 --- /dev/null +++ b/src/SharpCompress/Common/ChecksumDescriptor.cs @@ -0,0 +1,12 @@ +namespace SharpCompress.Common; + +internal enum ChecksumKind +{ + Crc32, +} + +internal readonly record struct ChecksumDescriptor( + ChecksumKind Kind, + long ExpectedValue, + bool IsAvailable +); diff --git a/src/SharpCompress/Common/ChecksumValidationStream.cs b/src/SharpCompress/Common/ChecksumValidationStream.cs new file mode 100644 index 00000000..fdf4ba42 --- /dev/null +++ b/src/SharpCompress/Common/ChecksumValidationStream.cs @@ -0,0 +1,140 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Crypto; + +namespace SharpCompress.Common; + +internal sealed class ChecksumValidationStream : Stream +{ + private readonly Stream _stream; + private readonly ChecksumDescriptor _checksum; + private readonly string _entryName; + private readonly uint[] _crc32Table; + private uint _seed = Crc32Stream.DEFAULT_SEED; + private bool _validated; + + internal ChecksumValidationStream(Stream stream, ChecksumDescriptor checksum, string? entryName) + { + _stream = stream; + _checksum = checksum; + _entryName = string.IsNullOrEmpty(entryName) ? "Entry" : entryName!; + _crc32Table = Crc32Stream.InitializeTable(Crc32Stream.DEFAULT_POLYNOMIAL); + } + + public override bool CanRead => _stream.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => _stream.Length; + + public override long Position + { + get => _stream.Position; + set => throw new NotSupportedException(); + } + + public override void Flush() => _stream.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => + _stream.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) + { + var read = _stream.Read(buffer, offset, count); + UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read); + return read; + } + +#if !LEGACY_DOTNET + public override int Read(Span buffer) + { + var read = _stream.Read(buffer); + UpdateAndValidateAtEof(buffer[..read], read); + return read; + } +#endif + + public override int ReadByte() + { + var value = _stream.ReadByte(); + if (value == -1) + { + Validate(); + } + else + { + _seed = Crc32Stream.CalculateCrc(_crc32Table, _seed, (byte)value); + } + + return value; + } + + public override async Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) + { + var read = await _stream + .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 _stream.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); + return; + } + + Validate(); + } + + private void Validate() + { + if (_validated) + { + return; + } + + _validated = true; + + if (_checksum.Kind != ChecksumKind.Crc32) + { + return; + } + + var actual = ~_seed; + var expected = unchecked((uint)_checksum.ExpectedValue); + if (actual != expected) + { + throw new InvalidFormatException( + $"CRC mismatch for entry '{_entryName}'. Expected 0x{expected:X8}, actual 0x{actual:X8}." + ); + } + } +} diff --git a/src/SharpCompress/Common/Entry.cs b/src/SharpCompress/Common/Entry.cs index 1942ba46..6c724c32 100644 --- a/src/SharpCompress/Common/Entry.cs +++ b/src/SharpCompress/Common/Entry.cs @@ -84,6 +84,8 @@ public abstract class Entry : IEntry internal virtual void Close() { } + internal virtual ChecksumDescriptor Checksum => default; + /// /// Entry file attribute. /// diff --git a/src/SharpCompress/Common/ExtractionOptions.cs b/src/SharpCompress/Common/ExtractionOptions.cs index b99658f8..04b33416 100644 --- a/src/SharpCompress/Common/ExtractionOptions.cs +++ b/src/SharpCompress/Common/ExtractionOptions.cs @@ -43,6 +43,15 @@ public sealed record ExtractionOptions : IExtractionOptions /// public int BufferSize { get; set; } = Constants.BufferSize; + /// + /// Validate archive entry checksums during extraction when checksum metadata is available. + /// + /// + /// Formats without payload checksums skip this validation. Compression-format integrity + /// checks that are required to decode data may still fail even when this is disabled. + /// + public bool CheckCrc { get; set; } = true; + /// /// Delegate for writing symbolic links to disk. /// The first parameter is the source path (where the symlink is created). diff --git a/src/SharpCompress/Common/IEntryExtensions.cs b/src/SharpCompress/Common/IEntryExtensions.cs index d3158f03..84645910 100644 --- a/src/SharpCompress/Common/IEntryExtensions.cs +++ b/src/SharpCompress/Common/IEntryExtensions.cs @@ -5,6 +5,24 @@ namespace SharpCompress.Common; internal static partial class IEntryExtensions { + internal static Stream WrapWithChecksumValidation( + IEntry entry, + Stream source, + ExtractionOptions? options + ) + { + if (options?.CheckCrc != false && entry is Entry typedEntry) + { + var checksum = typedEntry.Checksum; + if (checksum.IsAvailable) + { + return new ChecksumValidationStream(source, checksum, entry.Key); + } + } + + return source; + } + extension(IEntry entry) { /// diff --git a/src/SharpCompress/Common/Zip/Headers/DirectoryEntryHeader.Async.cs b/src/SharpCompress/Common/Zip/Headers/DirectoryEntryHeader.Async.cs index c09cac91..af978f64 100644 --- a/src/SharpCompress/Common/Zip/Headers/DirectoryEntryHeader.Async.cs +++ b/src/SharpCompress/Common/Zip/Headers/DirectoryEntryHeader.Async.cs @@ -22,6 +22,7 @@ internal partial class DirectoryEntryHeader .ReadUInt16Async() .ConfigureAwait(false); Crc = await reader.ReadUInt32Async().ConfigureAwait(false); + IsCrcAvailable = true; CompressedSize = await reader.ReadUInt32Async().ConfigureAwait(false); UncompressedSize = await reader.ReadUInt32Async().ConfigureAwait(false); var nameLength = await reader.ReadUInt16Async().ConfigureAwait(false); diff --git a/src/SharpCompress/Common/Zip/Headers/DirectoryEntryHeader.cs b/src/SharpCompress/Common/Zip/Headers/DirectoryEntryHeader.cs index e4e0f331..f41c6047 100644 --- a/src/SharpCompress/Common/Zip/Headers/DirectoryEntryHeader.cs +++ b/src/SharpCompress/Common/Zip/Headers/DirectoryEntryHeader.cs @@ -17,6 +17,7 @@ internal partial class DirectoryEntryHeader : ZipFileEntry OriginalLastModifiedTime = LastModifiedTime = reader.ReadUInt16(); OriginalLastModifiedDate = LastModifiedDate = reader.ReadUInt16(); Crc = reader.ReadUInt32(); + IsCrcAvailable = true; CompressedSize = reader.ReadUInt32(); UncompressedSize = reader.ReadUInt32(); var nameLength = reader.ReadUInt16(); diff --git a/src/SharpCompress/Common/Zip/Headers/LocalEntryHeader.Async.cs b/src/SharpCompress/Common/Zip/Headers/LocalEntryHeader.Async.cs index 950494df..2e96c942 100644 --- a/src/SharpCompress/Common/Zip/Headers/LocalEntryHeader.Async.cs +++ b/src/SharpCompress/Common/Zip/Headers/LocalEntryHeader.Async.cs @@ -20,6 +20,7 @@ internal partial class LocalEntryHeader .ReadUInt16Async() .ConfigureAwait(false); Crc = await reader.ReadUInt32Async().ConfigureAwait(false); + IsCrcAvailable = !Flags.HasFlag(HeaderFlags.UsePostDataDescriptor); CompressedSize = await reader.ReadUInt32Async().ConfigureAwait(false); UncompressedSize = await reader.ReadUInt32Async().ConfigureAwait(false); var nameLength = await reader.ReadUInt16Async().ConfigureAwait(false); diff --git a/src/SharpCompress/Common/Zip/Headers/LocalEntryHeader.cs b/src/SharpCompress/Common/Zip/Headers/LocalEntryHeader.cs index d9490137..9d4512b1 100644 --- a/src/SharpCompress/Common/Zip/Headers/LocalEntryHeader.cs +++ b/src/SharpCompress/Common/Zip/Headers/LocalEntryHeader.cs @@ -16,6 +16,7 @@ internal partial class LocalEntryHeader : ZipFileEntry OriginalLastModifiedTime = LastModifiedTime = reader.ReadUInt16(); OriginalLastModifiedDate = LastModifiedDate = reader.ReadUInt16(); Crc = reader.ReadUInt32(); + IsCrcAvailable = !Flags.HasFlag(HeaderFlags.UsePostDataDescriptor); CompressedSize = reader.ReadUInt32(); UncompressedSize = reader.ReadUInt32(); var nameLength = reader.ReadUInt16(); diff --git a/src/SharpCompress/Common/Zip/Headers/ZipFileEntry.cs b/src/SharpCompress/Common/Zip/Headers/ZipFileEntry.cs index b62b6a67..e7f0b042 100644 --- a/src/SharpCompress/Common/Zip/Headers/ZipFileEntry.cs +++ b/src/SharpCompress/Common/Zip/Headers/ZipFileEntry.cs @@ -80,6 +80,8 @@ internal abstract partial class ZipFileEntry(ZipHeaderType type, IArchiveEncodin internal uint Crc { get; set; } + internal bool IsCrcAvailable { get; set; } + protected void LoadExtra(byte[] extra) { for (var i = 0; i < extra.Length; ) diff --git a/src/SharpCompress/Common/Zip/SeekableZipHeaderFactory.cs b/src/SharpCompress/Common/Zip/SeekableZipHeaderFactory.cs index 22304b9d..7a88a480 100644 --- a/src/SharpCompress/Common/Zip/SeekableZipHeaderFactory.cs +++ b/src/SharpCompress/Common/Zip/SeekableZipHeaderFactory.cs @@ -159,6 +159,7 @@ internal sealed partial class SeekableZipHeaderFactory : ZipHeaderFactory if (FlagUtility.HasFlag(localEntryHeader.Flags, HeaderFlags.UsePostDataDescriptor)) { localEntryHeader.Crc = directoryEntryHeader.Crc; + localEntryHeader.IsCrcAvailable = true; localEntryHeader.CompressedSize = directoryEntryHeader.CompressedSize; localEntryHeader.UncompressedSize = directoryEntryHeader.UncompressedSize; } diff --git a/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.Async.cs b/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.Async.cs index 80ff16b4..4e3bcc3a 100644 --- a/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.Async.cs @@ -124,6 +124,7 @@ internal sealed partial class StreamingZipHeaderFactory .ConfigureAwait(false); } lastEntryHeader.Crc = crc; + lastEntryHeader.IsCrcAvailable = true; //attempt 32bit read ulong compressedSize = await _reader @@ -205,6 +206,7 @@ internal sealed partial class StreamingZipHeaderFactory .ConfigureAwait(false); } lastEntryHeader.Crc = crc; + lastEntryHeader.IsCrcAvailable = true; // The DataDescriptor can be either 64bit or 32bit var compressedSize = await _reader @@ -283,6 +285,7 @@ internal sealed partial class StreamingZipHeaderFactory localHeader.UncompressedSize = directoryHeader.Size; localHeader.CompressedSize = directoryHeader.CompressedSize; localHeader.Crc = (uint)directoryHeader.Crc; + localHeader.IsCrcAvailable = true; } // If we have CompressedSize, there is data to be read diff --git a/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.cs b/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.cs index 7dcc4c54..c955afb0 100644 --- a/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.cs +++ b/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.cs @@ -59,6 +59,7 @@ internal partial class StreamingZipHeaderFactory : ZipHeaderFactory crc = reader.ReadUInt32(); } _lastEntryHeader.Crc = crc; + _lastEntryHeader.IsCrcAvailable = true; //attempt 32bit read ulong compSize = reader.ReadUInt32(); @@ -121,6 +122,7 @@ internal partial class StreamingZipHeaderFactory : ZipHeaderFactory crc = reader.ReadUInt32(); } _lastEntryHeader.Crc = crc; + _lastEntryHeader.IsCrcAvailable = true; // The DataDescriptor can be either 64bit or 32bit var compressed_size = reader.ReadUInt32(); @@ -188,6 +190,7 @@ internal partial class StreamingZipHeaderFactory : ZipHeaderFactory local_header.UncompressedSize = dir_header.Size; local_header.CompressedSize = dir_header.CompressedSize; local_header.Crc = (uint)dir_header.Crc; + local_header.IsCrcAvailable = true; } // If we have CompressedSize, there is data to be read diff --git a/src/SharpCompress/Common/Zip/ZipEntry.cs b/src/SharpCompress/Common/Zip/ZipEntry.cs index 753c6dc7..bfe1a50d 100644 --- a/src/SharpCompress/Common/Zip/ZipEntry.cs +++ b/src/SharpCompress/Common/Zip/ZipEntry.cs @@ -89,6 +89,49 @@ public class ZipEntry : Entry public override long Crc => _filePart?.Header.Crc ?? 0; + internal override ChecksumDescriptor Checksum + { + get + { + if ( + _filePart is null + || IsDirectory + || !_filePart.Header.IsCrcAvailable + || !IsReliableCrcMetadata(_filePart.Header) + ) + { + return default; + } + + return new ChecksumDescriptor( + ChecksumKind.Crc32, + _filePart.Header.Crc, + IsAvailable: true + ); + } + } + + private static bool IsReliableCrcMetadata(ZipFileEntry header) + { + if (header.CompressionMethod != ZipCompressionMethod.WinzipAes) + { + return true; + } + + var aesExtraData = header.Extra.FirstOrDefault(x => x.Type == ExtraDataType.WinZipAes); + if (aesExtraData is null || aesExtraData.DataBytes.Length < MinimumWinZipAesExtraDataLength) + { + return false; + } + + var vendorVersion = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian( + aesExtraData.DataBytes + ); + + // WinZip AES AE-2 stores a zero CRC field by design and relies on AES authentication. + return vendorVersion == 0x0001; + } + public override string? Key => _filePart?.Header.Name; public override string? LinkTarget => null; diff --git a/src/SharpCompress/Common/Zip/ZipHeaderFactory.Async.cs b/src/SharpCompress/Common/Zip/ZipHeaderFactory.Async.cs index c31e24a6..bc5944b4 100644 --- a/src/SharpCompress/Common/Zip/ZipHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Zip/ZipHeaderFactory.Async.cs @@ -44,6 +44,7 @@ internal partial class ZipHeaderFactory ) { _lastEntryHeader.Crc = await reader.ReadUInt32Async().ConfigureAwait(false); + _lastEntryHeader.IsCrcAvailable = true; _lastEntryHeader.CompressedSize = zip64 ? (long)await reader.ReadUInt64Async().ConfigureAwait(false) : await reader.ReadUInt32Async().ConfigureAwait(false); diff --git a/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs b/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs index 8d0ffdb4..e1ebb8c1 100644 --- a/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs +++ b/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs @@ -66,6 +66,7 @@ internal partial class ZipHeaderFactory ) { _lastEntryHeader.Crc = reader.ReadUInt32(); + _lastEntryHeader.IsCrcAvailable = true; _lastEntryHeader.CompressedSize = zip64 ? (long)reader.ReadUInt64() : reader.ReadUInt32(); diff --git a/src/SharpCompress/Crypto/Crc32Stream.cs b/src/SharpCompress/Crypto/Crc32Stream.cs index b3294831..67dcb913 100644 --- a/src/SharpCompress/Crypto/Crc32Stream.cs +++ b/src/SharpCompress/Crypto/Crc32Stream.cs @@ -119,7 +119,7 @@ public sealed class Crc32Stream : Stream public static uint Compute(uint polynomial, uint seed, ReadOnlySpan buffer) => ~CalculateCrc(InitializeTable(polynomial), seed, buffer); - private static uint[] InitializeTable(uint polynomial) + internal static uint[] InitializeTable(uint polynomial) { if (polynomial == DEFAULT_POLYNOMIAL && _defaultTable != null) { @@ -153,7 +153,7 @@ public sealed class Crc32Stream : Stream return createTable; } - private static uint CalculateCrc(uint[] table, uint crc, ReadOnlySpan buffer) + internal static uint CalculateCrc(uint[] table, uint crc, ReadOnlySpan buffer) { unchecked { @@ -165,6 +165,6 @@ public sealed class Crc32Stream : Stream return crc; } - private static uint CalculateCrc(uint[] table, uint crc, byte b) => + internal static uint CalculateCrc(uint[] table, uint crc, byte b) => (crc >> 8) ^ table[(crc ^ b) & 0xFF]; } diff --git a/src/SharpCompress/Readers/IAsyncReaderExtensions.cs b/src/SharpCompress/Readers/IAsyncReaderExtensions.cs index a982982c..7515b257 100644 --- a/src/SharpCompress/Readers/IAsyncReaderExtensions.cs +++ b/src/SharpCompress/Readers/IAsyncReaderExtensions.cs @@ -36,7 +36,9 @@ public static class IAsyncReaderExtensions string destinationFileName, ExtractionOptions? options = null, CancellationToken cancellationToken = default - ) => + ) + { + options ??= new ExtractionOptions(); await reader .Entry.WriteEntryToFileAsync( destinationFileName, @@ -44,12 +46,12 @@ public static class IAsyncReaderExtensions async (x, fm, ct) => { using var fs = File.Open(x, fm); - await CopyEntryToAsync(reader, fs, options?.BufferSize, ct) - .ConfigureAwait(false); + await CopyEntryToAsync(reader, fs, options, ct).ConfigureAwait(false); }, cancellationToken ) .ConfigureAwait(false); + } /// /// Extract all remaining unread entries to specific directory asynchronously, retaining filename @@ -72,7 +74,9 @@ public static class IAsyncReaderExtensions string destinationFileName, ExtractionOptions? options = null, CancellationToken cancellationToken = default - ) => + ) + { + options ??= new ExtractionOptions(); await reader .Entry.WriteEntryToFileAsync( destinationFileName, @@ -80,12 +84,12 @@ public static class IAsyncReaderExtensions async (x, fm, ct) => { using var fs = File.Open(x, fm); - await CopyEntryToAsync(reader, fs, options?.BufferSize, ct) - .ConfigureAwait(false); + await CopyEntryToAsync(reader, fs, options, ct).ConfigureAwait(false); }, cancellationToken ) .ConfigureAwait(false); + } public async ValueTask WriteEntryToAsync( FileInfo destinationFileInfo, @@ -100,7 +104,7 @@ public static class IAsyncReaderExtensions private static async ValueTask CopyEntryToAsync( IAsyncReader reader, Stream writableStream, - int? bufferSize, + ExtractionOptions options, CancellationToken cancellationToken ) { @@ -113,9 +117,14 @@ public static class IAsyncReaderExtensions .OpenEntryStreamAsync(cancellationToken) .ConfigureAwait(false); #endif - var sourceStream = WrapWithProgress(entryStream, reader.Entry); + var checkedStream = IEntryExtensions.WrapWithChecksumValidation( + reader.Entry, + entryStream, + options + ); + var sourceStream = WrapWithProgress(checkedStream, reader.Entry); await sourceStream - .CopyToAsync(writableStream, bufferSize ?? Constants.BufferSize, cancellationToken) + .CopyToAsync(writableStream, options.BufferSize, cancellationToken) .ConfigureAwait(false); } diff --git a/src/SharpCompress/Readers/IReaderExtensions.cs b/src/SharpCompress/Readers/IReaderExtensions.cs index 93c391d2..8eb97193 100644 --- a/src/SharpCompress/Readers/IReaderExtensions.cs +++ b/src/SharpCompress/Readers/IReaderExtensions.cs @@ -51,26 +51,35 @@ public static class IReaderExtensions /// /// Extract to specific file /// - public void WriteEntryToFile( - string destinationFileName, - ExtractionOptions? options = null - ) => + public void WriteEntryToFile(string destinationFileName, ExtractionOptions? options = null) + { + options ??= new ExtractionOptions(); reader.Entry.WriteEntryToFile( destinationFileName, options, (x, fm) => { using var fs = File.Open(x, fm); - CopyEntryTo(reader, fs, options?.BufferSize ?? Constants.BufferSize); + CopyEntryTo(reader, fs, options ?? new ExtractionOptions()); } ); + } } - private static void CopyEntryTo(IReader reader, Stream writableStream, int bufferSize) + private static void CopyEntryTo( + IReader reader, + Stream writableStream, + ExtractionOptions options + ) { using var entryStream = reader.OpenEntryStream(); - var sourceStream = WrapWithProgress(entryStream, reader.Entry); - sourceStream.CopyTo(writableStream, bufferSize); + var checkedStream = IEntryExtensions.WrapWithChecksumValidation( + reader.Entry, + entryStream, + options + ); + var sourceStream = WrapWithProgress(checkedStream, reader.Entry); + sourceStream.CopyTo(writableStream, options.BufferSize); } private static Stream WrapWithProgress(Stream source, IEntry entry) diff --git a/tests/SharpCompress.Test/OptionsUsabilityTests.cs b/tests/SharpCompress.Test/OptionsUsabilityTests.cs index 8b08b265..1902e4ab 100644 --- a/tests/SharpCompress.Test/OptionsUsabilityTests.cs +++ b/tests/SharpCompress.Test/OptionsUsabilityTests.cs @@ -306,6 +306,7 @@ public class OptionsUsabilityTests : TestBase Assert.True(preserveMetadata.PreserveAttributes); Assert.Equal(Constants.BufferSize, new ExtractionOptions().BufferSize); + Assert.True(new ExtractionOptions().CheckCrc); } [Fact] diff --git a/tests/SharpCompress.Test/Zip/ZipCrcExtractionTests.cs b/tests/SharpCompress.Test/Zip/ZipCrcExtractionTests.cs new file mode 100644 index 00000000..14a55fc6 --- /dev/null +++ b/tests/SharpCompress.Test/Zip/ZipCrcExtractionTests.cs @@ -0,0 +1,196 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Readers; +using SharpCompress.Writers; +using SharpCompress.Writers.Zip; +using Xunit; + +namespace SharpCompress.Test.Zip; + +public class ZipCrcExtractionTests : ArchiveTests +{ + private const string EntryName = "crc.txt"; + private static readonly byte[] EntryData = Encoding.UTF8.GetBytes("crc validation payload"); + + [Fact] + public void Zip_Archive_WriteToFile_Throws_On_Crc_Mismatch() + { + using var zipStream = CreateZipWithInvalidCrc(useDataDescriptor: false); + using var archive = ZipArchive.OpenArchive(zipStream); + var entry = archive.Entries.Single(e => !e.IsDirectory); + var destination = Path.Combine(SCRATCH_FILES_PATH, "zip-crc-mismatch.txt"); + + var exception = Assert.Throws(() => entry.WriteToFile(destination)); + + Assert.Contains(EntryName, exception.Message); + } + + [Fact] + public void Zip_Archive_WriteToFile_Skips_Crc_Mismatch_When_Disabled() + { + using var zipStream = CreateZipWithInvalidCrc(useDataDescriptor: false); + using var archive = ZipArchive.OpenArchive(zipStream); + var entry = archive.Entries.Single(e => !e.IsDirectory); + var destination = Path.Combine(SCRATCH_FILES_PATH, "zip-crc-disabled.txt"); + + entry.WriteToFile(destination, new ExtractionOptions { CheckCrc = false }); + + Assert.Equal(EntryData, File.ReadAllBytes(destination)); + } + + [Fact] + public void Zip_Reader_WriteEntryToFile_Throws_On_Crc_Mismatch() + { + using var zipStream = CreateZipWithInvalidCrc(useDataDescriptor: false); + using var reader = ReaderFactory.OpenReader(zipStream); + var destination = Path.Combine(SCRATCH_FILES_PATH, "zip-reader-crc-mismatch.txt"); + + Assert.True(reader.MoveToNextEntry()); + var exception = Assert.Throws(() => + reader.WriteEntryToFile(destination) + ); + + Assert.Contains(EntryName, exception.Message); + } + + [Fact] + public void Zip_Reader_WriteEntryToFile_Skips_Crc_Mismatch_When_Disabled() + { + using var zipStream = CreateZipWithInvalidCrc(useDataDescriptor: false); + using var reader = ReaderFactory.OpenReader(zipStream); + var destination = Path.Combine(SCRATCH_FILES_PATH, "zip-reader-crc-disabled.txt"); + + Assert.True(reader.MoveToNextEntry()); + reader.WriteEntryToFile(destination, new ExtractionOptions { CheckCrc = false }); + + Assert.Equal(EntryData, File.ReadAllBytes(destination)); + } + + [Fact] + public async Task Zip_Archive_WriteToFileAsync_Throws_On_Crc_Mismatch() + { + using var zipStream = CreateZipWithInvalidCrc(useDataDescriptor: false); + using var archive = ZipArchive.OpenArchive(zipStream); + var entry = archive.Entries.Single(e => !e.IsDirectory); + var destination = Path.Combine(SCRATCH_FILES_PATH, "zip-crc-mismatch-async.txt"); + + var exception = await Assert.ThrowsAsync(async () => + await entry.WriteToFileAsync(destination) + ); + + Assert.Contains(EntryName, exception.Message); + } + + [Fact] + public async Task Zip_Reader_WriteEntryToFileAsync_Throws_On_Crc_Mismatch() + { + using var zipStream = CreateZipWithInvalidCrc(useDataDescriptor: false); + await using var reader = await ReaderFactory.OpenAsyncReader(zipStream); + var destination = Path.Combine(SCRATCH_FILES_PATH, "zip-reader-crc-mismatch-async.txt"); + + Assert.True(await reader.MoveToNextEntryAsync()); + var exception = await Assert.ThrowsAsync(async () => + await reader.WriteEntryToFileAsync(destination) + ); + + Assert.Contains(EntryName, exception.Message); + } + + [Fact] + public void Zip_Archive_WriteToFile_Throws_On_DataDescriptor_Crc_Mismatch() + { + using var zipStream = CreateZipWithInvalidCrc(useDataDescriptor: true); + using var archive = ZipArchive.OpenArchive(zipStream); + var entry = archive.Entries.Single(e => !e.IsDirectory); + var destination = Path.Combine(SCRATCH_FILES_PATH, "zip-dd-crc-mismatch.txt"); + + var exception = Assert.Throws(() => entry.WriteToFile(destination)); + + Assert.Contains(EntryName, exception.Message); + } + + private static MemoryStream CreateZipWithInvalidCrc(bool useDataDescriptor) + { + var zipStream = new MemoryStream(); + Stream writerStream = useDataDescriptor ? new NonSeekableWriteStream(zipStream) : zipStream; + using ( + var writer = WriterFactory.OpenWriter( + writerStream, + ArchiveType.Zip, + new ZipWriterOptions(CompressionType.Deflate) { LeaveStreamOpen = true } + ) + ) + { + writer.Write(EntryName, new MemoryStream(EntryData)); + } + + var bytes = zipStream.ToArray(); + CorruptCrc(bytes, ZipHeaderFactoryEntrySignature, 14); + CorruptCrc(bytes, ZipHeaderFactoryDirectorySignature, 16); + return new MemoryStream(bytes); + } + + private const uint ZipHeaderFactoryEntrySignature = 0x04034b50; + private const uint ZipHeaderFactoryDirectorySignature = 0x02014b50; + + private static void CorruptCrc(byte[] bytes, uint signature, int crcOffset) + { + var offset = FindSignature(bytes, signature); + var crcIndex = offset + crcOffset; + bytes[crcIndex] ^= 0xFF; + } + + private static int FindSignature(byte[] bytes, uint signature) + { + var signatureBytes = BitConverter.GetBytes(signature); + for (var i = 0; i <= bytes.Length - signatureBytes.Length; i++) + { + if (bytes.AsSpan(i, signatureBytes.Length).SequenceEqual(signatureBytes)) + { + return i; + } + } + + throw new InvalidOperationException($"ZIP signature 0x{signature:X8} was not found."); + } + + private sealed class NonSeekableWriteStream(Stream stream) : Stream + { + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => stream.Flush(); + + public override Task FlushAsync(CancellationToken cancellationToken) => + stream.FlushAsync(cancellationToken); + + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + 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) => + stream.Write(buffer, offset, count); + +#if !LEGACY_DOTNET + public override void Write(ReadOnlySpan buffer) => stream.Write(buffer); +#endif + } +}