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