diff --git a/docs/API.md b/docs/API.md index 57c618dd..7d359c36 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", @@ -484,6 +486,10 @@ 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. ### Options matrix @@ -831,3 +837,17 @@ 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. + +## 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/docs/FORMATS.md b/docs/FORMATS.md index 22a7080a..3ca381a8 100644 --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -36,9 +36,17 @@ ### 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`. +- 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. +### 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 - **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/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/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/Archives/Zip/ZipArchiveVolumeFactory.cs b/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs index 4e1fc8d7..0ab64be0 100644 --- a/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs +++ b/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs @@ -32,11 +32,6 @@ internal static class ZipArchiveVolumeFactory return ArchiveVolumeFactory.GetFilePart(index, part1); } - 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/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/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/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/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..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); } @@ -54,7 +57,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 +68,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 +102,17 @@ 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) + { + if (read == 0) + { + await ValidateAsync(cancellationToken).ConfigureAwait(false); + } + else + { + UpdateAndValidateAtEof(buffer.AsSpan(offset, read), read); + } + } return read; } @@ -104,7 +123,17 @@ internal sealed class GZipChecksumValidationStream : Stream ) { var read = await _source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - UpdateAndValidateAtEof(buffer.Span[..read], read); + if (!buffer.IsEmpty) + { + if (read == 0) + { + await ValidateAsync(cancellationToken).ConfigureAwait(false); + } + else + { + UpdateAndValidateAtEof(buffer.Span[..read], read); + } + } return read; } #endif @@ -135,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 e3648765..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)); @@ -64,7 +67,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 +87,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]; @@ -101,8 +111,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."); + } } } @@ -140,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 f7a1ae3f..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)); @@ -94,7 +109,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 +124,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]; @@ -130,7 +148,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/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..d5e7112b 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; } @@ -97,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/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/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/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/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) 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/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/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs index d39eb178..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,23 +336,14 @@ 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); 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 @@ -360,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 { @@ -477,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; } @@ -486,15 +450,30 @@ 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); 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 + { + _z.InputBuffer.AsSpan(_z.NextIn, trailer.Length).CopyTo(trailer); + } + ValidateGzipTrailer(trailer); } else { @@ -504,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) @@ -668,6 +677,7 @@ internal class ZlibBaseStream : Stream, IStreamStack #endif private bool nomoreinput; + private bool _readCompleted; private bool isDisposed; private string ReadZeroTerminatedString() @@ -736,7 +746,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 +758,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) { @@ -774,7 +786,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; @@ -796,7 +813,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 +829,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) { @@ -838,7 +861,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; @@ -889,7 +917,7 @@ internal class ZlibBaseStream : Stream, IStreamStack _z.NextOut = offset; _z.AvailableBytesOut = count; - if (count == 0) + if (count == 0 || _readCompleted) { return 0; } @@ -1012,6 +1040,7 @@ internal class ZlibBaseStream : Stream, IStreamStack } } + _readCompleted = !_wantCompress && rc == ZlibConstants.Z_STREAM_END; rc = (count - _z.AvailableBytesOut); // calculate CRC after reading @@ -1020,12 +1049,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; } @@ -1081,7 +1106,7 @@ internal class ZlibBaseStream : Stream, IStreamStack _z.NextOut = offset; _z.AvailableBytesOut = count; - if (count == 0) + if (count == 0 || _readCompleted) { return 0; } @@ -1206,6 +1231,7 @@ internal class ZlibBaseStream : Stream, IStreamStack } } + _readCompleted = !_wantCompress && rc == ZlibConstants.Z_STREAM_END; rc = (count - _z.AvailableBytesOut); // calculate CRC after reading @@ -1214,12 +1240,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/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/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/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/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)); + } +} 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); + } +} 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); + } +} 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/GZipExtraFieldTests.cs b/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs index 2d484fd3..8c24b976 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,290 @@ 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()); + } + + [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" }) + { + 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/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) + ); + } + } +} 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..77641c7f 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,52 @@ 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()); + + 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 +264,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 +280,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..edb814bd --- /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/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 = [ 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); + } +} 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/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"; + } +} 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); + } +} 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); + } +} 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()); + } +} 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()); + } +} 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) + ); + } +} 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( 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); + } +} 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