From ab3ac18e98cffd28cec62cb052ff5e767e6cdfca Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 08:22:55 +0100 Subject: [PATCH 01/18] Fix release review findings in RAR, GZip, and split ZIP parsing --- docs/API.md | 2 + docs/FORMATS.md | 6 + .../Archives/Zip/ZipArchiveVolumeFactory.cs | 20 ++ .../Common/GZip/GZipFilePart.Async.cs | 11 +- src/SharpCompress/Common/GZip/GZipFilePart.cs | 7 +- src/SharpCompress/Common/Rar/Headers/Flags.cs | 2 +- .../Common/Rar/Rar5CryptoInfo.cs | 9 +- src/SharpCompress/Common/Rar/Rar5KeyCache.cs | 7 + .../Common/Rar/RarCryptoWrapper.cs | 15 +- .../Compressors/Deflate/ZlibBaseStream.cs | 81 ++++---- .../GZip/GZipExtraFieldTests.cs | 190 ++++++++++++++++++ .../Mocks/ShortReadStream.cs | 28 +++ .../SharpCompress.Test/Rar/CryptKey5Tests.cs | 71 +++++++ .../SharpCompress.Test/Rar/RarCommentTests.cs | 70 ++++++- .../Rar/RarCryptoWrapperTests.cs | 120 +++++++++++ .../SharpCompress.Test/Zip/ZipArchiveTests.cs | 66 ++++++ 16 files changed, 648 insertions(+), 57 deletions(-) create mode 100644 tests/SharpCompress.Test/Mocks/ShortReadStream.cs create mode 100644 tests/SharpCompress.Test/Rar/RarCryptoWrapperTests.cs diff --git a/docs/API.md b/docs/API.md index 57c618dd..bad17881 100644 --- a/docs/API.md +++ b/docs/API.md @@ -81,6 +81,8 @@ using (var archive = ArchiveFactory.OpenArchive(parts)) Stored (compression method 0) comments are supported up to 16 MiB of unpacked data; larger or unknown-size comments throw `InvalidFormatException`. Comments using other compression methods are skipped without affecting entry extraction. +Truncated encrypted comment data throws `InvalidFormatException` on synchronous reads or `IncompleteArchiveException` on asynchronous reads. RAR5 KDF counts above 24 are rejected with `CryptographicException` before deriving encryption keys. + ```csharp using var archive = RarArchive.OpenArchive( "encrypted.rar", diff --git a/docs/FORMATS.md b/docs/FORMATS.md index 22a7080a..87a9a124 100644 --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -36,9 +36,15 @@ ### Zip Format Notes - Multi-volume/split ZIP archives require ZipArchive (seekable streams) as ZipReader cannot seek across volume files. +- ZIP/ZIPX companion volumes are discovered with a case-insensitive filename fallback, including on case-sensitive filesystems. An exact generated filename takes precedence; ambiguous fallback matches throw `InvalidFormatException`. - ZipReader processes entries from LocalEntry headers (which include directory entries ending with `/`) and intentionally skips DirectoryEntry headers from the central directory, as they are redundant in streaming mode - all entry data comes from LocalEntry headers which ZipReader has already processed. - ZIP supports ZStandard for reading and writing. Tar.Zstandard support is read/decompress only. +### Rar Format Notes + +- RAR5 encrypted headers and data accept KDF counts from 0 through 24 (the binary logarithm of the PBKDF2 iteration count), matching UnRAR's acceptance limit. Larger counts are rejected before key derivation. +- Encrypted ciphertext must contain complete AES blocks. Truncation throws `InvalidFormatException` on synchronous reads or `IncompleteArchiveException` on asynchronous reads. + ### 7Zip Format Notes - **Async Extraction Performance**: When using async extraction methods (e.g., `ExtractAllEntries()` with `MoveToNextEntryAsync()`), each file creates its own decompression stream to avoid state corruption in the LZMA decoder. This is less efficient than synchronous extraction, which can reuse a single decompression stream for multiple files in the same folder. diff --git a/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs b/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs index 4e1fc8d7..fd034da3 100644 --- a/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs +++ b/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs @@ -37,6 +37,26 @@ internal static class ZipArchiveVolumeFactory return item; } + // Generated suffixes are lowercase, but split archives can use any on-disk casing. + // Prefer the exact path above and never choose arbitrarily between fallback matches. + if (item is not null && item.Directory!.Exists) + { + FileInfo? match = null; + foreach (var file in item.Directory!.EnumerateFiles()) + { + if (!string.Equals(file.Name, item.Name, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (match is not null) + { + throw new InvalidFormatException($"Ambiguous ZIP volume name: {item.Name}"); + } + match = file; + } + return match; + } + return null; //no more items } } diff --git a/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs b/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs index e3648765..f6227168 100644 --- a/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs +++ b/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs @@ -64,7 +64,11 @@ internal sealed partial class GZipFilePart return; } - if (n != 10) + if ( + !await _stream + .ReadFullyAsync(header, n, header.Length - n, cancellationToken) + .ConfigureAwait(false) + ) { throw new ZlibException("Not a valid GZIP stream."); } @@ -80,7 +84,10 @@ internal sealed partial class GZipFilePart { // read and discard extra field var lengthField = new byte[2]; - _ = await _stream.ReadAsync(lengthField, 0, 2, cancellationToken).ConfigureAwait(false); + if (!await _stream.ReadFullyAsync(lengthField, cancellationToken).ConfigureAwait(false)) + { + throw new ZlibException("Unexpected end-of-file reading GZIP header."); + } var extraLength = lengthField[0] + (lengthField[1] * 256); var extra = new byte[extraLength]; diff --git a/src/SharpCompress/Common/GZip/GZipFilePart.cs b/src/SharpCompress/Common/GZip/GZipFilePart.cs index f7a1ae3f..35b25438 100644 --- a/src/SharpCompress/Common/GZip/GZipFilePart.cs +++ b/src/SharpCompress/Common/GZip/GZipFilePart.cs @@ -94,7 +94,7 @@ internal sealed partial class GZipFilePart : FilePart return; } - if (n != 10) + if (!_stream.ReadFully(header.Slice(n))) { throw new ZlibException("Not a valid GZIP stream."); } @@ -109,7 +109,10 @@ internal sealed partial class GZipFilePart : FilePart if ((header[3] & 0x04) == 0x04) { // read and discard extra field - n = _stream.Read(header.Slice(0, 2)); // 2-byte length field + if (!_stream.ReadFully(header.Slice(0, 2))) + { + throw new ZlibException("Unexpected end-of-file reading GZIP header."); + } var extraLength = header[0] + (header[1] * 256); var extra = new byte[extraLength]; diff --git a/src/SharpCompress/Common/Rar/Headers/Flags.cs b/src/SharpCompress/Common/Rar/Headers/Flags.cs index ef654ad5..d6e7a8c8 100644 --- a/src/SharpCompress/Common/Rar/Headers/Flags.cs +++ b/src/SharpCompress/Common/Rar/Headers/Flags.cs @@ -53,7 +53,7 @@ internal static class EncryptionFlagsV5 internal static class EncryptionConstV5 { public const int VERSION = 0; - public const uint CRYPT5_KDF_LG2_COUNT_MAX = 0x24; + public const uint CRYPT5_KDF_LG2_COUNT_MAX = 24; public const int SIZE_SALT30 = 0x08; public const int SIZE_SALT50 = 0x10; public const int SIZE_INITV = 0x10; diff --git a/src/SharpCompress/Common/Rar/Rar5CryptoInfo.cs b/src/SharpCompress/Common/Rar/Rar5CryptoInfo.cs index 2456e18a..733f5a01 100644 --- a/src/SharpCompress/Common/Rar/Rar5CryptoInfo.cs +++ b/src/SharpCompress/Common/Rar/Rar5CryptoInfo.cs @@ -24,7 +24,7 @@ internal class Rar5CryptoInfo encryptionFlags, EncryptionFlagsV5.CHFL_CRYPT_PSWCHECK ); - cryptoInfo.LG2Count = reader.ReadRarVIntByte(1); + cryptoInfo.LG2Count = reader.ReadByte(); if (cryptoInfo.LG2Count > EncryptionConstV5.CRYPT5_KDF_LG2_COUNT_MAX) { @@ -78,10 +78,9 @@ internal class Rar5CryptoInfo encryptionFlags, EncryptionFlagsV5.CHFL_CRYPT_PSWCHECK ); - cryptoInfo.LG2Count = (int) - await reader - .ReadRarVIntUInt32Async(cancellationToken: CancellationToken.None) - .ConfigureAwait(false); + cryptoInfo.LG2Count = await reader + .ReadByteAsync(CancellationToken.None) + .ConfigureAwait(false); if (cryptoInfo.LG2Count > EncryptionConstV5.CRYPT5_KDF_LG2_COUNT_MAX) { throw new CryptographicException($"Unsupported LG2 count of {cryptoInfo.LG2Count}."); diff --git a/src/SharpCompress/Common/Rar/Rar5KeyCache.cs b/src/SharpCompress/Common/Rar/Rar5KeyCache.cs index 5208c1e7..91680313 100644 --- a/src/SharpCompress/Common/Rar/Rar5KeyCache.cs +++ b/src/SharpCompress/Common/Rar/Rar5KeyCache.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using SharpCompress.Common.Rar.Headers; namespace SharpCompress.Common.Rar; @@ -12,6 +13,12 @@ internal sealed class Rar5KeyCache internal List GetKeys(string password, byte[] salt, int lg2Count) { + // Metadata can be mutated after parsing; validate before C# masks the shift count. + if (lg2Count < 0 || lg2Count > EncryptionConstV5.CRYPT5_KDF_LG2_COUNT_MAX) + { + throw new CryptographicException($"Unsupported LG2 count of {lg2Count}."); + } + lock (entries) { foreach (var (cachedPassword, cachedSalt, cachedCount, cachedKeys) in entries) diff --git a/src/SharpCompress/Common/Rar/RarCryptoWrapper.cs b/src/SharpCompress/Common/Rar/RarCryptoWrapper.cs index 6e1ed222..2e7d8051 100644 --- a/src/SharpCompress/Common/Rar/RarCryptoWrapper.cs +++ b/src/SharpCompress/Common/Rar/RarCryptoWrapper.cs @@ -40,8 +40,11 @@ internal sealed class RarCryptoWrapper : Stream Span cipherText = stackalloc byte[16]; for (var i = 0; i < alignedSize / 16; i++) { - //long ax = System.currentTimeMillis(); - _actualStream.Read(cipherText); + if (!_actualStream.ReadFully(cipherText)) + { + // Preserve the synchronous API's malformed-archive exception type. + throw new InvalidFormatException("Unexpected end of encrypted stream"); + } var readBytes = _rijndael.ProcessBlock(cipherText); foreach (var readByte in readBytes) @@ -49,11 +52,11 @@ internal sealed class RarCryptoWrapper : Stream _data.Enqueue(readByte); } } + } - for (var i = 0; i < count; i++) - { - buffer[offset + i] = _data.Dequeue(); - } + for (var i = 0; i < count; i++) + { + buffer[offset + i] = _data.Dequeue(); } return count; } diff --git a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs index d39eb178..66fd1732 100644 --- a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs +++ b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs @@ -341,18 +341,9 @@ internal class ZlibBaseStream : Stream, IStreamStack // Make sure we have read to the end of the stream _z.InputBuffer.AsSpan(_z.NextIn, _z.AvailableBytesIn).CopyTo(trailer); var bytesNeeded = 8 - _z.AvailableBytesIn; - var bytesRead = _stream.Read( - trailer.Slice(_z.AvailableBytesIn, bytesNeeded) - ); - if (bytesNeeded != bytesRead) + if (!_stream.ReadFully(trailer.Slice(_z.AvailableBytesIn, bytesNeeded))) { - throw new ZlibException( - String.Format( - Constants.DefaultCultureInfo, - "Protocol error. AvailableBytesIn={0}, expected 8", - _z.AvailableBytesIn + bytesRead - ) - ); + throw new ZlibException("Unexpected end-of-file reading GZIP trailer."); } } else @@ -491,9 +482,19 @@ internal class ZlibBaseStream : Stream, IStreamStack // Make sure we have read to the end of the stream _z.InputBuffer.AsSpan(_z.NextIn, _z.AvailableBytesIn).CopyTo(trailer); var bytesNeeded = 8 - _z.AvailableBytesIn; - var bytesRead = await _stream - .ReadAsync(trailer, _z.AvailableBytesIn, bytesNeeded, cancellationToken) - .ConfigureAwait(false); + if ( + !await _stream + .ReadFullyAsync( + trailer, + _z.AvailableBytesIn, + bytesNeeded, + cancellationToken + ) + .ConfigureAwait(false) + ) + { + throw new ZlibException("Unexpected end-of-file reading GZIP trailer."); + } } } else @@ -736,7 +737,7 @@ internal class ZlibBaseStream : Stream, IStreamStack return 0; } - if (n != 10) + if (!_stream.ReadFully(header.Slice(n))) { throw new ZlibException("Not a valid GZIP stream."); } @@ -748,21 +749,23 @@ internal class ZlibBaseStream : Stream, IStreamStack var timet = BinaryPrimitives.ReadInt32LittleEndian(header.Slice(4)); _GzipMtime = TarHeader.EPOCH.AddSeconds(timet); - totalBytesRead += n; + totalBytesRead += header.Length; if ((header[3] & 0x04) == 0x04) { // read and discard extra field - n = _stream.Read(header.Slice(0, 2)); // 2-byte length field - totalBytesRead += n; - - var extraLength = header[0] + (header[1] * 256); - var extra = new byte[extraLength]; - n = _stream.Read(extra, 0, extra.Length); - if (n != extraLength) + if (!_stream.ReadFully(header.Slice(0, 2))) { throw new ZlibException("Unexpected end-of-file reading GZIP header."); } - totalBytesRead += n; + totalBytesRead += 2; + + var extraLength = header[0] + (header[1] * 256); + var extra = new byte[extraLength]; + if (!_stream.ReadFully(extra)) + { + throw new ZlibException("Unexpected end-of-file reading GZIP header."); + } + totalBytesRead += extraLength; } if ((header[3] & 0x08) == 0x08) { @@ -796,7 +799,11 @@ internal class ZlibBaseStream : Stream, IStreamStack return 0; } - if (n != 10) + if ( + !await _stream + .ReadFullyAsync(header, n, header.Length - n, cancellationToken) + .ConfigureAwait(false) + ) { throw new ZlibException("Not a valid GZIP stream."); } @@ -808,23 +815,25 @@ internal class ZlibBaseStream : Stream, IStreamStack var timet = BinaryPrimitives.ReadInt32LittleEndian(header.AsSpan(4)); _GzipMtime = TarHeader.EPOCH.AddSeconds(timet); - totalBytesRead += n; + totalBytesRead += header.Length; if ((header[3] & 0x04) == 0x04) { // read and discard extra field - n = await _stream.ReadAsync(header, 0, 2, cancellationToken).ConfigureAwait(false); // 2-byte length field - totalBytesRead += n; - - var extraLength = header[0] + (header[1] * 256); - var extra = new byte[extraLength]; - n = await _stream - .ReadAsync(extra, 0, extra.Length, cancellationToken) - .ConfigureAwait(false); - if (n != extraLength) + if ( + !await _stream.ReadFullyAsync(header, 0, 2, cancellationToken).ConfigureAwait(false) + ) { throw new ZlibException("Unexpected end-of-file reading GZIP header."); } - totalBytesRead += n; + totalBytesRead += 2; + + var extraLength = header[0] + (header[1] * 256); + var extra = new byte[extraLength]; + if (!await _stream.ReadFullyAsync(extra, cancellationToken).ConfigureAwait(false)) + { + throw new ZlibException("Unexpected end-of-file reading GZIP header."); + } + totalBytesRead += extraLength; } if ((header[3] & 0x08) == 0x08) { diff --git a/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs b/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs index 2d484fd3..ae8d2712 100644 --- a/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs +++ b/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs @@ -1,10 +1,14 @@ using System; +using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using AwesomeAssertions; using SharpCompress.Archives.GZip; +using SharpCompress.Compressors.Deflate; +using SharpCompress.Readers; using SharpCompress.Readers.GZip; using SharpCompress.Test.Mocks; using Xunit; @@ -17,6 +21,192 @@ public class GZipExtraFieldTests { private static readonly byte[] EXPECTED_PAYLOAD = [.. "gzip payload"u8]; + public static IEnumerable FragmentedExtraFields() + { + foreach (var api in new[] { "Stream", "Reader", "Archive" }) + { + foreach (var useAsync in new[] { false, true }) + { + foreach (var seekable in new[] { false, true }) + { + foreach (var extraLength in new[] { 0, 32768, 65535 }) + { + foreach (var chunkSize in new[] { 1, 7, 1024 }) + { + if (api != "Archive" || seekable) + { + yield return [api, useAsync, seekable, extraLength, chunkSize]; + } + } + } + } + } + } + } + + [Theory] + [MemberData(nameof(FragmentedExtraFields))] + public async Task GZip_Read_WithFragmentedExtraField_ShouldReturnPayload( + string api, + bool useAsync, + bool seekable, + int extraLength, + int chunkSize + ) + { + using var input = new ShortReadStream( + new MemoryStream(CreateGZipWithExtraField(extraLength, "payload.bin")), + chunkSize, + seekable + ); + using var asyncOnly = new AsyncOnlyStream(input, disposeStream: false); + Assert.Equal( + EXPECTED_PAYLOAD, + await ReadPayload(useAsync ? asyncOnly : input, api, useAsync, CancellationToken.None) + ); + Assert.False(input.IsDisposed); + } + + public static IEnumerable TruncatedExtraFields() + { + foreach (var api in new[] { "Stream", "Reader", "Archive" }) + { + foreach (var useAsync in new[] { false, true }) + { + foreach (var length in new[] { 1, 9, 10, 11, 12, 13, 43 }) + { + yield return [api, useAsync, length]; + } + } + } + } + + [Theory] + [MemberData(nameof(TruncatedExtraFields))] + public async Task GZip_Read_RejectsTruncatedExtraField(string api, bool useAsync, int length) + { + using var input = new ShortReadStream( + new MemoryStream(CreateGZipWithExtraField(32).Take(length).ToArray()) + ); + await Assert.ThrowsAsync(() => + ReadPayload(input, api, useAsync, CancellationToken.None) + ); + Assert.False(input.IsDisposed); + } + + [Theory] + [InlineData(false, 1)] + [InlineData(true, 1)] + [InlineData(false, 7)] + [InlineData(true, 7)] + [InlineData(false, 8)] + [InlineData(true, 8)] + public async Task GZipStream_Read_RejectsTruncatedTrailer(bool useAsync, int missingBytes) + { + var data = CreateGZipWithExtraField(32); + using var input = new ShortReadStream( + new MemoryStream(data.Take(data.Length - missingBytes).ToArray()) + ); + await Assert.ThrowsAsync(() => + ReadPayload(input, "Stream", useAsync, CancellationToken.None) + ); + Assert.False(input.IsDisposed); + } + + [Theory] + [InlineData("Stream")] + [InlineData("Reader")] + [InlineData("Archive")] + public async Task GZip_ReadAsync_WithExtraField_HonorsCancellation(string api) + { + using var input = new ShortReadStream(new MemoryStream(CreateGZipWithExtraField(65535))); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => + ReadPayload(input, api, true, cancellation.Token) + ); + Assert.False(input.IsDisposed); + } + + private static async Task ReadPayload( + Stream input, + string api, + bool useAsync, + CancellationToken cancellationToken + ) + { + var options = ReaderOptions.ForExternalStream; + using var output = new MemoryStream(); + switch (api) + { + case "Stream": + if (useAsync) + { + await using var gzip = new SharpCompress.Compressors.Deflate.GZipStream( + input, + SharpCompressionMode.Decompress, + options + ); + await gzip.CopyToAsync(output, 81920, cancellationToken); + } + else + { + using var gzip = new SharpCompress.Compressors.Deflate.GZipStream( + input, + SharpCompressionMode.Decompress, + options + ); + gzip.CopyTo(output); + } + break; + case "Reader": + if (useAsync) + { + await using var reader = await GZipReader.OpenAsyncReader( + input, + options, + cancellationToken + ); + Assert.True(await reader.MoveToNextEntryAsync(cancellationToken)); + await reader.WriteEntryToAsync(output, cancellationToken); + } + else + { + using var reader = GZipReader.OpenReader(input, options); + Assert.True(reader.MoveToNextEntry()); + reader.WriteEntryTo(output); + } + break; + case "Archive": + if (useAsync) + { + await using var archive = await GZipArchive.OpenAsyncArchive( + input, + options, + cancellationToken + ); + await foreach ( + var entry in archive.EntriesAsync.WithCancellation(cancellationToken) + ) + { + using var entryStream = await entry.OpenEntryStreamAsync(cancellationToken); + await entryStream.CopyToAsync(output, 81920, cancellationToken); + break; + } + } + else + { + using var archive = GZipArchive.OpenArchive(input, options); + using var entryStream = archive.Entries.Single().OpenEntryStream(); + entryStream.CopyTo(output); + } + break; + default: + throw new ArgumentException("Unknown GZip API", nameof(api)); + } + return output.ToArray(); + } + [Theory] [InlineData(32767)] [InlineData(32768)] diff --git a/tests/SharpCompress.Test/Mocks/ShortReadStream.cs b/tests/SharpCompress.Test/Mocks/ShortReadStream.cs new file mode 100644 index 00000000..5c149b30 --- /dev/null +++ b/tests/SharpCompress.Test/Mocks/ShortReadStream.cs @@ -0,0 +1,28 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace SharpCompress.Test.Mocks; + +// Exercise legal partial reads through every stream read overload used by the parsers. +internal sealed class ShortReadStream(Stream stream, int maxReadSize = 1, bool seekable = true) + : TestStream(stream, read: true, write: false, seek: seekable) +{ + public override int Read(byte[] buffer, int offset, int count) => + base.Read(buffer, offset, Math.Min(count, maxReadSize)); + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) => base.ReadAsync(buffer, offset, Math.Min(count, maxReadSize), cancellationToken); + +#if !LEGACY_DOTNET + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) => base.ReadAsync(buffer.Slice(0, Math.Min(buffer.Length, maxReadSize)), cancellationToken); +#endif +} diff --git a/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs b/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs index 73102f5b..45665b2c 100644 --- a/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs +++ b/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs @@ -1,5 +1,6 @@ using System.IO; using System.Linq; +using System.Threading.Tasks; using SharpCompress.Common.Rar; using SharpCompress.Common.Rar.Headers; using SharpCompress.IO; @@ -10,6 +11,76 @@ namespace SharpCompress.Test.Rar; public class CryptKey5Tests : TestBase { + [Theory] + [InlineData(0)] + [InlineData(24)] + [InlineData(25)] + [InlineData(30)] + [InlineData(31)] + [InlineData(32)] + [InlineData(36)] + [InlineData(128)] + [InlineData(255)] + public async Task Rar5_CryptoInfo_Validates_Kdf_Count(int lg2Count) + { + // The KDF count is one raw byte, not a vint. Test the upper bound without deriving a key. + var data = new byte[35]; + data[2] = (byte)lg2Count; + using var syncStream = new MemoryStream(data); + using var asyncStream = new MemoryStream(data); + using var syncReader = new MarkingBinaryReader(syncStream); + using var asyncReader = new AsyncMarkingBinaryReader(asyncStream); + if (lg2Count <= 24) + { + Assert.Equal(lg2Count, Rar5CryptoInfo.Create(syncReader, true).LG2Count); + Assert.Equal(lg2Count, (await Rar5CryptoInfo.CreateAsync(asyncReader, true)).LG2Count); + } + else + { + Assert.Throws(() => + Rar5CryptoInfo.Create(syncReader, true) + ); + await Assert.ThrowsAsync(async () => + await Rar5CryptoInfo.CreateAsync(asyncReader, true) + ); + Assert.Equal(3, syncStream.Position); + Assert.Equal(3, asyncStream.Position); + } + } + + [Theory] + [InlineData(-1)] + [InlineData(25)] + [InlineData(30)] + [InlineData(31)] + [InlineData(32)] + [InlineData(33)] + [InlineData(34)] + [InlineData(35)] + [InlineData(36)] + [InlineData(255)] + [InlineData(int.MaxValue)] + public void Rar5_KeyCache_Rejects_Invalid_Kdf_Count(int lg2Count) + { + var cache = new Rar5KeyCache(); + Assert.Throws(() => + cache.GetKeys("test", new byte[16], lg2Count) + ); + } + + [Fact] + public void Rar5_Key_Rejects_Mutated_Kdf_Count() + { + var info = ReadCryptoInfo(); + var key = new CryptKey5("test", info); + using var transform = key.Transformer(info.Salt); + info.LG2Count = 32; + + Assert.Throws(() => + key.Transformer(info.Salt) + ); + } + private Rar5CryptoInfo ReadCryptoInfo() { using var stream = File.OpenRead( diff --git a/tests/SharpCompress.Test/Rar/RarCommentTests.cs b/tests/SharpCompress.Test/Rar/RarCommentTests.cs index a1c6d284..8400d539 100644 --- a/tests/SharpCompress.Test/Rar/RarCommentTests.cs +++ b/tests/SharpCompress.Test/Rar/RarCommentTests.cs @@ -9,6 +9,7 @@ using SharpCompress.Archives; using SharpCompress.Archives.Rar; using SharpCompress.Common; using SharpCompress.Common.Rar; +using SharpCompress.Common.Rar.Headers; using SharpCompress.Compressors.Rar; using SharpCompress.IO; using SharpCompress.Readers; @@ -203,7 +204,61 @@ public class RarCommentTests : TestBase Assert.True(stream.CanRead); } - public static IEnumerable UnsupportedCommentMethods() + public static IEnumerable TruncatedEncryptedComments() + { + foreach (var useAsync in new[] { false, true }) + { + foreach (var useReader in new[] { false, true }) + { + foreach (var length in new[] { 0, 15, 16, 31 }) + { + yield return [useAsync, useReader, length]; + } + } + } + } + + [Theory] + [MemberData(nameof(TruncatedEncryptedComments))] + public async Task Rar5_Comment_Rejects_Truncated_Encrypted_Data( + bool useAsync, + bool useReader, + int length + ) + { + var data = File.ReadAllBytes( + Path.Combine(TEST_ARCHIVES_PATH, "Rar5.comment.encrypted17.rar") + ); + using var source = new MemoryStream(data); + var factory = new RarHeaderFactory( + StreamingMode.Seekable, + ReaderOptions.ForExternalStream.WithPassword("test") + ); + var comment = factory + .ReadHeaders(source) + .OfType() + .First(x => x.FileName == "CMT"); + using var packed = comment.PackedStream!; + Assert.NotNull(comment.Rar5CryptoInfo); + Assert.True(comment.CompressedSize > length); + using var truncated = new MemoryStream(data.Take((int)source.Position + length).ToArray()); + + if (useAsync) + { + await Assert.ThrowsAsync(() => + ReadCommentArchive(truncated, useAsync, useReader, "test") + ); + } + else + { + await Assert.ThrowsAsync(() => + ReadCommentArchive(truncated, useAsync, useReader, "test") + ); + } + Assert.True(truncated.CanRead); + } + + public static IEnumerable NonStoredCommentMethods() { foreach (var useAsync in new[] { false, true }) { @@ -218,8 +273,12 @@ public class RarCommentTests : TestBase } [Theory] - [MemberData(nameof(UnsupportedCommentMethods))] - public async Task Rar5_Comment_Skips_Nonzero_Methods(int method, bool useAsync, bool useReader) + [MemberData(nameof(NonStoredCommentMethods))] + public async Task Rar5_Comment_Skips_NonStored_Methods( + int method, + bool useAsync, + bool useReader + ) { // Ignored comments must not allocate from their unpacked size or disturb the next entry. using var stream = CreateCommentArchive(int.MaxValue, method, [1, 2, 3]); @@ -230,10 +289,11 @@ public class RarCommentTests : TestBase private static async Task ReadCommentArchive( Stream stream, bool useAsync, - bool useReader + bool useReader, + string? password = null ) { - var options = ReaderOptions.ForExternalStream; + var options = ReaderOptions.ForExternalStream with { Password = password }; if (useReader) { using var forwardOnly = new ForwardOnlyStream( diff --git a/tests/SharpCompress.Test/Rar/RarCryptoWrapperTests.cs b/tests/SharpCompress.Test/Rar/RarCryptoWrapperTests.cs new file mode 100644 index 00000000..5f69a190 --- /dev/null +++ b/tests/SharpCompress.Test/Rar/RarCryptoWrapperTests.cs @@ -0,0 +1,120 @@ +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Common; +using SharpCompress.Common.Rar; +using SharpCompress.IO; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test.Rar; + +public class RarCryptoWrapperTests +{ + private static readonly byte[] Plaintext = Enumerable + .Range(0, 32) + .Select(x => (byte)x) + .ToArray(); + + [Theory] + [InlineData(false, 1)] + [InlineData(true, 1)] + [InlineData(false, 7)] + [InlineData(true, 7)] + [InlineData(false, 17)] + [InlineData(true, 17)] + [InlineData(false, 32)] + [InlineData(true, 32)] + public async Task Rar5_Crypto_Reads_Fragmented_Blocks_And_Buffered_Plaintext( + bool useAsync, + int readSize + ) + { + var info = CreateCryptoInfo(); + using var input = new ShortReadStream(new MemoryStream(Encrypt(info))); + using var asyncOnly = new AsyncOnlyStream(input, disposeStream: false); + using var crypto = new RarCryptoWrapper( + useAsync ? asyncOnly : input, + info.Salt, + new CryptKey5("test", info) + ); + var result = new byte[Plaintext.Length]; + var offset = 0; + while (offset < result.Length) + { + var count = Math.Min(readSize, result.Length - offset); + var read = useAsync + ? await crypto.ReadAsync(result, offset, count, CancellationToken.None) + : crypto.Read(result, offset, count); + Assert.Equal(count, read); + offset += read; + } + Assert.Equal(Plaintext, result); + Assert.Equal(0, crypto.Read(result, 0, 0)); + Assert.False(input.IsDisposed); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(15)] + [InlineData(16)] + [InlineData(17)] + [InlineData(31)] + public async Task Rar5_Crypto_Rejects_Truncated_Ciphertext(int length) + { + var info = CreateCryptoInfo(); + var data = Encrypt(info).Take(length).ToArray(); + using var syncInput = new ShortReadStream(new MemoryStream(data)); + using var asyncInput = new ShortReadStream(new MemoryStream(data)); + using var syncCrypto = new RarCryptoWrapper( + syncInput, + info.Salt, + new CryptKey5("test", info) + ); + using var asyncCrypto = new RarCryptoWrapper( + asyncInput, + info.Salt, + new CryptKey5("test", info) + ); + Assert.Throws(() => syncCrypto.Read(new byte[32], 0, 32)); + await Assert.ThrowsAsync(async () => + await asyncCrypto.ReadAsync(new byte[32], 0, 32, CancellationToken.None) + ); + } + + [Fact] + public async Task Rar5_Crypto_Async_Cancellation() + { + var info = CreateCryptoInfo(); + using var input = new MemoryStream(Encrypt(info)); + using var crypto = new RarCryptoWrapper(input, info.Salt, new CryptKey5("test", info)); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(async () => + await crypto.ReadAsync(new byte[32], 0, 32, cancellation.Token) + ); + Assert.Equal(0, input.Position); + } + + private static Rar5CryptoInfo CreateCryptoInfo() + { + // Use a one-iteration KDF so these stream tests do not spend time deriving keys. + using var stream = new MemoryStream(new byte[35]); + using var reader = new MarkingBinaryReader(stream); + return Rar5CryptoInfo.Create(reader, true); + } + + private static byte[] Encrypt(Rar5CryptoInfo info) + { + using var aes = Aes.Create(); + aes.Key = CryptKey5.GenerateRarPBKDF2Key("test", info.Salt, 1)[0]; + aes.IV = info.InitV; + aes.Padding = PaddingMode.None; + using var encryptor = aes.CreateEncryptor(); + return encryptor.TransformFinalBlock(Plaintext, 0, Plaintext.Length); + } +} diff --git a/tests/SharpCompress.Test/Zip/ZipArchiveTests.cs b/tests/SharpCompress.Test/Zip/ZipArchiveTests.cs index 52078ef7..6be81bce 100644 --- a/tests/SharpCompress.Test/Zip/ZipArchiveTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipArchiveTests.cs @@ -159,6 +159,72 @@ public class ZipArchiveTests : ArchiveTests Assert.Equal(new[] { zip, z01 }, ArchiveFactory.GetFileParts(zip)); } + [Theory] + [InlineData("MULTI.ZIP", "MULTI.Z01", "Infozip.nocomp.multi")] + [InlineData("MULTI.ZiP", "Multi.z01", "Infozip.nocomp.multi")] + [InlineData("MULTI.ZIPX", "MULTI.ZX01", "WinZip26.nocomp.multi")] + [InlineData("MULTI.ZiPx", "Multi.zX01", "WinZip26.nocomp.multi")] + public void Zip_GetParts_MixedCaseCompanions( + string archiveName, + string partName, + string fixture + ) + { + var zip = Path.Combine(SCRATCH2_FILES_PATH, archiveName); + var part = Path.Combine(SCRATCH2_FILES_PATH, partName); + var isZipX = archiveName.EndsWith("zipx", StringComparison.OrdinalIgnoreCase); + File.Copy(Path.Combine(TEST_ARCHIVES_PATH, fixture + (isZipX ? ".zipx" : ".zip")), zip); + File.Copy(Path.Combine(TEST_ARCHIVES_PATH, fixture + (isZipX ? ".zx01" : ".z01")), part); + + var parts = ArchiveFactory.GetFileParts(zip).ToArray(); + Assert.Equal(2, parts.Length); + Assert.Equal(zip, parts[0]); + Assert.True(string.Equals(part, parts[1], StringComparison.OrdinalIgnoreCase)); + using var archive = ZipArchive.OpenArchive(zip); + Assert.NotEmpty(archive.Entries); + } + + [Fact] + public void Zip_GetParts_RejectsAmbiguousCaseInsensitiveMatches() + { + var zip = Path.Combine(SCRATCH2_FILES_PATH, "MULTI.ZIP"); + File.WriteAllBytes(zip, []); + File.WriteAllBytes(Path.Combine(SCRATCH2_FILES_PATH, "Multi.Z01"), []); + var other = Path.Combine(SCRATCH2_FILES_PATH, "multi.Z01"); + if (File.Exists(other)) + { + // Case-insensitive filesystems cannot represent an ambiguous pair. + return; + } + File.WriteAllBytes(other, []); + + Assert.Throws(() => ArchiveFactory.GetFileParts(zip).ToArray()); + } + + [Fact] + public void Zip_GetParts_PrefersExactMatch() + { + var zip = Path.Combine(SCRATCH2_FILES_PATH, "MULTI.ZIP"); + var part = Path.Combine(SCRATCH2_FILES_PATH, "MULTI.z01"); + File.WriteAllBytes(zip, []); + File.WriteAllBytes(part, []); + var other = Path.Combine(SCRATCH2_FILES_PATH, "Multi.Z01"); + if (!File.Exists(other)) + { + File.WriteAllBytes(other, []); + } + + Assert.Equal(new[] { zip, part }, ArchiveFactory.GetFileParts(zip)); + } + + [Fact] + public void Zip_GetParts_AllowsMissingCompanion() + { + var zip = Path.Combine(SCRATCH2_FILES_PATH, "MULTI.ZIP"); + File.WriteAllBytes(zip, []); + Assert.Equal(new[] { zip }, ArchiveFactory.GetFileParts(zip)); + } + [Fact] public void Zip_GetPartsSplit() => //uses first part to search for all parts and compares against this array From 9ba6d17b5dfe2991699863eaa34d79f844538dd2 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 15:40:33 +0100 Subject: [PATCH 02/18] Validate 7z AES KDF limits before unsalted key derivation --- docs/API.md | 2 ++ .../Compressors/LZMA/AesDecoderStream.cs | 22 +++++++----- .../SevenZip/SevenZipAesKdfTests.cs | 35 +++++++++++++++++++ 3 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 tests/SharpCompress.Test/SevenZip/SevenZipAesKdfTests.cs diff --git a/docs/API.md b/docs/API.md index bad17881..bae3d649 100644 --- a/docs/API.md +++ b/docs/API.md @@ -486,6 +486,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. +7z AES key derivation accepts iteration exponents 0 through 24 and the special `0x3F` direct-key mode. Larger iteration exponents throw `NotSupportedException` before key derivation, including when salt and IV are absent. + When using `SymbolicLinkHandler`, directory extraction rejects link targets outside the extraction root and never follows symbolic links or reparse points while extracting later entries. The handler itself remains trusted application code. ### Options matrix diff --git a/src/SharpCompress/Compressors/LZMA/AesDecoderStream.cs b/src/SharpCompress/Compressors/LZMA/AesDecoderStream.cs index 52793292..72f05db6 100644 --- a/src/SharpCompress/Compressors/LZMA/AesDecoderStream.cs +++ b/src/SharpCompress/Compressors/LZMA/AesDecoderStream.cs @@ -24,7 +24,7 @@ internal sealed partial class AesDecoderStream : DecoderStream2 public AesDecoderStream(Stream input, byte[] info, IPasswordProvider pass, long limit) { var password = pass.CryptoGetTextPassword(); - if (password == null) + if (password is null) { throw new SharpCompress.Common.CryptographicException( "Encrypted 7Zip archive has no password specified." @@ -43,7 +43,7 @@ internal sealed partial class AesDecoderStream : DecoderStream2 var passwordBytes = Encoding.Unicode.GetBytes(password); var key = InitKey(numCyclesPower, salt, passwordBytes); - if (key == null) + if (key is null) { throw new ArchiveOperationException("Initialized with null key"); } @@ -147,13 +147,24 @@ internal sealed partial class AesDecoderStream : DecoderStream2 private void Init(byte[] info, out int numCyclesPower, out byte[] salt, out byte[] iv) { + if (info.Length == 0) + { + throw new InvalidFormatException("Missing 7z AES properties."); + } var bt = info[0]; numCyclesPower = bt & 0x3F; + // Check before the no-salt/no-IV return, and before starting expensive key derivation. + // 0x3F is the format's direct-key mode, not an iteration exponent. + if (numCyclesPower > 24 && numCyclesPower != 0x3F) + { + throw new NotSupportedException("Unsupported 7z AES KDF count."); + } + if ((bt & 0xC0) == 0) { salt = Array.Empty(); - iv = Array.Empty(); + iv = new byte[16]; return; } @@ -183,11 +194,6 @@ internal sealed partial class AesDecoderStream : DecoderStream2 { iv[i] = info[i + saltSize + 2]; } - - if (numCyclesPower > 24) - { - throw new NotSupportedException(); - } } private byte[]? InitKey(int mNumCyclesPower, byte[] salt, byte[] pass) diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipAesKdfTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipAesKdfTests.cs new file mode 100644 index 00000000..c7dcae90 --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/SevenZipAesKdfTests.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using SharpCompress.Compressors.LZMA; +using SharpCompress.Compressors.LZMA.Utilities; +using Xunit; + +namespace SharpCompress.Test.SevenZip; + +public class SevenZipAesKdfTests +{ + [Theory] + [InlineData(25)] + [InlineData(31)] + [InlineData(62)] + public void SevenZipAes_Rejects_Excessive_Kdf_Without_Salt(int exponent) + { + using var input = new MemoryStream(); + Assert.Throws(() => + new AesDecoderStream(input, [(byte)exponent], new Password(), 0) + ); + } + + [Fact] + public void SevenZipAes_Allows_Direct_Key_Without_Iv() + { + using var input = new MemoryStream(); + using var decoder = new AesDecoderStream(input, [0x3F], new Password(), 0); + Assert.Equal(0, decoder.Read(new byte[1], 0, 1)); + } + + private sealed class Password : IPasswordProvider + { + public string CryptoGetTextPassword() => "test"; + } +} From 7265047dd75cb6c662abfd05fee1e0b157c152f0 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 15:40:34 +0100 Subject: [PATCH 03/18] Verify WinZip AES authentication and reject truncated encrypted data --- docs/API.md | 2 + docs/FORMATS.md | 1 + .../Common/Zip/WinzipAesCryptoStream.Async.cs | 119 ++++++----- .../Common/Zip/WinzipAesCryptoStream.cs | 92 +++++++-- .../Streams/WinzipAesCryptoStreamTests.cs | 191 +++++++++++++++++- 5 files changed, 333 insertions(+), 72 deletions(-) diff --git a/docs/API.md b/docs/API.md index bae3d649..abeba83f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -486,6 +486,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. +WinZip AES authentication is always checked, including when `CheckCrc` is disabled. Complete or dispose entry streams to verify authentication; plaintext returned before completion must not be treated as authenticated. Invalid authentication tags throw `InvalidFormatException`, and missing ciphertext or tag bytes throw `IncompleteArchiveException`. + 7z AES key derivation accepts iteration exponents 0 through 24 and the special `0x3F` direct-key mode. Larger iteration exponents throw `NotSupportedException` before key derivation, including when salt and IV are absent. When using `SymbolicLinkHandler`, directory extraction rejects link targets outside the extraction root and never follows symbolic links or reparse points while extracting later entries. The handler itself remains trusted application code. diff --git a/docs/FORMATS.md b/docs/FORMATS.md index 87a9a124..fe8b8fa8 100644 --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -37,6 +37,7 @@ - Multi-volume/split ZIP archives require ZipArchive (seekable streams) as ZipReader cannot seek across volume files. - ZIP/ZIPX companion volumes are discovered with a case-insensitive filename fallback, including on case-sensitive filesystems. An exact generated filename takes precedence; ambiguous fallback matches throw `InvalidFormatException`. +- WinZip AES authentication (HMAC-SHA1-80 over ciphertext) is verified independently of `CheckCrc`. Incorrect tags throw `InvalidFormatException`; truncated ciphertext or tags throw `IncompleteArchiveException`. Reading an entry to completion or disposing its stream verifies authentication. - ZipReader processes entries from LocalEntry headers (which include directory entries ending with `/`) and intentionally skips DirectoryEntry headers from the central directory, as they are redundant in streaming mode - all entry data comes from LocalEntry headers which ZipReader has already processed. - ZIP supports ZStandard for reading and writing. Tar.Zstandard support is read/decompress only. diff --git a/src/SharpCompress/Common/Zip/WinzipAesCryptoStream.Async.cs b/src/SharpCompress/Common/Zip/WinzipAesCryptoStream.Async.cs index d68b8a87..dfe8e764 100644 --- a/src/SharpCompress/Common/Zip/WinzipAesCryptoStream.Async.cs +++ b/src/SharpCompress/Common/Zip/WinzipAesCryptoStream.Async.cs @@ -1,6 +1,8 @@ using System; using System.Buffers; using System.IO; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -17,21 +19,57 @@ internal partial class WinzipAesCryptoStream return; } _isDisposed = true; - // Read out last 10 auth bytes asynchronously - byte[] authBytes = ArrayPool.Shared.Rent(10); try { - await _stream.ReadFullyAsync(authBytes, 0, 10).ConfigureAwait(false); + if (!_authRead) + { + await DrainAndVerifyAsync().ConfigureAwait(false); + } } finally { - ArrayPool.Shared.Return(authBytes); + _authentication.Dispose(); + _transform.Dispose(); + _cipher.Dispose(); await _stream.DisposeAsync().ConfigureAwait(false); } await base.DisposeAsync().ConfigureAwait(false); } #endif + private async ValueTask VerifyAuthenticationAsync(CancellationToken cancellationToken) + { + _authenticationFailure?.Throw(); + if (_authRead) + { + return; + } + _authRead = true; + var tag = new byte[10]; + try + { + await _stream + .ReadExactAsync(tag, 0, tag.Length, cancellationToken) + .ConfigureAwait(false); + ValidateAuthentication(tag); + } + catch (Exception exception) + { + _authenticationFailure = ExceptionDispatchInfo.Capture(exception); + throw; + } + } + + private async ValueTask DrainAndVerifyAsync() + { + var buffer = new byte[4096]; + while (_totalBytesLeftToRead > 0) + { + await ReadAsync(buffer, 0, buffer.Length, CancellationToken.None).ConfigureAwait(false); + } + await VerifyAuthenticationAsync(CancellationToken.None).ConfigureAwait(false); + } + public override async Task ReadAsync( byte[] buffer, int offset, @@ -39,8 +77,15 @@ internal partial class WinzipAesCryptoStream CancellationToken cancellationToken ) { + cancellationToken.ThrowIfCancellationRequested(); + _authenticationFailure?.Throw(); + if (count == 0) + { + return 0; + } if (_totalBytesLeftToRead == 0) { + await VerifyAuthenticationAsync(cancellationToken).ConfigureAwait(false); return 0; } var bytesToRead = count; @@ -51,7 +96,17 @@ internal partial class WinzipAesCryptoStream var read = await _stream .ReadAsync(buffer, offset, bytesToRead, cancellationToken) .ConfigureAwait(false); + if (read == 0) + { + _authRead = true; + throw new IncompleteArchiveException("Unexpected end of WinZip AES ciphertext."); + } + _authentication.TransformBlock(buffer, offset, read, null, 0); _totalBytesLeftToRead -= read; + if (_totalBytesLeftToRead == 0) + { + await VerifyAuthenticationAsync(cancellationToken).ConfigureAwait(false); + } ReadTransformBlocks(buffer, offset, read); @@ -64,56 +119,22 @@ internal partial class WinzipAesCryptoStream CancellationToken cancellationToken = default ) { - if (_totalBytesLeftToRead == 0) + if (MemoryMarshal.TryGetArray((ReadOnlyMemory)buffer, out var segment)) { - return 0; + return await ReadAsync(segment.Array!, segment.Offset, segment.Count, cancellationToken) + .ConfigureAwait(false); } - var bytesToRead = buffer.Length; - if (buffer.Length > _totalBytesLeftToRead) + var rented = ArrayPool.Shared.Rent(buffer.Length); + try { - bytesToRead = (int)_totalBytesLeftToRead; + var read = await ReadAsync(rented, 0, buffer.Length, cancellationToken) + .ConfigureAwait(false); + rented.AsSpan(0, read).CopyTo(buffer.Span); + return read; } - var read = await _stream - .ReadAsync(buffer.Slice(0, bytesToRead), cancellationToken) - .ConfigureAwait(false); - _totalBytesLeftToRead -= read; - - ReadTransformBlocks(buffer.Span, read); - - return read; - } - - private void ReadTransformBlocks(Span buffer, int count) - { - var posn = 0; - var remaining = count; - - while (posn < buffer.Length && remaining > 0) + finally { - var n = ReadTransformOneBlock(buffer, posn, remaining); - posn += n; - remaining -= n; - } - } - - private int ReadTransformOneBlock(Span buffer, int offset, int remaining) - { - if (_counterOutOffset == BLOCK_SIZE_IN_BYTES) - { - FillCounterOut(); - } - - var bytesToXor = Math.Min(BLOCK_SIZE_IN_BYTES - _counterOutOffset, remaining); - XorInPlace(buffer, offset, bytesToXor, _counterOutOffset); - _counterOutOffset += bytesToXor; - return bytesToXor; - } - - private void XorInPlace(Span buffer, int offset, int count, int counterOffset) - { - for (var i = 0; i < count; i++) - { - buffer[offset + i] = (byte)(_counterOut[counterOffset + i] ^ buffer[offset + i]); + ArrayPool.Shared.Return(rented, clearArray: true); } } #endif diff --git a/src/SharpCompress/Common/Zip/WinzipAesCryptoStream.cs b/src/SharpCompress/Common/Zip/WinzipAesCryptoStream.cs index 9d269473..88ccf6c8 100644 --- a/src/SharpCompress/Common/Zip/WinzipAesCryptoStream.cs +++ b/src/SharpCompress/Common/Zip/WinzipAesCryptoStream.cs @@ -1,9 +1,8 @@ using System; -using System.Buffers; using System.Buffers.Binary; using System.IO; +using System.Runtime.ExceptionServices; using System.Security.Cryptography; -using System.Threading.Tasks; namespace SharpCompress.Common.Zip; @@ -14,6 +13,9 @@ internal partial class WinzipAesCryptoStream : Stream private readonly byte[] _counter = new byte[BLOCK_SIZE_IN_BYTES]; private readonly Stream _stream; private readonly ICryptoTransform _transform; + private readonly HMACSHA1 _authentication; + private bool _authRead; + private ExceptionDispatchInfo? _authenticationFailure; private int _nonce = 1; private byte[] _counterOut = new byte[BLOCK_SIZE_IN_BYTES]; private int _counterOutOffset = BLOCK_SIZE_IN_BYTES; @@ -28,6 +30,14 @@ internal partial class WinzipAesCryptoStream : Stream { _stream = stream; _totalBytesLeftToRead = length; + if (length < 0) + { + throw new InvalidFormatException("Invalid WinZip AES payload size."); + } + // PBKDF2's second key is the authentication key, not the CTR initialization vector. +#pragma warning disable CA5350 // WinZip AES mandates HMAC-SHA1-80 for interoperability. + _authentication = new HMACSHA1(winzipAesEncryptionData.IvBytes); +#pragma warning restore CA5350 _cipher = CreateCipher(winzipAesEncryptionData); @@ -69,45 +79,85 @@ internal partial class WinzipAesCryptoStream : Stream _isDisposed = true; if (disposing) { - // Read out last 10 auth bytes - catch exceptions for async-only streams - if (Utility.UseSyncOverAsyncDispose()) + try { - var ten = ArrayPool.Shared.Rent(10); - try + if (!_authRead && Utility.UseSyncOverAsyncDispose()) { #pragma warning disable VSTHRD002 // Avoid problematic synchronous waits #pragma warning disable CA2012 - _stream.ReadFullyAsync(ten, 0, 10).GetAwaiter().GetResult(); + DrainAndVerifyAsync().GetAwaiter().GetResult(); #pragma warning restore CA2012 #pragma warning restore VSTHRD002 // Avoid problematic synchronous waits } - finally + else if (!_authRead) { - ArrayPool.Shared.Return(ten); + var buffer = new byte[4096]; + while (_totalBytesLeftToRead > 0) + { + Read(buffer, 0, buffer.Length); + } + VerifyAuthentication(); } } - else + finally { - Span ten = stackalloc byte[10]; - _stream.ReadFully(ten); + _authentication.Dispose(); + _transform.Dispose(); + _cipher.Dispose(); + _stream.Dispose(); } - _stream.Dispose(); } base.Dispose(disposing); } - private async ValueTask ReadAuthBytesAsync() + private void VerifyAuthentication() { - byte[] authBytes = new byte[10]; - await _stream.ReadFullyAsync(authBytes, 0, 10).ConfigureAwait(false); + _authenticationFailure?.Throw(); + if (_authRead) + { + return; + } + _authRead = true; + var tag = new byte[10]; + try + { + _stream.ReadExact(tag, 0, tag.Length); + ValidateAuthentication(tag); + } + catch (Exception exception) + { + _authenticationFailure = ExceptionDispatchInfo.Capture(exception); + throw; + } + } + + private void ValidateAuthentication(byte[] tag) + { + _authentication.TransformFinalBlock(Array.Empty(), 0, 0); + var expected = _authentication.Hash!; + var difference = 0; + for (var i = 0; i < tag.Length; i++) + { + difference |= tag[i] ^ expected[i]; + } + if (difference != 0) + { + throw new InvalidFormatException("WinZip AES authentication failed."); + } } public override void Flush() { } public override int Read(byte[] buffer, int offset, int count) { + _authenticationFailure?.Throw(); + if (count == 0) + { + return 0; + } if (_totalBytesLeftToRead == 0) { + VerifyAuthentication(); return 0; } var bytesToRead = count; @@ -116,7 +166,17 @@ internal partial class WinzipAesCryptoStream : Stream bytesToRead = (int)_totalBytesLeftToRead; } var read = _stream.Read(buffer, offset, bytesToRead); + if (read == 0) + { + _authRead = true; + throw new IncompleteArchiveException("Unexpected end of WinZip AES ciphertext."); + } + _authentication.TransformBlock(buffer, offset, read, null, 0); _totalBytesLeftToRead -= read; + if (_totalBytesLeftToRead == 0) + { + VerifyAuthentication(); + } ReadTransformBlocks(buffer, offset, read); diff --git a/tests/SharpCompress.Test/Streams/WinzipAesCryptoStreamTests.cs b/tests/SharpCompress.Test/Streams/WinzipAesCryptoStreamTests.cs index 578f6212..8b879981 100644 --- a/tests/SharpCompress.Test/Streams/WinzipAesCryptoStreamTests.cs +++ b/tests/SharpCompress.Test/Streams/WinzipAesCryptoStreamTests.cs @@ -4,14 +4,175 @@ using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Security.Cryptography; +using System.Threading; using System.Threading.Tasks; using SharpCompress.Common.Zip; +using SharpCompress.Test.Mocks; using Xunit; namespace SharpCompress.Test.Streams; public class WinzipAesCryptoStreamTests { + [Fact] + public void Dispose_Verifies_Unread_Ciphertext() + { + byte[] salt = [0x10, 0x21, 0x32, 0x43, 0x54, 0x65, 0x76, 0x87]; + var stream = CreateStream( + new byte[33], + "password", + salt, + fragmented: true, + corruption: "ciphertext" + ); + Assert.Equal(1, stream.Read(new byte[1], 0, 1)); + Assert.Throws(() => stream.Dispose()); + stream.Dispose(); + } + +#if !LEGACY_DOTNET + [Fact] + public async Task DisposeAsync_Verifies_Unread_Ciphertext() + { + byte[] salt = [0x10, 0x21, 0x32, 0x43, 0x54, 0x65, 0x76, 0x87]; + var stream = CreateStream( + new byte[33], + "password", + salt, + fragmented: true, + corruption: "ciphertext" + ); + Assert.Equal(1, await stream.ReadAsync(new byte[1], 0, 1, CancellationToken.None)); + await Assert.ThrowsAsync(async () => + await stream.DisposeAsync() + ); + await stream.DisposeAsync(); + } +#endif + + [Theory] + [InlineData(false, 0)] + [InlineData(true, 0)] + [InlineData(false, 33)] + [InlineData(true, 33)] + public async Task Authentication_Allows_Fragmented_Reads_And_Empty_Payloads( + bool async, + int length + ) + { + byte[] salt = [0x10, 0x21, 0x32, 0x43, 0x54, 0x65, 0x76, 0x87]; + byte[] payload = Enumerable.Range(0, length).Select(i => (byte)i).ToArray(); + using var stream = CreateStream(payload, "password", salt, fragmented: true); + Assert.Equal(0, stream.Read(Array.Empty(), 0, 0)); + using var output = new MemoryStream(); + if (async) + { + await stream.CopyToAsync(output, 81920, CancellationToken.None); + } + else + { + stream.CopyTo(output); + } + Assert.Equal(payload, output.ToArray()); + } + + [Theory] + [InlineData(false, "ciphertext")] + [InlineData(true, "ciphertext")] + [InlineData(false, "missing-tag")] + [InlineData(true, "missing-tag")] + public async Task Authentication_Rejects_Tampering_And_Truncated_Tags( + bool async, + string corruption + ) + { + byte[] salt = [0x10, 0x21, 0x32, 0x43, 0x54, 0x65, 0x76, 0x87]; + using var stream = CreateStream( + new byte[33], + "password", + salt, + fragmented: true, + corruption: corruption + ); + using var output = new MemoryStream(); + if (async) + { + var error = await Record.ExceptionAsync(async () => + await stream.CopyToAsync(output, 81920, CancellationToken.None) + ); + Assert.Equal( + corruption == "ciphertext" + ? typeof(SharpCompress.Common.InvalidFormatException) + : typeof(SharpCompress.Common.IncompleteArchiveException), + error!.GetType() + ); + Assert.Same( + error, + await Record.ExceptionAsync(async () => + await stream.ReadAsync(new byte[1], 0, 1, CancellationToken.None) + ) + ); + } + else + { + var error = Record.Exception(() => stream.CopyTo(output)); + Assert.Equal( + corruption == "ciphertext" + ? typeof(SharpCompress.Common.InvalidFormatException) + : typeof(SharpCompress.Common.IncompleteArchiveException), + error!.GetType() + ); + Assert.Same(error, Record.Exception(() => stream.Read(new byte[1], 0, 1))); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Read_Rejects_Modified_Authentication_Tag(bool async) + { + byte[] salt = [0x10, 0x21, 0x32, 0x43, 0x54, 0x65, 0x76, 0x87]; + var data = CreateEncryptionData("password", salt); + var cipher = EncryptCtr(new byte[32], data.KeyBytes); + using var input = new MemoryStream(cipher.Concat(new byte[10]).ToArray()); + using var stream = new WinzipAesCryptoStream(input, data, cipher.Length); + if (async) + { + await Assert.ThrowsAsync(async () => + await stream.ReadAsync(new byte[32], 0, 32) + ); + } + else + { + Assert.Throws(() => + stream.Read(new byte[32], 0, 32) + ); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Read_Rejects_Truncated_Ciphertext(bool async) + { + byte[] salt = [0x10, 0x21, 0x32, 0x43, 0x54, 0x65, 0x76, 0x87]; + var data = CreateEncryptionData("password", salt); + using var input = new MemoryStream(); + using var stream = new WinzipAesCryptoStream(input, data, 32); + if (async) + { + await Assert.ThrowsAsync(async () => + await stream.ReadAsync(new byte[32], 0, 32) + ); + } + else + { + Assert.Throws(() => + stream.Read(new byte[32], 0, 32) + ); + } + } + [Fact] public void Read_Decrypts_Data_For_Aligned_Buffer_Size() { @@ -104,17 +265,33 @@ public class WinzipAesCryptoStreamTests private static WinzipAesCryptoStream CreateStream( byte[] plainText, string password, - byte[] salt + byte[] salt, + bool fragmented = false, + string? corruption = null ) { var encryptionData = CreateEncryptionData(password, salt); byte[] cipherText = EncryptCtr(plainText, encryptionData.KeyBytes); - byte[] archiveBytes = cipherText.Concat(new byte[10]).ToArray(); - return new WinzipAesCryptoStream( - new MemoryStream(archiveBytes, writable: false), - encryptionData, - cipherText.Length - ); +#pragma warning disable CA5350 // WinZip AES mandates HMAC-SHA1-80. + using var authentication = new HMACSHA1(encryptionData.IvBytes); +#pragma warning restore CA5350 + byte[] archiveBytes = cipherText + .Concat(authentication.ComputeHash(cipherText).Take(10)) + .ToArray(); + if (corruption == "ciphertext") + { + archiveBytes[0] ^= 1; + } + else if (corruption == "missing-tag") + { + archiveBytes = archiveBytes.Take(archiveBytes.Length - 1).ToArray(); + } + Stream input = new MemoryStream(archiveBytes, writable: false); + if (fragmented) + { + input = new ShortReadStream(input); + } + return new WinzipAesCryptoStream(input, encryptionData, cipherText.Length); } [SuppressMessage( From 17f26064f979fb84d08470e48c7f1e62b232f953 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 15:40:34 +0100 Subject: [PATCH 04/18] Read ZIP AES encryption metadata exactly on fragmented streams --- .../Common/Zip/ZipHeaderFactory.cs | 4 +- .../Zip/ZipAesShortReadTests.cs | 78 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 tests/SharpCompress.Test/Zip/ZipAesShortReadTests.cs diff --git a/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs b/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs index e1ebb8c1..927ab1ed 100644 --- a/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs +++ b/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs @@ -160,8 +160,8 @@ internal partial class ZipHeaderFactory var salt = new byte[WinzipAesEncryptionData.KeyLengthInBytes(keySize) / 2]; var passwordVerifyValue = new byte[2]; - stream.Read(salt, 0, salt.Length); - stream.Read(passwordVerifyValue, 0, 2); + stream.ReadExact(salt, 0, salt.Length); + stream.ReadExact(passwordVerifyValue, 0, 2); entryHeader.WinzipAesEncryptionData = new WinzipAesEncryptionData( keySize, salt, diff --git a/tests/SharpCompress.Test/Zip/ZipAesShortReadTests.cs b/tests/SharpCompress.Test/Zip/ZipAesShortReadTests.cs new file mode 100644 index 00000000..e2af629f --- /dev/null +++ b/tests/SharpCompress.Test/Zip/ZipAesShortReadTests.cs @@ -0,0 +1,78 @@ +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives.Zip; +using SharpCompress.Readers; +using SharpCompress.Readers.Zip; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test.Zip; + +public class ZipAesShortReadTests : TestBase +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Zip_Aes_Archive_Reads_Fragmented_Metadata(bool async) + { + using var input = new ShortReadStream( + File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.WinzipAES.zip")) + ); + using var output = new MemoryStream(); + var options = new ReaderOptions { Password = "test", LeaveStreamOpen = true }; + if (async) + { + await using var archive = await ZipArchive.OpenAsyncArchive( + input, + options, + CancellationToken.None + ); + await foreach (var entry in archive.EntriesAsync) + { + if (entry.IsDirectory) + { + continue; + } + using var entryStream = await entry.OpenEntryStreamAsync(CancellationToken.None); + await entryStream.CopyToAsync(output, 81920, CancellationToken.None); + } + } + else + { + using var archive = ZipArchive.OpenArchive(input, options); + foreach (var entry in archive.Entries.Where(e => !e.IsDirectory)) + { + using var entryStream = entry.OpenEntryStream(); + entryStream.CopyTo(output); + } + } + Assert.True(output.Length > 0); + Assert.False(input.IsDisposed); + } + + [Fact] + public void Zip_Aes_Reader_Reads_Fragmented_Metadata() + { + using var input = new ShortReadStream( + File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.WinzipAES.zip")), + seekable: false + ); + using var reader = ZipReader.OpenReader( + input, + new ReaderOptions { Password = "test", LeaveStreamOpen = true } + ); + using var output = new MemoryStream(); + while (reader.MoveToNextEntry()) + { + if (reader.Entry.IsDirectory) + { + continue; + } + using var entryStream = reader.OpenEntryStream(); + entryStream.CopyTo(output); + } + Assert.True(output.Length > 0); + } +} From d01539ae3ce0ee42092bf2bcf1c76daffa934c4f Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 15:40:35 +0100 Subject: [PATCH 05/18] Handle partial LZip headers and validate dictionary sizes consistently --- .../Compressors/LZMA/LZipStream.Async.cs | 14 ++------- .../Compressors/LZMA/LZipStream.cs | 21 ++++++++----- .../Streams/LZipHeaderReadTests.cs | 30 +++++++++++++++++++ 3 files changed, 45 insertions(+), 20 deletions(-) create mode 100644 tests/SharpCompress.Test/Streams/LZipHeaderReadTests.cs diff --git a/src/SharpCompress/Compressors/LZMA/LZipStream.Async.cs b/src/SharpCompress/Compressors/LZMA/LZipStream.Async.cs index 949fbc97..36929d8a 100644 --- a/src/SharpCompress/Compressors/LZMA/LZipStream.Async.cs +++ b/src/SharpCompress/Compressors/LZMA/LZipStream.Async.cs @@ -129,11 +129,7 @@ public sealed partial class LZipStream var header = ArrayPool.Shared.Rent(6); try { - var n = await stream.ReadAsync(header, 0, 6, cancellationToken).ConfigureAwait(false); - - // TODO: Handle reading only part of the header? - - if (n != 6) + if (!await stream.ReadFullyAsync(header, 0, 6, cancellationToken).ConfigureAwait(false)) { return 0; } @@ -148,13 +144,7 @@ public sealed partial class LZipStream { return 0; } - var basePower = header[5] & 0x1F; - var subtractionNumerator = (header[5] & 0xE0) >> 5; - if (basePower < 4 || basePower > 30) - { - return 0; - } - return (1 << basePower) - (subtractionNumerator * (1 << (basePower - 4))); + return DecodeDictionarySize(header[5]); } finally { diff --git a/src/SharpCompress/Compressors/LZMA/LZipStream.cs b/src/SharpCompress/Compressors/LZMA/LZipStream.cs index f2e1abea..7379639d 100644 --- a/src/SharpCompress/Compressors/LZMA/LZipStream.cs +++ b/src/SharpCompress/Compressors/LZMA/LZipStream.cs @@ -253,11 +253,7 @@ public sealed partial class LZipStream : Stream, IFinishable { // Read the header Span header = stackalloc byte[6]; - var n = stream.Read(header); - - // TODO: Handle reading only part of the header? - - if (n != 6) + if (!stream.ReadFully(header)) { return 0; } @@ -272,9 +268,18 @@ public sealed partial class LZipStream : Stream, IFinishable { return 0; } - var basePower = header[5] & 0x1F; - var subtractionNumerator = (header[5] & 0xE0) >> 5; - return (1 << basePower) - (subtractionNumerator * (1 << (basePower - 4))); + return DecodeDictionarySize(header[5]); + } + + private static int DecodeDictionarySize(byte encoded) + { + var basePower = encoded & 0x1F; + if (basePower < 12 || basePower > 29) + { + return 0; + } + var size = (1 << basePower) - ((encoded >> 5) * (1 << (basePower - 4))); + return size >= 4096 ? size : 0; } // Async methods moved to LZipStream.Async.cs diff --git a/tests/SharpCompress.Test/Streams/LZipHeaderReadTests.cs b/tests/SharpCompress.Test/Streams/LZipHeaderReadTests.cs new file mode 100644 index 00000000..7d45b6c9 --- /dev/null +++ b/tests/SharpCompress.Test/Streams/LZipHeaderReadTests.cs @@ -0,0 +1,30 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Compressors.LZMA; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test.Streams; + +public class LZipHeaderReadTests +{ + [Theory] + [InlineData(12, 4096)] + [InlineData(29, 536870912)] + [InlineData(11, 0)] + [InlineData(30, 0)] + [InlineData(31, 0)] + [InlineData(44, 0)] + public async Task LZip_Header_Fragmentation_And_Dictionary_Validation(int encoded, int expected) + { + byte[] header = [0x4C, 0x5A, 0x49, 0x50, 1, (byte)encoded]; + using var sync = new ShortReadStream(new MemoryStream(header)); + using var asyncInput = new ShortReadStream(new MemoryStream(header)); + Assert.Equal(expected, LZipStream.ValidateAndReadSize(sync)); + Assert.Equal( + expected, + await LZipStream.ValidateAndReadSizeAsync(asyncInput, CancellationToken.None) + ); + } +} From b442327e5fa3f7656c2fd7b5b4074b6b4b7f2958 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 15:40:35 +0100 Subject: [PATCH 06/18] Allow partial reads in ACE signature detection --- .../Common/Ace/Headers/AceHeader.cs | 2 +- .../Ace/AceHeaderReadTests.cs | 21 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 tests/SharpCompress.Test/Ace/AceHeaderReadTests.cs diff --git a/src/SharpCompress/Common/Ace/Headers/AceHeader.cs b/src/SharpCompress/Common/Ace/Headers/AceHeader.cs index 8d270558..ba0899a7 100644 --- a/src/SharpCompress/Common/Ace/Headers/AceHeader.cs +++ b/src/SharpCompress/Common/Ace/Headers/AceHeader.cs @@ -100,7 +100,7 @@ public abstract partial class AceHeader // First two bytes are typically 0x60 0xEA (signature bytes) // At offset 7, there should be "**ACE**" (7 bytes) var bytes = new byte[14]; - if (stream.Read(bytes, 0, 14) != 14) + if (!stream.ReadFully(bytes)) { return false; } diff --git a/tests/SharpCompress.Test/Ace/AceHeaderReadTests.cs b/tests/SharpCompress.Test/Ace/AceHeaderReadTests.cs new file mode 100644 index 00000000..c7007400 --- /dev/null +++ b/tests/SharpCompress.Test/Ace/AceHeaderReadTests.cs @@ -0,0 +1,21 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Common.Ace.Headers; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test.Ace; + +public class AceHeaderReadTests +{ + [Fact] + public async Task Ace_Signature_Allows_Partial_Reads() + { + byte[] header = [0, 0, 0, 0, 0, 0, 0, 42, 42, 65, 67, 69, 42, 42]; + using var sync = new ShortReadStream(new MemoryStream(header)); + using var asyncInput = new ShortReadStream(new MemoryStream(header)); + Assert.True(AceHeader.IsArchive(sync)); + Assert.True(await AceHeader.IsArchiveAsync(asyncInput, CancellationToken.None)); + } +} From 2a68ec725704ca82a491d12a1fa4f79c23628ea6 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 15:40:35 +0100 Subject: [PATCH 07/18] Read ARC entry headers fully on fragmented streams --- .../Common/Arc/ArcEntryHeader.cs | 8 ++---- .../Arc/ArcHeaderReadTests.cs | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 tests/SharpCompress.Test/Arc/ArcHeaderReadTests.cs diff --git a/src/SharpCompress/Common/Arc/ArcEntryHeader.cs b/src/SharpCompress/Common/Arc/ArcEntryHeader.cs index 983b1b78..1fb91983 100644 --- a/src/SharpCompress/Common/Arc/ArcEntryHeader.cs +++ b/src/SharpCompress/Common/Arc/ArcEntryHeader.cs @@ -26,7 +26,7 @@ public class ArcEntryHeader public ArcEntryHeader? ReadHeader(Stream stream) { byte[] headerBytes = new byte[29]; - if (stream.Read(headerBytes, 0, headerBytes.Length) != headerBytes.Length) + if (!stream.ReadFully(headerBytes)) { return null; } @@ -40,11 +40,7 @@ public class ArcEntryHeader ) { byte[] headerBytes = new byte[29]; - if ( - await stream - .ReadAsync(headerBytes, 0, headerBytes.Length, cancellationToken) - .ConfigureAwait(false) != headerBytes.Length - ) + if (!await stream.ReadFullyAsync(headerBytes, cancellationToken).ConfigureAwait(false)) { return null; } diff --git a/tests/SharpCompress.Test/Arc/ArcHeaderReadTests.cs b/tests/SharpCompress.Test/Arc/ArcHeaderReadTests.cs new file mode 100644 index 00000000..147ebc42 --- /dev/null +++ b/tests/SharpCompress.Test/Arc/ArcHeaderReadTests.cs @@ -0,0 +1,26 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Common; +using SharpCompress.Common.Arc; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test.Arc; + +public class ArcHeaderReadTests +{ + [Fact] + public async Task Arc_Header_Allows_Partial_Reads() + { + var header = new byte[29]; + header[0] = 0x1A; + header[1] = 2; + header[2] = (byte)'x'; + using var sync = new ShortReadStream(new MemoryStream(header)); + using var asyncInput = new ShortReadStream(new MemoryStream(header)); + var parser = new ArcEntryHeader(new ArchiveEncoding()); + Assert.Equal("x", parser.ReadHeader(sync)!.Name); + Assert.Equal("x", (await parser.ReadHeaderAsync(asyncInput, CancellationToken.None))!.Name); + } +} From 13ed208a2bcd490b4814303b08d1ad1f6d3da926 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 15:40:36 +0100 Subject: [PATCH 08/18] Read ARJ headers and extended headers exactly --- .../Common/Arj/Headers/ArjHeader.Async.cs | 28 +++------- .../Common/Arj/Headers/ArjHeader.cs | 20 +++---- .../Arj/ArjHeaderReadTests.cs | 53 +++++++++++++++++++ 3 files changed, 69 insertions(+), 32 deletions(-) create mode 100644 tests/SharpCompress.Test/Arj/ArjHeaderReadTests.cs diff --git a/src/SharpCompress/Common/Arj/Headers/ArjHeader.Async.cs b/src/SharpCompress/Common/Arj/Headers/ArjHeader.Async.cs index 660fbc53..56193611 100644 --- a/src/SharpCompress/Common/Arj/Headers/ArjHeader.Async.cs +++ b/src/SharpCompress/Common/Arj/Headers/ArjHeader.Async.cs @@ -21,7 +21,7 @@ public abstract partial class ArjHeader { // check for magic bytes var magic = new byte[2]; - if (await stream.ReadAsync(magic, 0, 2, cancellationToken).ConfigureAwait(false) != 2) + if (!await stream.ReadFullyAsync(magic, cancellationToken).ConfigureAwait(false)) { return Array.Empty(); } @@ -33,7 +33,7 @@ public abstract partial class ArjHeader // read header_size byte[] headerBytes = new byte[2]; - await stream.ReadAsync(headerBytes, 0, 2, cancellationToken).ConfigureAwait(false); + await stream.ReadExactAsync(headerBytes, 0, 2, cancellationToken).ConfigureAwait(false); var headerSize = (ushort)(headerBytes[0] | headerBytes[1] << 8); if (headerSize < 1) { @@ -41,16 +41,13 @@ public abstract partial class ArjHeader } var body = new byte[headerSize]; - var read = await stream - .ReadAsync(body, 0, headerSize, cancellationToken) - .ConfigureAwait(false); - if (read < headerSize) + if (!await stream.ReadFullyAsync(body, cancellationToken).ConfigureAwait(false)) { return Array.Empty(); } byte[] crc = new byte[4]; - await stream.ReadFullyAsync(crc, 0, 4, cancellationToken).ConfigureAwait(false); + await stream.ReadExactAsync(crc, 0, 4, cancellationToken).ConfigureAwait(false); var checksum = Crc32Stream.Compute(body); // Compute the hash value if (checksum != BitConverter.ToUInt32(crc, 0)) @@ -70,10 +67,7 @@ public abstract partial class ArjHeader while (true) { - int bytesRead = await reader - .ReadAsync(buffer, 0, 2, cancellationToken) - .ConfigureAwait(false); - if (bytesRead < 2) + if (!await reader.ReadFullyAsync(buffer, cancellationToken).ConfigureAwait(false)) { throw new IncompleteArchiveException( "Unexpected end of stream while reading extended header size." @@ -87,10 +81,7 @@ public abstract partial class ArjHeader } byte[] header = new byte[extHeaderSize]; - bytesRead = await reader - .ReadAsync(header, 0, extHeaderSize, cancellationToken) - .ConfigureAwait(false); - if (bytesRead < extHeaderSize) + if (!await reader.ReadFullyAsync(header, cancellationToken).ConfigureAwait(false)) { throw new IncompleteArchiveException( "Unexpected end of stream while reading extended header data." @@ -98,10 +89,7 @@ public abstract partial class ArjHeader } byte[] crcextended = new byte[4]; - bytesRead = await reader - .ReadAsync(crcextended, 0, 4, cancellationToken) - .ConfigureAwait(false); - if (bytesRead < 4) + if (!await reader.ReadFullyAsync(crcextended, cancellationToken).ConfigureAwait(false)) { throw new IncompleteArchiveException( "Unexpected end of stream while reading extended header CRC." @@ -130,7 +118,7 @@ public abstract partial class ArjHeader ) { var bytes = new byte[2]; - if (await stream.ReadAsync(bytes, 0, 2, cancellationToken).ConfigureAwait(false) != 2) + if (!await stream.ReadFullyAsync(bytes, cancellationToken).ConfigureAwait(false)) { return false; } diff --git a/src/SharpCompress/Common/Arj/Headers/ArjHeader.cs b/src/SharpCompress/Common/Arj/Headers/ArjHeader.cs index f974e44a..ad1cf78f 100644 --- a/src/SharpCompress/Common/Arj/Headers/ArjHeader.cs +++ b/src/SharpCompress/Common/Arj/Headers/ArjHeader.cs @@ -37,7 +37,7 @@ public abstract partial class ArjHeader { // check for magic bytes var magic = new byte[2]; - if (stream.Read(magic) != 2) + if (!stream.ReadFully(magic)) { return Array.Empty(); } @@ -49,7 +49,7 @@ public abstract partial class ArjHeader // read header_size byte[] headerBytes = new byte[2]; - stream.Read(headerBytes, 0, 2); + stream.ReadExact(headerBytes, 0, 2); var headerSize = (ushort)(headerBytes[0] | headerBytes[1] << 8); if (headerSize < 1) { @@ -57,14 +57,13 @@ public abstract partial class ArjHeader } var body = new byte[headerSize]; - var read = stream.Read(body, 0, headerSize); - if (read < headerSize) + if (!stream.ReadFully(body)) { return Array.Empty(); } byte[] crc = new byte[4]; - read = stream.Read(crc, 0, 4); + stream.ReadExact(crc, 0, 4); var checksum = Crc32Stream.Compute(body); // Compute the hash value if (checksum != BitConverter.ToUInt32(crc, 0)) @@ -83,8 +82,7 @@ public abstract partial class ArjHeader while (true) { - int bytesRead = reader.Read(buffer, 0, 2); - if (bytesRead < 2) + if (!reader.ReadFully(buffer)) { throw new IncompleteArchiveException( "Unexpected end of stream while reading extended header size." @@ -98,8 +96,7 @@ public abstract partial class ArjHeader } byte[] header = new byte[extHeaderSize]; - bytesRead = reader.Read(header, 0, extHeaderSize); - if (bytesRead < extHeaderSize) + if (!reader.ReadFully(header)) { throw new IncompleteArchiveException( "Unexpected end of stream while reading extended header data." @@ -107,8 +104,7 @@ public abstract partial class ArjHeader } byte[] crc = new byte[4]; - bytesRead = reader.Read(crc, 0, 4); - if (bytesRead < 4) + if (!reader.ReadFully(crc)) { throw new IncompleteArchiveException( "Unexpected end of stream while reading extended header CRC." @@ -147,7 +143,7 @@ public abstract partial class ArjHeader public static bool IsArchive(Stream stream) { var bytes = new byte[2]; - if (stream.Read(bytes, 0, 2) != 2) + if (!stream.ReadFully(bytes)) { return false; } diff --git a/tests/SharpCompress.Test/Arj/ArjHeaderReadTests.cs b/tests/SharpCompress.Test/Arj/ArjHeaderReadTests.cs new file mode 100644 index 00000000..a5c07c27 --- /dev/null +++ b/tests/SharpCompress.Test/Arj/ArjHeaderReadTests.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Common; +using SharpCompress.Common.Arj.Headers; +using SharpCompress.Crypto; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test.Arj; + +public class ArjHeaderReadTests +{ + [Fact] + public async Task Arj_Header_Allows_Partial_Reads() + { + byte[] header = [0x60, 0xEA, 1, 0, 42, 0, 0, 0, 0]; + BitConverter.GetBytes(Crc32Stream.Compute(new byte[] { 42 })).CopyTo(header, 5); + var parser = new ArjMainHeader(new ArchiveEncoding()); + using var sync = new ShortReadStream(new MemoryStream(header)); + using var asyncInput = new ShortReadStream(new MemoryStream(header)); + Assert.Equal(new byte[] { 42 }, parser.ReadHeader(sync)); + Assert.Equal( + new byte[] { 42 }, + await parser.ReadHeaderAsync(asyncInput, CancellationToken.None) + ); + } + + [Fact] + public async Task Arj_Extended_Header_Allows_Partial_Reads() + { + byte[] header = [1, 0, 42, 0, 0, 0, 0, 0, 0]; + BitConverter.GetBytes(Crc32Stream.Compute(new byte[] { 42 })).CopyTo(header, 3); + using var sync = new ShortReadStream(new MemoryStream(header)); + using var asyncInput = new ShortReadStream(new MemoryStream(header)); + var parser = new ExtendedArjHeader(); + Assert.Equal(new byte[] { 42 }, Assert.Single(parser.ReadExtended(sync))); + Assert.Equal(new byte[] { 42 }, Assert.Single(await parser.ReadExtendedAsync(asyncInput))); + } + + private sealed class ExtendedArjHeader : ArjMainHeader + { + public ExtendedArjHeader() + : base(new ArchiveEncoding()) { } + + public List ReadExtended(Stream input) => ReadExtendedHeaders(input); + + public ValueTask> ReadExtendedAsync(Stream input) => + ReadExtendedHeadersAsync(input, CancellationToken.None); + } +} From 06fea02e64ad2a55ff194210b78d08b19c1db82f Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 15:40:36 +0100 Subject: [PATCH 09/18] Resolve RAR companion volumes with unambiguous case-insensitive fallback --- docs/FORMATS.md | 1 + .../Archives/ArchiveVolumeFactory.cs | 27 +++++++++-- .../Archives/Rar/RarArchiveVolumeFactory.cs | 7 +-- .../Archives/Zip/ZipArchiveVolumeFactory.cs | 27 +---------- .../Rar/RarVolumeCasingTests.cs | 45 +++++++++++++++++++ 5 files changed, 72 insertions(+), 35 deletions(-) create mode 100644 tests/SharpCompress.Test/Rar/RarVolumeCasingTests.cs diff --git a/docs/FORMATS.md b/docs/FORMATS.md index fe8b8fa8..3ca381a8 100644 --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -44,6 +44,7 @@ ### Rar Format Notes - RAR5 encrypted headers and data accept KDF counts from 0 through 24 (the binary logarithm of the PBKDF2 iteration count), matching UnRAR's acceptance limit. Larger counts are rejected before key derivation. +- RAR companion filenames use the same exact-match-first, case-insensitive fallback as ZIP, with ambiguous fallback matches rejected. - Encrypted ciphertext must contain complete AES blocks. Truncation throws `InvalidFormatException` on synchronous reads or `IncompleteArchiveException` on asynchronous reads. ### 7Zip Format Notes diff --git a/src/SharpCompress/Archives/ArchiveVolumeFactory.cs b/src/SharpCompress/Archives/ArchiveVolumeFactory.cs index 92ed6818..c0da8ff3 100644 --- a/src/SharpCompress/Archives/ArchiveVolumeFactory.cs +++ b/src/SharpCompress/Archives/ArchiveVolumeFactory.cs @@ -28,11 +28,32 @@ internal abstract class ArchiveVolumeFactory ); } - if (item != null && item.Exists) + return item is null ? null : ResolveFilePart(item); + } + + internal static FileInfo? ResolveFilePart(FileInfo item) + { + if (item.Exists) { return item; } - - return null; + if (!item.Directory!.Exists) + { + return null; + } + FileInfo? match = null; + foreach (var file in item.Directory.EnumerateFiles()) + { + if (!string.Equals(file.Name, item.Name, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + if (match is not null) + { + throw new InvalidFormatException($"Ambiguous archive volume name: {item.Name}"); + } + match = file; + } + return match; } } diff --git a/src/SharpCompress/Archives/Rar/RarArchiveVolumeFactory.cs b/src/SharpCompress/Archives/Rar/RarArchiveVolumeFactory.cs index 1e464876..8a1f03b6 100644 --- a/src/SharpCompress/Archives/Rar/RarArchiveVolumeFactory.cs +++ b/src/SharpCompress/Archives/Rar/RarArchiveVolumeFactory.cs @@ -61,11 +61,6 @@ internal static class RarArchiveVolumeFactory } } - if (item != null && item.Exists) - { - return item; - } - - return null; //no more items + return item is null ? null : ArchiveVolumeFactory.ResolveFilePart(item); } } diff --git a/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs b/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs index fd034da3..0ab64be0 100644 --- a/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs +++ b/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs @@ -32,31 +32,6 @@ internal static class ZipArchiveVolumeFactory return ArchiveVolumeFactory.GetFilePart(index, part1); } - if (item != null && item.Exists) - { - return item; - } - - // Generated suffixes are lowercase, but split archives can use any on-disk casing. - // Prefer the exact path above and never choose arbitrarily between fallback matches. - if (item is not null && item.Directory!.Exists) - { - FileInfo? match = null; - foreach (var file in item.Directory!.EnumerateFiles()) - { - if (!string.Equals(file.Name, item.Name, StringComparison.OrdinalIgnoreCase)) - { - continue; - } - if (match is not null) - { - throw new InvalidFormatException($"Ambiguous ZIP volume name: {item.Name}"); - } - match = file; - } - return match; - } - - return null; //no more items + return item is null ? null : ArchiveVolumeFactory.ResolveFilePart(item); } } diff --git a/tests/SharpCompress.Test/Rar/RarVolumeCasingTests.cs b/tests/SharpCompress.Test/Rar/RarVolumeCasingTests.cs new file mode 100644 index 00000000..17257c0f --- /dev/null +++ b/tests/SharpCompress.Test/Rar/RarVolumeCasingTests.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; +using SharpCompress.Archives.Rar; +using SharpCompress.Common; +using Xunit; + +namespace SharpCompress.Test.Rar; + +public class RarVolumeCasingTests : TestBase +{ + [Theory] + [InlineData("sample.RAR", "SAMPLE.R00")] + [InlineData("sample.part01.RAR", "SAMPLE.PART02.rar")] + [InlineData("sample.rar.001", "SAMPLE.RAR.002")] + public void Companion_Lookup_Is_Case_Insensitive(string firstName, string nextName) + { + var first = new FileInfo(Path.Combine(SCRATCH_FILES_PATH, firstName)); + var next = Path.Combine(SCRATCH_FILES_PATH, nextName); + File.WriteAllBytes(next, []); + Assert.Equal( + next, + RarArchiveVolumeFactory.GetFilePart(1, first)!.FullName, + ignoreCase: true + ); + Assert.Null(RarArchiveVolumeFactory.GetFilePart(2, first)); + } + + [Fact] + public void Companion_Lookup_Prefers_Exact_Path_And_Rejects_Ambiguity() + { + var first = new FileInfo(Path.Combine(SCRATCH_FILES_PATH, "sample.rar")); + var upper = Path.Combine(SCRATCH_FILES_PATH, "SAMPLE.R00"); + var mixed = Path.Combine(SCRATCH_FILES_PATH, "Sample.r00"); + File.WriteAllBytes(upper, []); + if (File.Exists(mixed)) + { + return; // Case-insensitive filesystems cannot represent ambiguous companion names. + } + File.WriteAllBytes(mixed, []); + Assert.Throws(() => RarArchiveVolumeFactory.GetFilePart(1, first)); + var exact = Path.Combine(SCRATCH_FILES_PATH, "sample.r00"); + File.WriteAllBytes(exact, []); + Assert.Equal(exact, RarArchiveVolumeFactory.GetFilePart(1, first)!.FullName); + } +} From fed1d884c72f9ffc8eceb1d58e8bd7d36a355f8e Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 15:40:37 +0100 Subject: [PATCH 10/18] Do not treat zero-length checksum reads as end of entry --- .../Common/ChecksumValidationStream.cs | 20 ++++++++-- .../GZip/GZipChecksumValidationStream.cs | 20 ++++++++-- .../Streams/ChecksumReadRegressionTests.cs | 40 +++++++++++++++++++ 3 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 tests/SharpCompress.Test/Streams/ChecksumReadRegressionTests.cs diff --git a/src/SharpCompress/Common/ChecksumValidationStream.cs b/src/SharpCompress/Common/ChecksumValidationStream.cs index 1b14cc9d..2aea94b1 100644 --- a/src/SharpCompress/Common/ChecksumValidationStream.cs +++ b/src/SharpCompress/Common/ChecksumValidationStream.cs @@ -43,7 +43,10 @@ internal sealed class ChecksumValidationStream : Stream public override int Read(byte[] buffer, int offset, int count) { var read = _stream.Read(buffer, offset, count); - UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read); + if (count != 0) + { + UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read); + } return read; } @@ -51,7 +54,10 @@ internal sealed class ChecksumValidationStream : Stream public override int Read(Span buffer) { var read = _stream.Read(buffer); - UpdateAndValidateAtEof(buffer[..read], read); + if (!buffer.IsEmpty) + { + UpdateAndValidateAtEof(buffer[..read], read); + } return read; } #endif @@ -68,7 +74,10 @@ internal sealed class ChecksumValidationStream : Stream var read = await _stream .ReadAsync(buffer, offset, count, cancellationToken) .ConfigureAwait(false); - UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read); + if (count != 0) + { + UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read); + } return read; } @@ -79,7 +88,10 @@ internal sealed class ChecksumValidationStream : Stream ) { var read = await _stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - UpdateAndValidateAtEof(buffer.Span[..read], read); + if (!buffer.IsEmpty) + { + UpdateAndValidateAtEof(buffer.Span[..read], read); + } return read; } #endif diff --git a/src/SharpCompress/Common/GZip/GZipChecksumValidationStream.cs b/src/SharpCompress/Common/GZip/GZipChecksumValidationStream.cs index 512d7cc2..6763ec11 100644 --- a/src/SharpCompress/Common/GZip/GZipChecksumValidationStream.cs +++ b/src/SharpCompress/Common/GZip/GZipChecksumValidationStream.cs @@ -54,7 +54,10 @@ internal sealed class GZipChecksumValidationStream : Stream public override int Read(byte[] buffer, int offset, int count) { var read = _source.Read(buffer, offset, count); - UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read); + if (count != 0) + { + UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read); + } return read; } @@ -62,7 +65,10 @@ internal sealed class GZipChecksumValidationStream : Stream public override int Read(Span buffer) { var read = _source.Read(buffer); - UpdateAndValidateAtEof(buffer[..read], read); + if (!buffer.IsEmpty) + { + UpdateAndValidateAtEof(buffer[..read], read); + } return read; } #endif @@ -93,7 +99,10 @@ internal sealed class GZipChecksumValidationStream : Stream var read = await _source .ReadAsync(buffer, offset, count, cancellationToken) .ConfigureAwait(false); - UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read); + if (count != 0) + { + UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read); + } return read; } @@ -104,7 +113,10 @@ internal sealed class GZipChecksumValidationStream : Stream ) { var read = await _source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - UpdateAndValidateAtEof(buffer.Span[..read], read); + if (!buffer.IsEmpty) + { + UpdateAndValidateAtEof(buffer.Span[..read], read); + } return read; } #endif diff --git a/tests/SharpCompress.Test/Streams/ChecksumReadRegressionTests.cs b/tests/SharpCompress.Test/Streams/ChecksumReadRegressionTests.cs new file mode 100644 index 00000000..a6f3237d --- /dev/null +++ b/tests/SharpCompress.Test/Streams/ChecksumReadRegressionTests.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Common; +using SharpCompress.Common.GZip; +using SharpCompress.Crypto; +using Xunit; + +namespace SharpCompress.Test.Streams; + +public class ChecksumReadRegressionTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Zero_Length_Read_Does_Not_Validate(bool gzip) + { + byte[] payload = [1, 2, 3]; + var crc = Crc32Stream.Compute(payload); + using var input = new MemoryStream(payload); + using var raw = new MemoryStream(); + using Stream stream = gzip + ? new GZipChecksumValidationStream(input, raw, "test", crc, 3) + : new ChecksumValidationStream( + input, + new ChecksumDescriptor(ChecksumKind.Crc32, crc, true), + "test" + ); + Assert.Equal(0, stream.Read(Array.Empty(), 0, 0)); + Assert.Equal(0, await stream.ReadAsync(Array.Empty(), 0, 0, CancellationToken.None)); +#if !LEGACY_DOTNET + Assert.Equal(0, stream.Read(Span.Empty)); + Assert.Equal(0, await stream.ReadAsync(Memory.Empty, CancellationToken.None)); +#endif + using var output = new MemoryStream(); + await stream.CopyToAsync(output, 81920, CancellationToken.None); + Assert.Equal(payload, output.ToArray()); + } +} From f8881795837a6fa95889c1a0ab93f81d35c89e49 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 15:40:37 +0100 Subject: [PATCH 11/18] Preserve DEFLATE read-ahead bytes after one-byte output and EOF --- .../Compressors/Deflate/ZlibBaseStream.cs | 22 ++++----- .../Streams/DeflateReadAheadTests.cs | 48 +++++++++++++++++++ 2 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 tests/SharpCompress.Test/Streams/DeflateReadAheadTests.cs diff --git a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs index 66fd1732..6401169c 100644 --- a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs +++ b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs @@ -669,6 +669,7 @@ internal class ZlibBaseStream : Stream, IStreamStack #endif private bool nomoreinput; + private bool _readCompleted; private bool isDisposed; private string ReadZeroTerminatedString() @@ -898,7 +899,7 @@ internal class ZlibBaseStream : Stream, IStreamStack _z.NextOut = offset; _z.AvailableBytesOut = count; - if (count == 0) + if (count == 0 || _readCompleted) { return 0; } @@ -1021,6 +1022,7 @@ internal class ZlibBaseStream : Stream, IStreamStack } } + _readCompleted = !_wantCompress && rc == ZlibConstants.Z_STREAM_END; rc = (count - _z.AvailableBytesOut); // calculate CRC after reading @@ -1029,12 +1031,8 @@ internal class ZlibBaseStream : Stream, IStreamStack crc.SlurpBlock(buffer, offset, rc); } - if (rc == ZlibConstants.Z_STREAM_END && z.AvailableBytesIn != 0 && !_wantCompress) - { - //rewind the buffer - this.Rewind(z.AvailableBytesIn); - z.AvailableBytesIn = 0; - } + // Preserve read-ahead bytes for trailer validation or the entry stream's Flush. + // rc is now an output byte count, not a codec status. return rc; } @@ -1090,7 +1088,7 @@ internal class ZlibBaseStream : Stream, IStreamStack _z.NextOut = offset; _z.AvailableBytesOut = count; - if (count == 0) + if (count == 0 || _readCompleted) { return 0; } @@ -1215,6 +1213,7 @@ internal class ZlibBaseStream : Stream, IStreamStack } } + _readCompleted = !_wantCompress && rc == ZlibConstants.Z_STREAM_END; rc = (count - _z.AvailableBytesOut); // calculate CRC after reading @@ -1223,12 +1222,7 @@ internal class ZlibBaseStream : Stream, IStreamStack crc.SlurpBlock(buffer, offset, rc); } - if (rc == ZlibConstants.Z_STREAM_END && z.AvailableBytesIn != 0 && !_wantCompress) - { - //rewind the buffer - this.Rewind(z.AvailableBytesIn); - z.AvailableBytesIn = 0; - } + // Preserve read-ahead bytes, as in the synchronous path. return rc; } diff --git a/tests/SharpCompress.Test/Streams/DeflateReadAheadTests.cs b/tests/SharpCompress.Test/Streams/DeflateReadAheadTests.cs new file mode 100644 index 00000000..8476c8d2 --- /dev/null +++ b/tests/SharpCompress.Test/Streams/DeflateReadAheadTests.cs @@ -0,0 +1,48 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace SharpCompress.Test.Streams; + +public class DeflateReadAheadTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Deflate_One_Byte_Output_Preserves_ReadAhead(bool async) + { + using var compressed = new MemoryStream(); + using ( + var writer = new System.IO.Compression.DeflateStream( + compressed, + System.IO.Compression.CompressionMode.Compress, + leaveOpen: true + ) + ) + { + writer.WriteByte(42); + } + byte[] trailer = [1, 2, 3, 4, 5, 6, 7, 8]; + compressed.Write(trailer, 0, trailer.Length); + compressed.Position = 0; + using var decoder = new SharpCompress.Compressors.Deflate.DeflateStream( + compressed, + SharpCompress.Compressors.CompressionMode.Decompress + ); + var buffer = new byte[1]; + if (async) + { + Assert.Equal(1, await decoder.ReadAsync(buffer, 0, 1, CancellationToken.None)); + Assert.Equal(0, await decoder.ReadAsync(buffer, 0, 1, CancellationToken.None)); + } + else + { + Assert.Equal(1, decoder.Read(buffer, 0, 1)); + Assert.Equal(0, decoder.Read(buffer, 0, 1)); + } + Assert.Equal(42, buffer[0]); + using var remaining = decoder.InputBuffer; + Assert.Equal(trailer, remaining.ToArray()); + } +} From 953ce61564b2024bc165a6a2b55568ab2ae45332 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 15:40:38 +0100 Subject: [PATCH 12/18] Consume both GZip header CRC bytes and reject truncated fields --- .../Common/GZip/GZipFilePart.Async.cs | 7 +- src/SharpCompress/Common/GZip/GZipFilePart.cs | 6 +- .../Compressors/Deflate/ZlibBaseStream.cs | 14 +++- .../GZip/GZipExtraFieldTests.cs | 76 +++++++++++++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs b/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs index f6227168..7ff1f854 100644 --- a/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs +++ b/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs @@ -108,8 +108,11 @@ internal sealed partial class GZipFilePart } if ((header[3] & 0x02) == 0x02) { - var buf = new byte[1]; - _ = await _stream.ReadAsync(buf, 0, 1, cancellationToken).ConfigureAwait(false); // CRC16, ignore + var headerCrc = new byte[2]; + if (!await _stream.ReadFullyAsync(headerCrc, cancellationToken).ConfigureAwait(false)) + { + throw new ZlibException("Unexpected end-of-file reading GZIP header CRC."); + } } } diff --git a/src/SharpCompress/Common/GZip/GZipFilePart.cs b/src/SharpCompress/Common/GZip/GZipFilePart.cs index 35b25438..85de0611 100644 --- a/src/SharpCompress/Common/GZip/GZipFilePart.cs +++ b/src/SharpCompress/Common/GZip/GZipFilePart.cs @@ -133,7 +133,11 @@ internal sealed partial class GZipFilePart : FilePart } if ((header[3] & 0x02) == 0x02) { - _stream.ReadByte(); // CRC16, ignore + Span headerCrc = stackalloc byte[2]; + if (!_stream.ReadFully(headerCrc)) + { + throw new ZlibException("Unexpected end-of-file reading GZIP header CRC."); + } } } diff --git a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs index 6401169c..5054e37b 100644 --- a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs +++ b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs @@ -778,7 +778,12 @@ internal class ZlibBaseStream : Stream, IStreamStack } if ((header[3] & 0x02) == 0x02) { - Read(_buf1, 0, 1); // CRC16, ignore + Span headerCrc = stackalloc byte[2]; + if (!_stream.ReadFully(headerCrc)) + { + throw new ZlibException("Unexpected end-of-file reading GZIP header CRC."); + } + totalBytesRead += 2; } return totalBytesRead; @@ -848,7 +853,12 @@ internal class ZlibBaseStream : Stream, IStreamStack } if ((header[3] & 0x02) == 0x02) { - await _stream.ReadAsync(_buf1, 0, 1, cancellationToken).ConfigureAwait(false); // CRC16, ignore + var headerCrc = new byte[2]; + if (!await _stream.ReadFullyAsync(headerCrc, cancellationToken).ConfigureAwait(false)) + { + throw new ZlibException("Unexpected end-of-file reading GZIP header CRC."); + } + totalBytesRead += 2; } return totalBytesRead; diff --git a/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs b/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs index ae8d2712..5c189904 100644 --- a/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs +++ b/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs @@ -21,6 +21,82 @@ public class GZipExtraFieldTests { private static readonly byte[] EXPECTED_PAYLOAD = [.. "gzip payload"u8]; + [Theory] + [InlineData("Stream", false)] + [InlineData("Stream", true)] + [InlineData("Archive", false)] + [InlineData("Archive", true)] + [InlineData("Reader", false)] + [InlineData("Reader", true)] + public async Task GZip_Rejects_Truncated_Header_Crc(string api, bool async) + { + byte[] bytes = [0x1F, 0x8B, 8, 2, 0, 0, 0, 0, 0, 3, 0]; + using var input = new ShortReadStream(new MemoryStream(bytes)); + await Assert.ThrowsAsync(() => + ReadPayload(input, api, async, CancellationToken.None) + ); + Assert.False(input.IsDisposed); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task GZip_Header_Crc_Field_Is_Two_Bytes(bool async) + { + var original = CreateGZipWithExtraField(0, null); + original[3] |= 2; + var headerCrc = SharpCompress.Crypto.Crc32Stream.Compute(original.AsSpan(0, 12).ToArray()); + byte[] bytes = + [ + .. original.AsSpan(0, 12), + (byte)headerCrc, + (byte)(headerCrc >> 8), + .. original.AsSpan(12), + ]; + using var input = new ShortReadStream(new MemoryStream(bytes)); + using var output = new MemoryStream(); + if (async) + { + await using var gzip = new SharpCompress.Compressors.Deflate.GZipStream( + input, + SharpCompressionMode.Decompress, + CompressionLevel.Default, + Encoding.UTF8, + leaveOpen: true + ); + await gzip.CopyToAsync(output, 81920, CancellationToken.None); + } + else + { + using var gzip = new SharpCompress.Compressors.Deflate.GZipStream( + input, + SharpCompressionMode.Decompress, + CompressionLevel.Default, + Encoding.UTF8, + leaveOpen: true + ); + gzip.CopyTo(output); + } + Assert.Equal(EXPECTED_PAYLOAD, output.ToArray()); + using var archiveInput = new ShortReadStream(new MemoryStream(bytes)); + if (async) + { + await using var archive = await GZipArchive.OpenAsyncArchive(archiveInput); + var entry = await archive.EntriesAsync.FirstAsync(); + using var entryStream = await entry.OpenEntryStreamAsync(CancellationToken.None); + output.SetLength(0); + await entryStream.CopyToAsync(output, 81920, CancellationToken.None); + } + else + { + using var archive = GZipArchive.OpenArchive(archiveInput); + using var entryStream = archive.Entries.Single().OpenEntryStream(); + output.SetLength(0); + entryStream.CopyTo(output); + } + Assert.Equal(EXPECTED_PAYLOAD, output.ToArray()); + } + public static IEnumerable FragmentedExtraFields() { foreach (var api in new[] { "Stream", "Reader", "Archive" }) From b919aaab1f5ff39527060ae5318a807cc7e9bcde Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 15:40:39 +0100 Subject: [PATCH 13/18] Validate GZip CRC and size during asynchronous stream disposal --- .../Compressors/Deflate/ZlibBaseStream.cs | 72 ++++++++++--------- .../GZip/GZipExtraFieldTests.cs | 22 ++++++ 2 files changed, 62 insertions(+), 32 deletions(-) diff --git a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs index 5054e37b..5c1fd8d2 100644 --- a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs +++ b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs @@ -327,7 +327,7 @@ internal class ZlibBaseStream : Stream, IStreamStack if (!_wantCompress) { // workitem 8501: handle edge case (decompress empty stream) - if (_z.TotalBytesOut == 0L) + if (_z.TotalBytesOut == 0L && !_readCompleted) { return; } @@ -336,7 +336,7 @@ internal class ZlibBaseStream : Stream, IStreamStack Span trailer = stackalloc byte[8]; // workitem 8679 - if (_z.AvailableBytesIn != 8) + if (_z.AvailableBytesIn < 8) { // Make sure we have read to the end of the stream _z.InputBuffer.AsSpan(_z.NextIn, _z.AvailableBytesIn).CopyTo(trailer); @@ -351,34 +351,7 @@ internal class ZlibBaseStream : Stream, IStreamStack _z.InputBuffer.AsSpan(_z.NextIn, trailer.Length).CopyTo(trailer); } - var crc32_expected = BinaryPrimitives.ReadInt32LittleEndian(trailer); - var crc32_actual = crc.Crc32Result; - var isize_expected = BinaryPrimitives.ReadInt32LittleEndian(trailer.Slice(4)); - var isize_actual = (Int32)(_z.TotalBytesOut & 0x00000000FFFFFFFF); - - if (crc32_actual != crc32_expected) - { - throw new ZlibException( - String.Format( - Constants.DefaultCultureInfo, - "Bad CRC32 in GZIP stream. (actual({0:X8})!=expected({1:X8}))", - crc32_actual, - crc32_expected - ) - ); - } - - if (isize_actual != isize_expected) - { - throw new ZlibException( - String.Format( - Constants.DefaultCultureInfo, - "Bad size in GZIP stream. (actual({0})!=expected({1}))", - isize_actual, - isize_expected - ) - ); - } + ValidateGzipTrailer(trailer); } else { @@ -468,7 +441,7 @@ internal class ZlibBaseStream : Stream, IStreamStack if (!_wantCompress) { // workitem 8501: handle edge case (decompress empty stream) - if (_z.TotalBytesOut == 0L) + if (_z.TotalBytesOut == 0L && !_readCompleted) { return; } @@ -477,7 +450,7 @@ internal class ZlibBaseStream : Stream, IStreamStack byte[] trailer = new byte[8]; // workitem 8679 - if (_z.AvailableBytesIn != 8) + if (_z.AvailableBytesIn < 8) { // Make sure we have read to the end of the stream _z.InputBuffer.AsSpan(_z.NextIn, _z.AvailableBytesIn).CopyTo(trailer); @@ -496,6 +469,11 @@ internal class ZlibBaseStream : Stream, IStreamStack throw new ZlibException("Unexpected end-of-file reading GZIP trailer."); } } + else + { + _z.InputBuffer.AsSpan(_z.NextIn, trailer.Length).CopyTo(trailer); + } + ValidateGzipTrailer(trailer); } else { @@ -505,6 +483,36 @@ internal class ZlibBaseStream : Stream, IStreamStack } } + private void ValidateGzipTrailer(ReadOnlySpan trailer) + { + var expectedCrc = BinaryPrimitives.ReadInt32LittleEndian(trailer); + var actualCrc = crc.Crc32Result; + if (actualCrc != expectedCrc) + { + throw new ZlibException( + String.Format( + Constants.DefaultCultureInfo, + "Bad CRC32 in GZIP stream. (actual({0:X8})!=expected({1:X8}))", + actualCrc, + expectedCrc + ) + ); + } + var expectedSize = BinaryPrimitives.ReadInt32LittleEndian(trailer.Slice(4)); + var actualSize = unchecked((int)_z.TotalBytesOut); + if (actualSize != expectedSize) + { + throw new ZlibException( + String.Format( + Constants.DefaultCultureInfo, + "Bad size in GZIP stream. (actual({0})!=expected({1}))", + actualSize, + expectedSize + ) + ); + } + } + private void end() { if (z is null) diff --git a/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs b/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs index 5c189904..8c24b976 100644 --- a/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs +++ b/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs @@ -97,6 +97,28 @@ public class GZipExtraFieldTests Assert.Equal(EXPECTED_PAYLOAD, output.ToArray()); } + [Theory] + [InlineData(0)] + [InlineData(4)] + public async Task GZip_Async_Dispose_Rejects_Corrupt_Trailer(int offset) + { + var bytes = CreateGZipWithExtraField(0, null); + bytes[bytes.Length - 8 + offset] ^= 1; + using var input = new ShortReadStream(new MemoryStream(bytes)); + var gzip = new SharpCompress.Compressors.Deflate.GZipStream( + input, + SharpCompressionMode.Decompress, + CompressionLevel.Default, + Encoding.UTF8, + leaveOpen: true + ); + using var output = new MemoryStream(); + await gzip.CopyToAsync(output, 81920, CancellationToken.None); + await Assert.ThrowsAsync(async () => + await gzip.DisposeAsync() + ); + } + public static IEnumerable FragmentedExtraFields() { foreach (var api in new[] { "Stream", "Reader", "Archive" }) From b07e6bc7277a34f1bb1e4175f09d7e4b22a3d40e Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 10 Oct 2026 15:40:39 +0100 Subject: [PATCH 14/18] Validate fragmented GZip trailers asynchronously using inflater read-ahead --- .../GZip/GZipChecksumValidationStream.cs | 83 ++++++++++++++++--- .../Common/GZip/GZipFilePart.Async.cs | 8 +- src/SharpCompress/Common/GZip/GZipFilePart.cs | 21 ++++- .../GZip/GZipCrcExtractionTests.cs | 44 ++++++++++ .../GZip/GZipTrailerReadTests.cs | 45 ++++++++++ 5 files changed, 186 insertions(+), 15 deletions(-) create mode 100644 tests/SharpCompress.Test/GZip/GZipTrailerReadTests.cs 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) + ); + } + } +} From eac609b35a58c315a95c769eaf63eac823499202 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 12 Oct 2026 15:23:52 +0100 Subject: [PATCH 15/18] Report truncated encrypted RAR data as InvalidFormatException asynchronously ReadExactAsync throws IncompleteArchiveException, so the async decrypt path's EndOfStreamException catch never ran. On net48, EntryStream.Dispose skips the entry through the async path, so its IncompleteArchiveException replaced the InvalidFormatException from the synchronous read and failed Rar_Multi_Reader_Encrypted. Sync and async now report truncated encrypted data as InvalidFormatException. Co-Authored-By: Claude Opus 5.5 --- src/SharpCompress/Common/Rar/RarCryptoWrapper.cs | 2 +- tests/SharpCompress.Test/Rar/RarCommentTests.cs | 15 +++------------ .../Rar/RarCryptoWrapperTests.cs | 2 +- .../SharpCompress.Test/Rar/RarReaderAsyncTests.cs | 2 +- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/SharpCompress/Common/Rar/RarCryptoWrapper.cs b/src/SharpCompress/Common/Rar/RarCryptoWrapper.cs index 2e7d8051..d5e7112b 100644 --- a/src/SharpCompress/Common/Rar/RarCryptoWrapper.cs +++ b/src/SharpCompress/Common/Rar/RarCryptoWrapper.cs @@ -100,7 +100,7 @@ internal sealed class RarCryptoWrapper : Stream } } } - catch (EndOfStreamException e) + catch (IncompleteArchiveException e) { throw new InvalidFormatException("Unexpected end of encrypted stream", e); } diff --git a/tests/SharpCompress.Test/Rar/RarCommentTests.cs b/tests/SharpCompress.Test/Rar/RarCommentTests.cs index 8400d539..77641c7f 100644 --- a/tests/SharpCompress.Test/Rar/RarCommentTests.cs +++ b/tests/SharpCompress.Test/Rar/RarCommentTests.cs @@ -243,18 +243,9 @@ public class RarCommentTests : TestBase Assert.True(comment.CompressedSize > length); using var truncated = new MemoryStream(data.Take((int)source.Position + length).ToArray()); - if (useAsync) - { - await Assert.ThrowsAsync(() => - ReadCommentArchive(truncated, useAsync, useReader, "test") - ); - } - else - { - await Assert.ThrowsAsync(() => - ReadCommentArchive(truncated, useAsync, useReader, "test") - ); - } + await Assert.ThrowsAsync(() => + ReadCommentArchive(truncated, useAsync, useReader, "test") + ); Assert.True(truncated.CanRead); } diff --git a/tests/SharpCompress.Test/Rar/RarCryptoWrapperTests.cs b/tests/SharpCompress.Test/Rar/RarCryptoWrapperTests.cs index 5f69a190..edb814bd 100644 --- a/tests/SharpCompress.Test/Rar/RarCryptoWrapperTests.cs +++ b/tests/SharpCompress.Test/Rar/RarCryptoWrapperTests.cs @@ -81,7 +81,7 @@ public class RarCryptoWrapperTests new CryptKey5("test", info) ); Assert.Throws(() => syncCrypto.Read(new byte[32], 0, 32)); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => await asyncCrypto.ReadAsync(new byte[32], 0, 32, CancellationToken.None) ); } diff --git a/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs b/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs index 3cec11e2..caef263e 100644 --- a/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs +++ b/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs @@ -81,7 +81,7 @@ public class RarReaderAsyncTests : ReaderTests [Fact] public async ValueTask Rar_Multi_Reader_Encrypted_Async() => - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => { string[] archives = [ From 7d7983e0f43bc9e9636d05cf3c088df259679d61 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 12 Oct 2026 15:29:40 +0100 Subject: [PATCH 16/18] Fix 7z encryption detection for streamless files --- docs/API.md | 4 + .../Archives/SevenZip/SevenZipArchive.cs | 2 +- .../Common/SevenZip/SevenZipFilePart.cs | 4 +- .../SevenZipEncryptionDetectionTests.cs | 82 +++++++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 tests/SharpCompress.Test/SevenZip/SevenZipEncryptionDetectionTests.cs diff --git a/docs/API.md b/docs/API.md index 57c618dd..e3026917 100644 --- a/docs/API.md +++ b/docs/API.md @@ -831,3 +831,7 @@ using (var reader = ReaderFactory.OpenReader(stream)) - [USAGE.md](USAGE.md) - Complete code examples - [FORMATS.md](FORMATS.md) - Supported formats - [PERFORMANCE.md](PERFORMANCE.md) - API selection guide +## 7z Encryption Detection + +For 7z archives, `IsEncrypted` is true when any entry has an AES-encrypted data stream. +Streamless empty files and directories are not encrypted. Archives without files return false. diff --git a/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs b/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs index 27822313..f90c7e92 100644 --- a/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs +++ b/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs @@ -98,7 +98,7 @@ public partial class SevenZipArchive : AbstractArchive x.FilePart.Folder) .Any(folder => folder.Skip(1).Any()); - public override bool IsEncrypted => Entries.First(x => !x.IsDirectory).IsEncrypted; + public override bool IsEncrypted => Entries.Any(x => x.IsEncrypted); public override long TotalSize => _database?._packSizes.Aggregate(0L, (total, packSize) => total + packSize) ?? 0; diff --git a/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs b/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs index 65bb7503..a1715fdf 100644 --- a/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs +++ b/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs @@ -119,7 +119,5 @@ internal class SevenZipFilePart : FilePart }; } - internal bool IsEncrypted => - !Header.IsDir - && Folder?._coders.FindIndex(c => c._methodId._id == CMethodId.K_AES_ID) != -1; + internal bool IsEncrypted => !Header.IsDir && Folder?.IsEncrypted() == true; } diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipEncryptionDetectionTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipEncryptionDetectionTests.cs new file mode 100644 index 00000000..2bd2a0aa --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/SevenZipEncryptionDetectionTests.cs @@ -0,0 +1,82 @@ +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using SharpCompress.Archives.SevenZip; +using SharpCompress.Common.SevenZip; +using SharpCompress.Crypto; +using SharpCompress.Writers.SevenZip; +using Xunit; + +namespace SharpCompress.Test.SevenZip; + +public class SevenZipEncryptionDetectionTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SevenZipArchive_EmptyFirstFile_EncryptionDetection(bool encrypted) + { + using var output = new MemoryStream(); + using (var writer = new SevenZipWriter(output, new SevenZipWriterOptions())) + { + using var empty = new MemoryStream(); + using var data = new MemoryStream(new byte[] { 1, 2, 3 }); + writer.Write("empty.txt", empty, null); + writer.Write("data.txt", data, null); + } + output.Position = 0; + using var archive = (SevenZipArchive)SevenZipArchive.OpenArchive(output); + var entries = archive.Entries.Cast().ToList(); + if (encrypted) + { + // Only metadata is under test; do not decode after adding the AES marker. + entries[1] + .FilePart.Folder!._coders.Add(new CCoderInfo { _methodId = CMethodId.K_AES }); + } + Assert.False(entries[0].IsEncrypted); + Assert.Same(Stream.Null, entries[0].OpenEntryStream()); + Assert.Equal(encrypted, entries[1].IsEncrypted); + Assert.Equal(encrypted, archive.IsEncrypted); + Assert.Equal(encrypted, await archive.IsEncryptedAsync()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void SevenZipArchive_NoFiles_IsNotEncrypted(bool directory) + { + using var stream = new MemoryStream(); + // Build reader fixtures directly so this PR does not depend on the writer fix in #1440. + using var header = new MemoryStream(); + ArchiveHeaderWriter.WriteRawHeader( + header, + null, + new SevenZipFilesInfoWriter + { + Entries = directory + ? new[] + { + new SevenZipWriteEntry + { + Name = "dir", + IsDirectory = true, + IsEmpty = true, + Attributes = 0x10, + }, + } + : System.Array.Empty(), + } + ); + var bytes = header.ToArray(); + var crc = Crc32Stream.Compute( + Crc32Stream.DEFAULT_POLYNOMIAL, + Crc32Stream.DEFAULT_SEED, + bytes + ); + SevenZipSignatureHeaderWriter.WriteFinal(stream, 0, (ulong)bytes.Length, crc); + stream.Write(bytes, 0, bytes.Length); + stream.Position = 0; + using var archive = SevenZipArchive.OpenArchive(stream); + Assert.False(archive.IsEncrypted); + } +} From 69e1477ee6c42d229d2510510ef832536381f1a8 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 12 Oct 2026 15:29:40 +0100 Subject: [PATCH 17/18] Expose public exceptions for encrypted 7z decoding failures --- docs/API.md | 8 ++ .../Common/SevenZip/ArchiveReader.Async.cs | 5 +- .../Common/SevenZip/ArchiveReader.cs | 5 +- .../Common/SharpCompressException.cs | 9 +- .../Compressors/LZMA/DecoderStream.cs | 54 ++++++--- .../LZMA/EncryptedDecoderStream.cs | 58 +++++++++ .../SevenZip/EncryptedDecoderStreamTests.cs | 78 ++++++++++++ .../SevenZipPasswordExceptionTests.cs | 113 ++++++++++++++++++ 8 files changed, 309 insertions(+), 21 deletions(-) create mode 100644 src/SharpCompress/Compressors/LZMA/EncryptedDecoderStream.cs create mode 100644 tests/SharpCompress.Test/SevenZip/EncryptedDecoderStreamTests.cs create mode 100644 tests/SharpCompress.Test/SevenZip/SevenZipPasswordExceptionTests.cs diff --git a/docs/API.md b/docs/API.md index 57c618dd..d13842e3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -831,3 +831,11 @@ using (var reader = ReaderFactory.OpenReader(stream)) - [USAGE.md](USAGE.md) - Complete code examples - [FORMATS.md](FORMATS.md) - Supported formats - [PERFORMANCE.md](PERFORMANCE.md) - API selection guide +## 7z Encrypted Decoding Errors + +AES-encrypted 7z codec data errors and encrypted-header CRC failures throw `SharpCompress.Common.CryptographicException`, +with the original decoder error in `InnerException`. The exception can indicate either an +incorrect password or corrupt encrypted data; 7z AES has no independent password verifier. +`CryptographicException(string message, Exception inner)` preserves an underlying failure. +Unencrypted codec errors retain their existing behavior. This does not guarantee detection +of every incorrect password, particularly for data without compression or integrity checking. diff --git a/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs b/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs index ed29e81c..30c5ad40 100644 --- a/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs +++ b/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs @@ -231,9 +231,12 @@ internal sealed partial class ArchiveReader != folder._unpackCrc ) { - throw new InvalidFormatException( + var exception = new InvalidFormatException( "Decoded stream does not match expected CRC." ); + throw folder.IsEncrypted() + ? EncryptedDecoderStream.DecodingException(exception) + : exception; } } } diff --git a/src/SharpCompress/Common/SevenZip/ArchiveReader.cs b/src/SharpCompress/Common/SevenZip/ArchiveReader.cs index 8ce543c2..3c9ddee9 100644 --- a/src/SharpCompress/Common/SevenZip/ArchiveReader.cs +++ b/src/SharpCompress/Common/SevenZip/ArchiveReader.cs @@ -674,9 +674,12 @@ internal partial class ArchiveReader != folder._unpackCrc ) { - throw new InvalidFormatException( + var exception = new InvalidFormatException( "Decoded stream does not match expected CRC." ); + throw folder.IsEncrypted() + ? EncryptedDecoderStream.DecodingException(exception) + : exception; } } } diff --git a/src/SharpCompress/Common/SharpCompressException.cs b/src/SharpCompress/Common/SharpCompressException.cs index 7dc7cdda..cbbbd37b 100644 --- a/src/SharpCompress/Common/SharpCompressException.cs +++ b/src/SharpCompress/Common/SharpCompressException.cs @@ -28,7 +28,14 @@ public class ArchiveOperationException : SharpCompressException public class IncompleteArchiveException(string message) : ArchiveException(message); -public class CryptographicException(string message) : SharpCompressException(message); +public class CryptographicException : SharpCompressException +{ + public CryptographicException(string message) + : base(message) { } + + public CryptographicException(string message, Exception inner) + : base(message, inner) { } +} public class ReaderCancelledException(string message) : SharpCompressException(message); diff --git a/src/SharpCompress/Compressors/LZMA/DecoderStream.cs b/src/SharpCompress/Compressors/LZMA/DecoderStream.cs index ac50ac52..611561d4 100644 --- a/src/SharpCompress/Compressors/LZMA/DecoderStream.cs +++ b/src/SharpCompress/Compressors/LZMA/DecoderStream.cs @@ -303,14 +303,23 @@ internal static class DecoderStreamHelper out var primaryCoderIndex, out var primaryOutStreamIndex ); - return CreateDecoderStream( - inStreams, - packSizes, - outStreams, - folderInfo, - primaryCoderIndex, - pass - ); + var encrypted = folderInfo.IsEncrypted(); + try + { + var decoder = CreateDecoderStream( + inStreams, + packSizes, + outStreams, + folderInfo, + primaryCoderIndex, + pass + ); + return encrypted ? new EncryptedDecoderStream(decoder) : decoder; + } + catch (DataErrorException ex) when (encrypted) + { + throw EncryptedDecoderStream.DecodingException(ex); + } } internal static async ValueTask CreateDecoderStreamAsync( @@ -341,15 +350,24 @@ internal static class DecoderStreamHelper out var primaryCoderIndex, out var primaryOutStreamIndex ); - return await CreateDecoderStreamAsync( - inStreams, - packSizes, - outStreams, - folderInfo, - primaryCoderIndex, - pass, - cancellationToken - ) - .ConfigureAwait(false); + var encrypted = folderInfo.IsEncrypted(); + try + { + var decoder = await CreateDecoderStreamAsync( + inStreams, + packSizes, + outStreams, + folderInfo, + primaryCoderIndex, + pass, + cancellationToken + ) + .ConfigureAwait(false); + return encrypted ? new EncryptedDecoderStream(decoder) : decoder; + } + catch (DataErrorException ex) when (encrypted) + { + throw EncryptedDecoderStream.DecodingException(ex); + } } } diff --git a/src/SharpCompress/Compressors/LZMA/EncryptedDecoderStream.cs b/src/SharpCompress/Compressors/LZMA/EncryptedDecoderStream.cs new file mode 100644 index 00000000..4c1c94dc --- /dev/null +++ b/src/SharpCompress/Compressors/LZMA/EncryptedDecoderStream.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Common; + +namespace SharpCompress.Compressors.LZMA; + +// AES has no password verifier. Decoder errors can mean either a wrong password +// or damaged ciphertext; retain the codec error without claiming certainty. +internal sealed class EncryptedDecoderStream(Stream decoder) : DecoderStream2 +{ + internal static CryptographicException DecodingException(Exception exception) => + new("Incorrect password or corrupt encrypted 7z data.", exception); + + public override long Length => decoder.Length; + public override long Position => decoder.Position; + + public override int Read(byte[] buffer, int offset, int count) + { + try + { + return decoder.Read(buffer, offset, count); + } + catch (DataErrorException ex) + { + throw DecodingException(ex); + } + } + + public override async Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) + { + try + { + return await decoder + .ReadAsync(buffer, offset, count, cancellationToken) + .ConfigureAwait(false); + } + catch (DataErrorException ex) + { + throw DecodingException(ex); + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + decoder.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/tests/SharpCompress.Test/SevenZip/EncryptedDecoderStreamTests.cs b/tests/SharpCompress.Test/SevenZip/EncryptedDecoderStreamTests.cs new file mode 100644 index 00000000..7f942045 --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/EncryptedDecoderStreamTests.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Common; +using SharpCompress.Compressors.LZMA; +using Xunit; + +namespace SharpCompress.Test.SevenZip; + +public class EncryptedDecoderStreamTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task EncryptedDecoderStream_DataError_PreservesInnerException(bool async) + { + var error = new DataErrorException(); + using var decoder = new FailingStream(error); + using var stream = new EncryptedDecoderStream(decoder); + var exception = await Assert.ThrowsAsync(async () => + { + if (async) + { + await stream.ReadAsync(new byte[1], 0, 1, CancellationToken.None); + } + else + { + stream.Read(new byte[1], 0, 1); + } + }); + Assert.Same(error, exception.InnerException); + } + + [Fact] + public async Task EncryptedDecoderStream_OtherErrors_AreNotTranslated() + { + var error = new IOException("read failed"); + using var decoder = new FailingStream(error); + using var stream = new EncryptedDecoderStream(decoder); + Assert.Same(error, Assert.Throws(() => stream.ReadByte())); + Assert.Same( + error, + await Assert.ThrowsAsync(() => stream.ReadAsync(new byte[1], 0, 1)) + ); + using var canceled = new CancellationTokenSource(); + canceled.Cancel(); + await Assert.ThrowsAnyAsync(() => + stream.ReadAsync(new byte[1], 0, 1, canceled.Token) + ); + stream.Dispose(); + Assert.True(decoder.Disposed); + } + + private sealed class FailingStream(Exception exception) : MemoryStream + { + public bool Disposed { get; private set; } + + public override int Read(byte[] buffer, int offset, int count) => throw exception; + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) + { + cancellationToken.ThrowIfCancellationRequested(); + throw exception; + } + + protected override void Dispose(bool disposing) + { + Disposed = true; + base.Dispose(disposing); + } + } +} diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipPasswordExceptionTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipPasswordExceptionTests.cs new file mode 100644 index 00000000..8e9715b0 --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/SevenZipPasswordExceptionTests.cs @@ -0,0 +1,113 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives.SevenZip; +using SharpCompress.Common; +using SharpCompress.Compressors.LZMA; +using SharpCompress.Readers; +using Xunit; + +namespace SharpCompress.Test.SevenZip; + +public class SevenZipPasswordExceptionTests : ArchiveTests +{ + // 206-byte fixture generated with 7zz a -psecret -mhe=on -m0=LZMA2 + // encrypted-header.7z data.txt (data.txt contains "hello"). No external tool needed in CI. + private const string EncryptedHeader = + "N3q8ryccAAQx1ES9gAAAAAAAAAAuAAAAAAAAADjCO/gWFzzAowVAn1/zBdIMi+5r2AyhxUL1SKmLFzNxH6VJqIWJTTzQDTAhcddAjSIgK/e92hRdPfRIZYJaEPZcue3MFygwZTMOKvgnqcH5B+k/o2345MpVIuA4n2yg4ag99CRyj4LF52P188ujA4nEMXCEsl5lKfQmln5QFOXywSf2ShcGEAEJcAAHCwEAASQG8QcBElMPq2bT4KLY1zYjazQMOzZJ1AxqCgGTarjeAAA="; + + [Theory] + [InlineData(false, "secret")] + [InlineData(true, "secret")] + [InlineData(false, "wrong")] + [InlineData(true, "wrong")] + public async Task SevenZipArchive_EncryptedHeader_PasswordErrors(bool async, string password) + { + using var input = new MemoryStream(Convert.FromBase64String(EncryptedHeader)); + async Task ReadArchive() + { + if (async) + { + await using var archive = await SevenZipArchive.OpenAsyncArchive( + input, + new ReaderOptions { Password = password, LeaveStreamOpen = true }, + CancellationToken.None + ); + var count = 0; + await foreach (var entry in archive.EntriesAsync) + { + Assert.Equal("data.txt", entry.Key); + count++; + } + Assert.Equal(1, count); + } + else + { + using var archive = SevenZipArchive.OpenArchive( + input, + new ReaderOptions { Password = password, LeaveStreamOpen = true } + ); + Assert.Equal("data.txt", archive.Entries.Single().Key); + } + } + if (password == "secret") + { + await ReadArchive(); + } + else + { + var exception = await Assert.ThrowsAsync(ReadArchive); + Assert.IsType(exception.InnerException); + } + } + + [Fact] + public async Task SevenZipArchive_WrongPassword_AsyncOpenPublicException() + { + await using var archive = await SevenZipArchive.OpenAsyncArchive( + Path.Combine(TEST_ARCHIVES_PATH, "7Zip.LZMA.Aes.7z"), + new ReaderOptions { Password = "wrong" }, + CancellationToken.None + ); + var exception = await Assert.ThrowsAsync(async () => + { + await foreach (var entry in archive.EntriesAsync) + { + if (!entry.IsDirectory) + { + using var stream = await entry.OpenEntryStreamAsync(CancellationToken.None); + await stream.CopyToAsync(Stream.Null, 81920, CancellationToken.None); + } + } + }); + Assert.IsType(exception.InnerException); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task SevenZipArchive_WrongPassword_PublicException(bool async) + { + using var archive = SevenZipArchive.OpenArchive( + Path.Combine(TEST_ARCHIVES_PATH, "7Zip.LZMA.Aes.7z"), + new ReaderOptions { Password = "wrong" } + ); + var entry = archive.Entries.First(e => !e.IsDirectory); + var exception = await Assert.ThrowsAsync(async () => + { + using var stream = entry.OpenEntryStream(); + if (async) + { + await stream.CopyToAsync(Stream.Null, 81920, CancellationToken.None); + } + else + { + stream.CopyTo(Stream.Null); + } + }); + Assert.IsType(exception.InnerException); + Assert.Contains("corrupt encrypted", exception.Message); + } +} From b924008b89599446fb09edce3e881819285a68b7 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 12 Oct 2026 16:59:41 +0100 Subject: [PATCH 18/18] Return pooled tar header buffer only after parsing it TarHeader.ReadAsync returned the rented header block to ArrayPool before reading the checksum, name, size and other fields from it. Under parallel load another caller could rent the same array and overwrite it, which made async tar reads fail intermittently with a bad checksum ("Cannot determine compressed stream type") or "Unexpected EOF reading tar file". Co-Authored-By: Claude Opus 5.5 --- .../Common/Tar/Headers/TarHeader.Async.cs | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/src/SharpCompress/Common/Tar/Headers/TarHeader.Async.cs b/src/SharpCompress/Common/Tar/Headers/TarHeader.Async.cs index f1c81813..748e6d22 100644 --- a/src/SharpCompress/Common/Tar/Headers/TarHeader.Async.cs +++ b/src/SharpCompress/Common/Tar/Headers/TarHeader.Async.cs @@ -260,48 +260,48 @@ internal sealed partial class TarHeader break; } + + // Check header checksum + if (!checkChecksum(buffer)) + { + return false; + } + + Name = ArchiveEncoding.Decode(buffer, 0, 100).TrimNulls(); + EntryType = entryType; + Size = ReadSize(buffer); + LinkName = null; + + // for symlinks, additionally read the linkname + if (entryType == EntryType.SymLink || entryType == EntryType.HardLink) + { + LinkName = ArchiveEncoding.Decode(buffer, 157, 100).TrimNulls(); + } + + Mode = ReadAsciiInt64Base8(buffer, 100, 7); + UserId = ReadAsciiInt64Base8oldGnu(buffer, 108, 7); + GroupId = ReadAsciiInt64Base8oldGnu(buffer, 116, 7); + + var unixTimeStamp = ReadAsciiInt64Base8(buffer, 136, 11); + + LastModifiedTime = EPOCH.AddSeconds(unixTimeStamp).ToLocalTime(); + Magic = ArchiveEncoding.Decode(buffer, 257, 6).TrimNulls(); + + if (!string.IsNullOrEmpty(Magic) && "ustar".Equals(Magic, StringComparison.Ordinal)) + { + var namePrefix = ArchiveEncoding.Decode(buffer, 345, 157).TrimNulls(); + + if (!string.IsNullOrEmpty(namePrefix)) + { + Name = namePrefix + "/" + Name; + } + } } finally { ArrayPool.Shared.Return(buffer); } - // Check header checksum - if (!checkChecksum(buffer)) - { - return false; - } - - Name = ArchiveEncoding.Decode(buffer, 0, 100).TrimNulls(); - EntryType = entryType; - Size = ReadSize(buffer); - LinkName = null; - - // for symlinks, additionally read the linkname - if (entryType == EntryType.SymLink || entryType == EntryType.HardLink) - { - LinkName = ArchiveEncoding.Decode(buffer, 157, 100).TrimNulls(); - } - - Mode = ReadAsciiInt64Base8(buffer, 100, 7); - UserId = ReadAsciiInt64Base8oldGnu(buffer, 108, 7); - GroupId = ReadAsciiInt64Base8oldGnu(buffer, 116, 7); - - var unixTimeStamp = ReadAsciiInt64Base8(buffer, 136, 11); - - LastModifiedTime = EPOCH.AddSeconds(unixTimeStamp).ToLocalTime(); - Magic = ArchiveEncoding.Decode(buffer, 257, 6).TrimNulls(); - - if (!string.IsNullOrEmpty(Magic) && "ustar".Equals(Magic, StringComparison.Ordinal)) - { - var namePrefix = ArchiveEncoding.Decode(buffer, 345, 157).TrimNulls(); - - if (!string.IsNullOrEmpty(namePrefix)) - { - Name = namePrefix + "/" + Name; - } - } - pendingMetadata.ApplyTo(this); if (entryType == EntryType.Directory)