diff --git a/docs/API.md b/docs/API.md index 7da04787..8b33ed02 100644 --- a/docs/API.md +++ b/docs/API.md @@ -101,6 +101,27 @@ The stream overloads of `DetectArchive` and `InspectArchive` preserve the suppli | `ZipDataDescriptorEntryCount` | `Zip.HasEntriesWithDeferredSizes` | | `SolidStreamCount` | `SolidStreamCount` | +### RAR5 Archive Comments + +`SharpCompress.Common.Rar.RarVolume.Comment` exposes UTF-8 archive comments, including encrypted comments when `ReaderOptions.Password` is supplied. Comments are loaded while entries are enumerated; the property can be `null` before enumeration or when no comment is present. + +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. + +```csharp +using var archive = RarArchive.OpenArchive( + "encrypted.rar", + ReaderOptions.ForFilePath.WithPassword("password") +); +foreach (var entry in archive.Entries) +{ + Console.WriteLine(entry.Key); +} +foreach (var volume in archive.Volumes.OfType()) +{ + Console.WriteLine(volume.Comment); +} +``` + ### Creating Archives ```csharp diff --git a/global.json b/global.json index e784f38f..af07787e 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "10.0.302", + "version": "10.0.401", "rollForward": "disable" } -} \ No newline at end of file +} diff --git a/src/SharpCompress/Archives/Rar/FileInfoRarFilePart.cs b/src/SharpCompress/Archives/Rar/FileInfoRarFilePart.cs index 02d2f769..bda245d6 100644 --- a/src/SharpCompress/Archives/Rar/FileInfoRarFilePart.cs +++ b/src/SharpCompress/Archives/Rar/FileInfoRarFilePart.cs @@ -12,7 +12,7 @@ internal sealed class FileInfoRarFilePart : SeekableFilePart FileHeader fh, FileInfo fi ) - : base(mh, fh, volume.Index, volume.Stream, password) => FileInfo = fi; + : base(mh, fh, volume.Index, volume.Stream, password, volume.KeyCache) => FileInfo = fi; internal FileInfo FileInfo { get; } diff --git a/src/SharpCompress/Archives/Rar/SeekableFilePart.cs b/src/SharpCompress/Archives/Rar/SeekableFilePart.cs index 97822d90..1cf70a15 100644 --- a/src/SharpCompress/Archives/Rar/SeekableFilePart.cs +++ b/src/SharpCompress/Archives/Rar/SeekableFilePart.cs @@ -8,18 +8,21 @@ internal class SeekableFilePart : RarFilePart { private readonly Stream _stream; private readonly string? _password; + private readonly Rar5KeyCache _keyCache; internal SeekableFilePart( MarkHeader mh, FileHeader fh, int index, Stream stream, - string? password + string? password, + Rar5KeyCache keyCache ) : base(mh, fh, index) { _stream = stream; _password = password; + _keyCache = keyCache; } internal override Stream GetCompressedStream() @@ -34,7 +37,7 @@ internal class SeekableFilePart : RarFilePart if (FileHeader.Rar5CryptoInfo != null) { - var cryptKey = new CryptKey5(_password!, FileHeader.Rar5CryptoInfo); + var cryptKey = new CryptKey5(_password!, FileHeader.Rar5CryptoInfo, _keyCache); return new RarCryptoWrapper(_stream, FileHeader.Rar5CryptoInfo.Salt, cryptKey); } diff --git a/src/SharpCompress/Archives/Rar/StreamRarArchiveVolume.cs b/src/SharpCompress/Archives/Rar/StreamRarArchiveVolume.cs index b7ffc67e..95885ae5 100644 --- a/src/SharpCompress/Archives/Rar/StreamRarArchiveVolume.cs +++ b/src/SharpCompress/Archives/Rar/StreamRarArchiveVolume.cs @@ -18,5 +18,12 @@ internal class StreamRarArchiveVolume : RarVolume GetVolumeFilePartsAsync(); internal override RarFilePart CreateFilePart(MarkHeader markHeader, FileHeader fileHeader) => - new SeekableFilePart(markHeader, fileHeader, Index, Stream, ReaderOptions.Password); + new SeekableFilePart( + markHeader, + fileHeader, + Index, + Stream, + ReaderOptions.Password, + KeyCache + ); } diff --git a/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs b/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs index ca2f0304..4e1fc8d7 100644 --- a/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs +++ b/src/SharpCompress/Archives/Zip/ZipArchiveVolumeFactory.cs @@ -21,7 +21,7 @@ internal static class ZipArchiveVolumeFactory part1.DirectoryName!, String.Concat( m.Groups[1].Value, - Regex.Replace(m.Groups[2].Value, @"[^xz]", ""), + Regex.Replace(m.Groups[2].Value.ToLowerInvariant(), @"[^xz]", ""), index.ToString(Constants.DefaultCultureInfo).PadLeft(2, '0') ) ) diff --git a/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs b/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs index e4bd555d..e81f87e8 100644 --- a/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs +++ b/src/SharpCompress/Common/GZip/GZipFilePart.Async.cs @@ -83,7 +83,7 @@ internal sealed partial class GZipFilePart var lengthField = new byte[2]; _ = await _stream.ReadAsync(lengthField, 0, 2, cancellationToken).ConfigureAwait(false); - var extraLength = (short)(lengthField[0] + (lengthField[1] * 256)); + var extraLength = lengthField[0] + (lengthField[1] * 256); var extra = new byte[extraLength]; if (!await _stream.ReadFullyAsync(extra, cancellationToken).ConfigureAwait(false)) diff --git a/src/SharpCompress/Common/GZip/GZipFilePart.cs b/src/SharpCompress/Common/GZip/GZipFilePart.cs index 3ce51163..486bbee2 100644 --- a/src/SharpCompress/Common/GZip/GZipFilePart.cs +++ b/src/SharpCompress/Common/GZip/GZipFilePart.cs @@ -87,7 +87,7 @@ internal sealed partial class GZipFilePart : FilePart // read and discard extra field n = _stream.Read(header.Slice(0, 2)); // 2-byte length field - var extraLength = (short)(header[0] + (header[1] * 256)); + var extraLength = header[0] + (header[1] * 256); var extra = new byte[extraLength]; if (!_stream.ReadFully(extra)) diff --git a/src/SharpCompress/Common/Rar/CryptKey5.cs b/src/SharpCompress/Common/Rar/CryptKey5.cs index 490a3c54..ee5f0c3b 100644 --- a/src/SharpCompress/Common/Rar/CryptKey5.cs +++ b/src/SharpCompress/Common/Rar/CryptKey5.cs @@ -9,32 +9,32 @@ namespace SharpCompress.Common.Rar; internal class CryptKey5 : ICryptKey { const int AES_256 = 256; - const int DERIVED_KEY_LENGTH = 0x10; const int SHA256_DIGEST_SIZE = 32; - private string _password; - private Rar5CryptoInfo _cryptoInfo; + private readonly string _password; + private readonly Rar5CryptoInfo _cryptoInfo; + private readonly Rar5KeyCache _keyCache; + private byte[]? _aesKey; + private byte[] _derivedSalt = []; + private int _derivedLg2Count; private byte[] _pswCheck = []; private byte[] _hashKey = []; - public CryptKey5(string? password, Rar5CryptoInfo rar5CryptoInfo) + public CryptKey5(string? password, Rar5CryptoInfo rar5CryptoInfo, Rar5KeyCache? keyCache = null) { _password = password ?? ""; _cryptoInfo = rar5CryptoInfo; + _keyCache = keyCache ?? new Rar5KeyCache(); } public byte[] PswCheck => _pswCheck; public byte[] HashKey => _hashKey; - private static List GenerateRarPBKDF2Key( - string password, - byte[] salt, - int iterations, - int keyLength - ) + internal static List GenerateRarPBKDF2Key(string password, byte[] salt, int iterations) { var passwordBytes = Encoding.UTF8.GetBytes(password); + salt = salt.Concat(new byte[] { 0, 0, 0, 1 }).ToArray(); #if LEGACY_DOTNET using var hmac = new HMACSHA256(passwordBytes); var block = hmac.ComputeHash(salt); @@ -69,23 +69,24 @@ internal class CryptKey5 : ICryptKey public ICryptoTransform Transformer(byte[] salt) { - var iterations = (1 << _cryptoInfo.LG2Count); // Adjust the number of iterations as needed - - var salt_rar5 = salt.Concat(new byte[] { 0, 0, 0, 1 }); - var derivedKey = GenerateRarPBKDF2Key( - _password, - salt_rar5.ToArray(), - iterations, - DERIVED_KEY_LENGTH - ); - - _hashKey = derivedKey[1]; - - _pswCheck = new byte[EncryptionConstV5.SIZE_PSWCHECK]; - - for (var i = 0; i < SHA256_DIGEST_SIZE; i++) + // Header IVs change for each block, but the expensive KDF inputs do not. + // Snapshot the salt so in-place changes cannot reuse an unrelated key. + if ( + _aesKey is null + || _derivedLg2Count != _cryptoInfo.LG2Count + || !_derivedSalt.SequenceEqual(salt) + ) { - _pswCheck[i % EncryptionConstV5.SIZE_PSWCHECK] ^= derivedKey[2][i]; + var derivedKey = _keyCache.GetKeys(_password, salt, _cryptoInfo.LG2Count); + _hashKey = derivedKey[1]; + _pswCheck = new byte[EncryptionConstV5.SIZE_PSWCHECK]; + for (var i = 0; i < SHA256_DIGEST_SIZE; i++) + { + _pswCheck[i % EncryptionConstV5.SIZE_PSWCHECK] ^= derivedKey[2][i]; + } + _derivedSalt = (byte[])salt.Clone(); + _derivedLg2Count = _cryptoInfo.LG2Count; + _aesKey = derivedKey[0]; } if (_cryptoInfo.UsePswCheck && !_cryptoInfo.PswCheck.SequenceEqual(_pswCheck)) @@ -93,11 +94,11 @@ internal class CryptKey5 : ICryptKey throw new CryptographicException("The password did not match."); } - var aes = Aes.Create(); + using var aes = Aes.Create(); aes.KeySize = AES_256; aes.Mode = CipherMode.CBC; aes.Padding = PaddingMode.None; - aes.Key = derivedKey[0]; + aes.Key = _aesKey; aes.IV = _cryptoInfo.InitV; return aes.CreateDecryptor(); } diff --git a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs index d020add9..a6108b32 100644 --- a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.Async.cs @@ -14,6 +14,7 @@ public partial class RarHeaderFactory { public async IAsyncEnumerable ReadHeadersAsync(Stream stream) { + ResetHeaderScanState(); var markHeader = await MarkHeader .ReadAsync( stream, @@ -67,10 +68,8 @@ public partial class RarHeaderFactory await _cryptInfo .ReadInitVAsync(new AsyncMarkingBinaryReader(stream)) .ConfigureAwait(false); - var _headerKey = new CryptKey5(Options.Password!, _cryptInfo); - reader = await AsyncRarCryptoBinaryReader - .Create(stream, _headerKey, _cryptInfo.Salt) + .Create(stream, GetHeaderKey(), _cryptInfo.Salt) .ConfigureAwait(false); } else @@ -136,9 +135,9 @@ public partial class RarHeaderFactory var fh = await FileHeader .CreateAsync(header, reader, HeaderType.Service, cancellationToken) .ConfigureAwait(false); - if (fh.FileName == "CMT") + if (fh.FileName == "CMT" && fh.IsStored) { - fh.PackedStream = new ReadOnlySubStream(reader.BaseStream, fh.CompressedSize); + fh.PackedStream = CreatePackedStream(fh, reader.BaseStream); } else { @@ -187,7 +186,8 @@ public partial class RarHeaderFactory fh.R4Salt is null ? new CryptKey5( Options.Password, - fh.Rar5CryptoInfo.NotNull() + fh.Rar5CryptoInfo.NotNull(), + KeyCache ) : new CryptKey3(Options.Password) ); @@ -215,6 +215,7 @@ public partial class RarHeaderFactory .ConfigureAwait(false); IsEncrypted = true; _cryptInfo = cryptoHeader.CryptInfo; + _headerKey = null; return cryptoHeader; } diff --git a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs index e9694c2a..27ae9db6 100644 --- a/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs +++ b/src/SharpCompress/Common/Rar/Headers/RarHeaderFactory.cs @@ -13,6 +13,21 @@ public partial class RarHeaderFactory private bool _isRar5; private Rar5CryptoInfo? _cryptInfo; + private CryptKey5? _headerKey; + private string? _headerPassword; + internal Rar5KeyCache KeyCache { get; } = new(); + + private CryptKey5 GetHeaderKey() + { + // ReaderOptions is mutable; changing its password must not keep using + // a key derived from the previous password. + if (_headerKey is null || _headerPassword != Options.Password) + { + _headerPassword = Options.Password; + _headerKey = new CryptKey5(_headerPassword, _cryptInfo.NotNull(), KeyCache); + } + return _headerKey; + } public RarHeaderFactory(StreamingMode mode, ReaderOptions options) { @@ -24,8 +39,20 @@ public partial class RarHeaderFactory public StreamingMode StreamingMode { get; } public bool IsEncrypted { get; private set; } + private void ResetHeaderScanState() + { + // Metadata probes rewind the stream. Each new scan must rediscover encryption + // from the plaintext archive headers, rather than reuse the previous scan's state. + IsEncrypted = false; + _cryptInfo = null; + _headerKey = null; + _headerPassword = null; + // Keep the password/salt/iteration-keyed KDF cache across scans. + } + public IEnumerable ReadHeaders(Stream stream) { + ResetHeaderScanState(); var markHeader = MarkHeader.Read(stream, Options.LeaveStreamOpen, Options.LookForHeader); _isRar5 = markHeader.IsRar5; yield return markHeader; @@ -62,9 +89,7 @@ public partial class RarHeaderFactory if (_isRar5 && _cryptInfo != null) { _cryptInfo.ReadInitV(new MarkingBinaryReader(stream)); - var _headerKey = new CryptKey5(Options.Password!, _cryptInfo); - - reader = RarCryptoBinaryReader.Create(stream, _headerKey, _cryptInfo.Salt); + reader = RarCryptoBinaryReader.Create(stream, GetHeaderKey(), _cryptInfo.Salt); } else { @@ -120,9 +145,10 @@ public partial class RarHeaderFactory case HeaderCodeV.RAR5_SERVICE_HEADER: { var fh = FileHeader.Create(header, reader, HeaderType.Service); - if (fh.FileName == "CMT") + // Only stored comments are supported; skip other methods without decrypting. + if (fh.FileName == "CMT" && fh.IsStored) { - fh.PackedStream = new ReadOnlySubStream(reader.BaseStream, fh.CompressedSize); + fh.PackedStream = CreatePackedStream(fh, reader.BaseStream); } else { @@ -167,7 +193,8 @@ public partial class RarHeaderFactory fh.R4Salt is null ? new CryptKey5( Options.Password, - fh.Rar5CryptoInfo.NotNull() + fh.Rar5CryptoInfo.NotNull(), + KeyCache ) : new CryptKey3(Options.Password) ); @@ -191,6 +218,7 @@ public partial class RarHeaderFactory var cryptoHeader = ArchiveCryptHeader.Create(header, reader); IsEncrypted = true; _cryptInfo = cryptoHeader.CryptInfo; + _headerKey = null; return cryptoHeader; } @@ -223,4 +251,20 @@ public partial class RarHeaderFactory } } } + + private Stream CreatePackedStream(FileHeader header, Stream stream) + { + var packedStream = new ReadOnlySubStream(stream, header.CompressedSize); + // Service data has its own encryption metadata, independent of header encryption. + if (header.Rar5CryptoInfo is not null) + { + return new RarCryptoWrapper( + packedStream, + header.Rar5CryptoInfo.Salt, + new CryptKey5(Options.Password, header.Rar5CryptoInfo, KeyCache) + ); + } + + return packedStream; + } } diff --git a/src/SharpCompress/Common/Rar/Rar5KeyCache.cs b/src/SharpCompress/Common/Rar/Rar5KeyCache.cs new file mode 100644 index 00000000..5208c1e7 --- /dev/null +++ b/src/SharpCompress/Common/Rar/Rar5KeyCache.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System.Linq; + +namespace SharpCompress.Common.Rar; + +// Cache only KDF output, never entry-specific IVs, checks, or AES transforms. +internal sealed class Rar5KeyCache +{ + private const int Capacity = 4; + private readonly List<(string Password, byte[] Salt, int Lg2Count, List Keys)> entries = + new(); + + internal List GetKeys(string password, byte[] salt, int lg2Count) + { + lock (entries) + { + foreach (var (cachedPassword, cachedSalt, cachedCount, cachedKeys) in entries) + { + if ( + cachedPassword == password + && cachedCount == lg2Count + && cachedSalt.SequenceEqual(salt) + ) + { + return cachedKeys; + } + } + + // Snapshot mutable metadata so later changes cannot corrupt cache identity. + var snapshot = (byte[])salt.Clone(); + var keys = CryptKey5.GenerateRarPBKDF2Key(password, snapshot, 1 << lg2Count); + if (entries.Count == Capacity) + { + entries.RemoveAt(0); + } + entries.Add((password, snapshot, lg2Count, keys)); + return keys; + } + } +} diff --git a/src/SharpCompress/Common/Rar/RarVolume.cs b/src/SharpCompress/Common/Rar/RarVolume.cs index f3913d2c..4a8a34cb 100644 --- a/src/SharpCompress/Common/Rar/RarVolume.cs +++ b/src/SharpCompress/Common/Rar/RarVolume.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.IO; using System.Linq; @@ -17,7 +18,10 @@ namespace SharpCompress.Common.Rar; /// public abstract class RarVolume : Volume { + private const int MaxCommentSize = 16 * 1024 * 1024; + private readonly RarHeaderFactory _headerFactory; + internal Rar5KeyCache KeyCache => _headerFactory.KeyCache; private int _maxCompressionAlgorithm; internal RarVolume(StreamingMode mode, Stream stream, ReaderOptions options, int index) @@ -66,11 +70,25 @@ public abstract class RarVolume : Volume case HeaderType.Service: { var fh = (FileHeader)header; - if (fh.FileName == "CMT") + if (fh.FileName == "CMT" && fh.IsStored) { - var buffer = new byte[fh.CompressedSize]; - fh.PackedStream.NotNull().ReadFully(buffer); - Comment = Encoding.UTF8.GetString(buffer, 0, buffer.Length - 1); + // Read the logical service size, not the potentially padded packed size. + var commentSize = GetCommentSize(fh); + var buffer = ArrayPool.Shared.Rent(commentSize); + try + { + using var packedStream = fh.PackedStream.NotNull(); + if (!packedStream.ReadFully(buffer.AsSpan(0, commentSize))) + { + // Never decode unread bytes from a pooled buffer. + throw new EndOfStreamException(); + } + Comment = DecodeComment(buffer, commentSize); + } + finally + { + ArrayPool.Shared.Return(buffer); + } } } break; @@ -115,14 +133,28 @@ public abstract class RarVolume : Volume case HeaderType.Service: { var fh = (FileHeader)header; - if (fh.FileName == "CMT") + if (fh.FileName == "CMT" && fh.IsStored) { - var buffer = new byte[fh.CompressedSize]; - await fh - .PackedStream.NotNull() - .ReadFullyAsync(buffer, cancellationToken) - .ConfigureAwait(false); - Comment = Encoding.UTF8.GetString(buffer, 0, buffer.Length - 1); + var commentSize = GetCommentSize(fh); + var buffer = ArrayPool.Shared.Rent(commentSize); + try + { + using var packedStream = fh.PackedStream.NotNull(); + if ( + !await packedStream + .ReadFullyAsync(buffer, 0, commentSize, cancellationToken) + .ConfigureAwait(false) + ) + { + // Never decode unread bytes from a pooled buffer. + throw new EndOfStreamException(); + } + Comment = DecodeComment(buffer, commentSize); + } + finally + { + ArrayPool.Shared.Return(buffer); + } } } break; @@ -130,6 +162,28 @@ public abstract class RarVolume : Volume } } + private static int GetCommentSize(FileHeader header) + { + // Match UnRAR's limit for in-memory service data before renting a buffer. + // Unknown unpacked sizes are represented by long.MaxValue and rejected too. + if (header.UncompressedSize < 0 || header.UncompressedSize > MaxCommentSize) + { + throw new InvalidFormatException("RAR archive comment exceeds the 16 MiB size limit."); + } + + return (int)header.UncompressedSize; + } + + private static string DecodeComment(byte[] buffer, int commentSize) + { + // Like UnRAR, stop at the first NUL. Encrypted comments can include + // padding in their unpacked size as well as their packed size. + // Limit decoding to the payload, since rented buffers can be larger. + var terminator = Array.IndexOf(buffer, (byte)0, 0, commentSize); + var length = terminator < 0 ? commentSize : terminator; + return Encoding.UTF8.GetString(buffer, 0, length); + } + private void EnsureArchiveHeaderLoaded() { if (ArchiveHeader is null) diff --git a/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs b/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs index 45420797..4ff426c7 100644 --- a/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs +++ b/src/SharpCompress/Common/SevenZip/ArchiveReader.Async.cs @@ -104,6 +104,13 @@ internal sealed partial class ArchiveReader throw new ArchiveOperationException("nextHeaderOffset is invalid"); } + // Validate the physical header range before allocating attacker-controlled sizes. + // Subtract the validated offset instead of adding offset and size to avoid overflow. + if (nextHeaderSize > _streamEnding - db._startPositionAfterHeader - nextHeaderOffset) + { + throw new IncompleteArchiveException("Next header extends beyond the end of stream."); + } + _stream.Seek(nextHeaderOffset, SeekOrigin.Current); var header = new byte[nextHeaderSize]; diff --git a/src/SharpCompress/Common/SevenZip/ArchiveReader.cs b/src/SharpCompress/Common/SevenZip/ArchiveReader.cs index b75d123c..8ce543c2 100644 --- a/src/SharpCompress/Common/SevenZip/ArchiveReader.cs +++ b/src/SharpCompress/Common/SevenZip/ArchiveReader.cs @@ -1043,6 +1043,13 @@ internal partial class ArchiveReader throw new ArchiveOperationException("nextHeaderOffset is invalid"); } + // Validate the physical header range before allocating attacker-controlled sizes. + // Subtract the validated offset instead of adding offset and size to avoid overflow. + if (nextHeaderSize > _streamEnding - db._startPositionAfterHeader - nextHeaderOffset) + { + throw new IncompleteArchiveException("Next header extends beyond the end of stream."); + } + _stream.Seek(nextHeaderOffset, SeekOrigin.Current); var header = new byte[nextHeaderSize]; diff --git a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs index 4815ec33..11ceee61 100644 --- a/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs +++ b/src/SharpCompress/Compressors/Deflate/ZlibBaseStream.cs @@ -660,7 +660,7 @@ internal partial class ZlibBaseStream : Stream, IStreamStack n = _stream.Read(header.Slice(0, 2)); // 2-byte length field totalBytesRead += n; - var extraLength = (short)(header[0] + header[1] * 256); + var extraLength = header[0] + (header[1] * 256); var extra = new byte[extraLength]; n = _stream.Read(extra, 0, extra.Length); if (n != extraLength) @@ -720,7 +720,7 @@ internal partial class ZlibBaseStream : Stream, IStreamStack n = await _stream.ReadAsync(header, 0, 2, cancellationToken).ConfigureAwait(false); // 2-byte length field totalBytesRead += n; - var extraLength = (short)(header[0] + header[1] * 256); + var extraLength = header[0] + (header[1] * 256); var extra = new byte[extraLength]; n = await _stream .ReadAsync(extra, 0, extra.Length, cancellationToken) diff --git a/src/SharpCompress/packages.lock.json b/src/SharpCompress/packages.lock.json index 65b5301d..f4c3588e 100644 --- a/src/SharpCompress/packages.lock.json +++ b/src/SharpCompress/packages.lock.json @@ -339,9 +339,9 @@ "net10.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, "Microsoft.NETFramework.ReferenceAssemblies": { "type": "Direct", @@ -471,9 +471,9 @@ "net8.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[8.0.29, )", - "resolved": "8.0.29", - "contentHash": "HSBTfrkIZijz8z3ybLRKB7E8rHk4QQufFwpHa9fc5CMIgRhRzdn4mBGmlyXZqaueiMPtuJcnjresGvSTfaW8Mg==" + "requested": "[8.0.31, )", + "resolved": "8.0.31", + "contentHash": "OJdAGATV2QoVWL11zlDLEluA06q8rK+cgZR47ohrO8V+Qj5z9dlvN7bG9/0e5t0IOe+yD8tREC0bpsEgDGrzIQ==" }, "Microsoft.NETFramework.ReferenceAssemblies": { "type": "Direct", diff --git a/tests/SharpCompress.AotSmoke/packages.lock.json b/tests/SharpCompress.AotSmoke/packages.lock.json index 5904a29a..882d8334 100644 --- a/tests/SharpCompress.AotSmoke/packages.lock.json +++ b/tests/SharpCompress.AotSmoke/packages.lock.json @@ -4,15 +4,15 @@ "net10.0": { "Microsoft.DotNet.ILCompiler": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "tnG8ntt/Bk6odvHREnGLMo3PEiihy5iSlIFVp0JbIo00GKtNRt2k73eKZbPqR5yaJNIa3z8R86YLwbxfqpb17g==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "AawF393Q+VkdrnrnI1gu612zh5iqpa1AGSvnKCQ3IkMgSKJXIQbO1sXYRgEzOU4f0cPZ6MaCCF29xZSGWlmbuQ==" }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "xi+BDjFpW+Sb+MHFHaH6Y/gV9I8BluFwRXc1QyCdoZbIK26eNiBeFuMTe/FMwc33G1wdHCyDg7CVTmb8OdQrMQ==" }, "Microsoft.NETFramework.ReferenceAssemblies": { "type": "Direct", @@ -82,17 +82,17 @@ "net10.0/linux-x64": { "Microsoft.DotNet.ILCompiler": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "tnG8ntt/Bk6odvHREnGLMo3PEiihy5iSlIFVp0JbIo00GKtNRt2k73eKZbPqR5yaJNIa3z8R86YLwbxfqpb17g==", + "requested": "[10.0.12, )", + "resolved": "10.0.12", + "contentHash": "AawF393Q+VkdrnrnI1gu612zh5iqpa1AGSvnKCQ3IkMgSKJXIQbO1sXYRgEzOU4f0cPZ6MaCCF29xZSGWlmbuQ==", "dependencies": { - "runtime.linux-x64.Microsoft.DotNet.ILCompiler": "10.0.10" + "runtime.linux-x64.Microsoft.DotNet.ILCompiler": "10.0.12" } }, "runtime.linux-x64.Microsoft.DotNet.ILCompiler": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "WRjSRBfv6A6UjgjO8EQuLe9xqdICpkQx1hACUziCw4B2uGL+2jVhkFLq/G7rxRr3MGvqLo9B+nNdfIJ/5CYN7A==" + "resolved": "10.0.12", + "contentHash": "wAa+LAE6+Bkd+pEJvGdnQU8PZQRzvhurpQ4hzsOTpRuedIq1kunJcz86xBCBlzsc+QlVdCKVUPzEfB3uYVB7Gw==" } } } diff --git a/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs b/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs new file mode 100644 index 00000000..2d484fd3 --- /dev/null +++ b/tests/SharpCompress.Test/GZip/GZipExtraFieldTests.cs @@ -0,0 +1,164 @@ +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using AwesomeAssertions; +using SharpCompress.Archives.GZip; +using SharpCompress.Readers.GZip; +using SharpCompress.Test.Mocks; +using Xunit; +using SharpCompressionMode = SharpCompress.Compressors.CompressionMode; +using SystemCompressionMode = System.IO.Compression.CompressionMode; + +namespace SharpCompress.Test.GZip; + +public class GZipExtraFieldTests +{ + private static readonly byte[] EXPECTED_PAYLOAD = [.. "gzip payload"u8]; + + [Theory] + [InlineData(32767)] + [InlineData(32768)] + [InlineData(65535)] + public void GZipStream_Read_WithLargeExtraField_ShouldReturnPayload(int extraLength) + { + using var input = new MemoryStream(CreateGZipWithExtraField(extraLength)); + using var gzip = new SharpCompress.Compressors.Deflate.GZipStream( + input, + SharpCompressionMode.Decompress + ); + using var output = new MemoryStream(); + + gzip.CopyTo(output); + + _ = output.ToArray().Should().Equal(EXPECTED_PAYLOAD); + } + + [Theory] + [InlineData(32767)] + [InlineData(32768)] + [InlineData(65535)] + public async ValueTask GZipStream_ReadAsync_WithLargeExtraField_ShouldReturnPayload( + int extraLength + ) + { + using var rawInput = new MemoryStream(CreateGZipWithExtraField(extraLength)); + using var input = new AsyncOnlyStream(rawInput, disposeStream: false); + await using var gzip = new SharpCompress.Compressors.Deflate.GZipStream( + input, + SharpCompressionMode.Decompress + ); + using var output = new MemoryStream(); + + await gzip.CopyToAsync(output); + + _ = output.ToArray().Should().Equal(EXPECTED_PAYLOAD); + } + + [Fact] + public void GZipReader_Read_WithLargeExtraFieldAndName_ShouldReturnEntry() + { + using var input = new MemoryStream(CreateGZipWithExtraField(65535, "payload.bin")); + using var reader = GZipReader.OpenReader(input); + + Assert.True(reader.MoveToNextEntry()); + Assert.Equal("payload.bin", reader.Entry.Key); + + using var output = new MemoryStream(); + reader.WriteEntryTo(output); + + _ = output.ToArray().Should().Equal(EXPECTED_PAYLOAD); + } + + [Fact] + public async ValueTask GZipReader_ReadAsync_WithLargeExtraFieldAndName_ShouldReturnEntry() + { + using var rawInput = new MemoryStream(CreateGZipWithExtraField(65535, "payload.bin")); + using var input = new AsyncOnlyStream(rawInput, disposeStream: false); + await using var reader = await GZipReader.OpenAsyncReader(input); + + Assert.True(await reader.MoveToNextEntryAsync()); + Assert.Equal("payload.bin", reader.Entry.Key); + + using var output = new MemoryStream(); + await reader.WriteEntryToAsync(output); + + _ = output.ToArray().Should().Equal(EXPECTED_PAYLOAD); + } + + [Fact] + public void GZipArchive_Read_WithLargeExtraFieldAndName_ShouldReturnEntry() + { + using var input = new MemoryStream(CreateGZipWithExtraField(65535, "payload.bin")); + using var archive = GZipArchive.OpenArchive(input); + var entry = archive.Entries.Single(); + + Assert.Equal("payload.bin", entry.Key); + using var entryStream = entry.OpenEntryStream(); + using var output = new MemoryStream(); + entryStream.CopyTo(output); + + _ = output.ToArray().Should().Equal(EXPECTED_PAYLOAD); + } + + [Fact] + public async ValueTask GZipArchive_ReadAsync_WithLargeExtraFieldAndName_ShouldReturnEntry() + { + using var rawInput = new MemoryStream(CreateGZipWithExtraField(65535, "payload.bin")); + using var input = new AsyncOnlyStream(rawInput, disposeStream: false); + await using var archive = await GZipArchive.OpenAsyncArchive(input); + var entry = await archive.EntriesAsync.FirstAsync(); + + Assert.Equal("payload.bin", entry.Key); +#if NETFRAMEWORK + using (var entryStream = await entry.OpenEntryStreamAsync()) +#else + await using (var entryStream = await entry.OpenEntryStreamAsync()) +#endif + { + using var output = new MemoryStream(); + await entryStream.CopyToAsync(output); + + _ = output.ToArray().Should().Equal(EXPECTED_PAYLOAD); + } + } + + private static byte[] CreateGZipWithExtraField(int extraLength, string? fileName = null) + { + using var compressed = new MemoryStream(); + using ( + var gzip = new System.IO.Compression.GZipStream( + compressed, + SystemCompressionMode.Compress, + leaveOpen: true + ) + ) + { + gzip.Write(EXPECTED_PAYLOAD, 0, EXPECTED_PAYLOAD.Length); + } + + var original = compressed.ToArray(); + var fileNameBytes = fileName is null + ? [] + : Encoding.GetEncoding("iso-8859-1").GetBytes(fileName + "\0"); + var result = new byte[original.Length + extraLength + 2 + fileNameBytes.Length]; + Buffer.BlockCopy(original, 0, result, 0, 10); + result[3] |= 0x04; + if (fileNameBytes.Length > 0) + { + result[3] |= 0x08; + } + result[10] = (byte)extraLength; + result[11] = (byte)(extraLength >> 8); + Buffer.BlockCopy(fileNameBytes, 0, result, 12 + extraLength, fileNameBytes.Length); + Buffer.BlockCopy( + original, + 10, + result, + 12 + extraLength + fileNameBytes.Length, + original.Length - 10 + ); + return result; + } +} diff --git a/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs b/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs new file mode 100644 index 00000000..73102f5b --- /dev/null +++ b/tests/SharpCompress.Test/Rar/CryptKey5Tests.cs @@ -0,0 +1,129 @@ +using System.IO; +using System.Linq; +using SharpCompress.Common.Rar; +using SharpCompress.Common.Rar.Headers; +using SharpCompress.IO; +using SharpCompress.Readers; +using Xunit; + +namespace SharpCompress.Test.Rar; + +public class CryptKey5Tests : TestBase +{ + private Rar5CryptoInfo ReadCryptoInfo() + { + using var stream = File.OpenRead( + Path.Combine(TEST_ARCHIVES_PATH, "Rar5.encrypted_filesAndHeader.rar") + ); + var factory = new RarHeaderFactory(StreamingMode.Seekable, ReaderOptions.ForExternalStream); + var info = factory.ReadHeaders(stream).OfType().First().CryptInfo; + info.InitV = new byte[16]; + return info; + } + + [Fact] + public void Rar5_Key_SharesDerivedMaterial_AcrossEntries() + { + var cache = new Rar5KeyCache(); + var firstInfo = ReadCryptoInfo(); + var secondInfo = ReadCryptoInfo(); + secondInfo.InitV[0] = 1; + var first = new CryptKey5("test", firstInfo, cache); + var second = new CryptKey5("test", secondInfo, cache); + using var firstTransform = first.Transformer(firstInfo.Salt); + using var secondTransform = second.Transformer(secondInfo.Salt); + Assert.Same(first.HashKey, second.HashKey); + Assert.Equal(first.PswCheck, second.PswCheck); + var firstBlock = firstTransform.TransformFinalBlock(new byte[16], 0, 16); + var secondBlock = secondTransform.TransformFinalBlock(new byte[16], 0, 16); + Assert.False(firstBlock.SequenceEqual(secondBlock)); + using var fresh = new CryptKey5("test", secondInfo).Transformer(secondInfo.Salt); + Assert.Equal(fresh.TransformFinalBlock(new byte[16], 0, 16), secondBlock); + + secondInfo.PswCheck[0] ^= 1; + Assert.Throws(() => + new CryptKey5("test", secondInfo, cache).Transformer(secondInfo.Salt) + ); + Assert.Throws(() => + new CryptKey5("failed", firstInfo, cache).Transformer(firstInfo.Salt) + ); + } + + [Fact] + public void Rar5_KeyCache_MatchesInputs_AndBoundsRetention() + { + var cache = new Rar5KeyCache(); + var salt = new byte[16]; + var original = cache.GetKeys("test", salt, 0); + Assert.Same(original, cache.GetKeys("test", (byte[])salt.Clone(), 0)); + Assert.NotSame(original, cache.GetKeys("other", salt, 0)); + Assert.NotSame(original, cache.GetKeys("test", salt, 1)); + salt[0] = 1; + Assert.NotSame(original, cache.GetKeys("test", salt, 0)); + Assert.Same(original, cache.GetKeys("test", new byte[16], 0)); + salt[0] = 2; + cache.GetKeys("test", salt, 0); + Assert.NotSame(original, cache.GetKeys("test", new byte[16], 0)); + } + + [Fact] + public void Rar5_Key_ReusesDerivedMaterial_WithDifferentIVs() + { + var info = ReadCryptoInfo(); + var key = new CryptKey5("test", info); + using var first = key.Transformer(info.Salt); + var firstBlock = first.TransformFinalBlock(new byte[16], 0, 16); + var hashKey = key.HashKey; + var passwordCheck = key.PswCheck; + + info.InitV[0] = 1; + using var second = key.Transformer((byte[])info.Salt.Clone()); + var secondBlock = second.TransformFinalBlock(new byte[16], 0, 16); + Assert.Same(hashKey, key.HashKey); + Assert.Same(passwordCheck, key.PswCheck); + Assert.False(firstBlock.SequenceEqual(secondBlock)); + + using var fresh = new CryptKey5("test", info).Transformer(info.Salt); + Assert.Equal(fresh.TransformFinalBlock(new byte[16], 0, 16), secondBlock); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Rar5_Key_InvalidatesDerivedMaterial_WhenInputsChange(bool changeIterations) + { + var info = ReadCryptoInfo(); + var key = new CryptKey5("test", info); + using var first = key.Transformer(info.Salt); + var hashKey = key.HashKey; + info.UsePswCheck = false; + if (changeIterations) + { + info.LG2Count--; + } + else + { + info.Salt[0] ^= 1; + } + + using var second = key.Transformer(info.Salt); + using var fresh = new CryptKey5("test", info).Transformer(info.Salt); + Assert.NotSame(hashKey, key.HashKey); + Assert.Equal( + fresh.TransformFinalBlock(new byte[16], 0, 16), + second.TransformFinalBlock(new byte[16], 0, 16) + ); + } + + [Fact] + public void Rar5_Key_ValidatesPasswordCheck_OnCacheHit() + { + var info = ReadCryptoInfo(); + var key = new CryptKey5("test", info); + using var first = key.Transformer(info.Salt); + info.PswCheck[0] ^= 1; + Assert.Throws(() => + key.Transformer(info.Salt) + ); + } +} diff --git a/tests/SharpCompress.Test/Rar/RarCommentTests.cs b/tests/SharpCompress.Test/Rar/RarCommentTests.cs new file mode 100644 index 00000000..a1c6d284 --- /dev/null +++ b/tests/SharpCompress.Test/Rar/RarCommentTests.cs @@ -0,0 +1,365 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Archives.Rar; +using SharpCompress.Common; +using SharpCompress.Common.Rar; +using SharpCompress.Compressors.Rar; +using SharpCompress.IO; +using SharpCompress.Readers; +using SharpCompress.Readers.Rar; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test.Rar; + +public class RarCommentTests : TestBase +{ + public static IEnumerable Comments() + { + // Generated with RAR 7.22: rar a -ma5 -m0 -qo- -zcomment.txt [-hptest|-ptest] archive.rar payload.txt. + foreach (var useAsync in new[] { false, true }) + { + yield return ["Rar5.comment.encrypted15.rar", "123456789012345", useAsync]; + yield return ["Rar5.comment.encrypted16.rar", "1234567890123456", useAsync]; + yield return ["Rar5.comment.encrypted17.rar", "123456789012345é", useAsync]; + yield return ["Rar5.comment.plain.rar", "123456789012345é", useAsync]; + yield return ["Rar5.comment.filesOnly.rar", "123456789012345é", useAsync]; + } + } + + [Theory] + [MemberData(nameof(Comments))] + public async Task Rar5_Comment_Archive(string filename, string expected, bool useAsync) + { + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, filename)); + var options = ReaderOptions.ForExternalStream.WithPassword("test"); + using var output = new MemoryStream(); + if (useAsync) + { + await using var archive = await RarArchive.OpenAsyncArchive(stream, options); + await foreach (var entry in archive.EntriesAsync) + { + Assert.Equal("payload.txt", entry.Key); + await entry.WriteToAsync(output, cancellationToken: CancellationToken.None); + } + await foreach (var volume in archive.VolumesAsync) + { + Assert.Equal(expected, ((RarVolume)volume).Comment); + } + } + else + { + using var archive = RarArchive.OpenArchive(stream, options); + var entry = Assert.Single(archive.Entries); + Assert.Equal("payload.txt", entry.Key); + entry.WriteTo(output); + Assert.Equal(expected, archive.Volumes.OfType().First().Comment); + } + Assert.Equal("hello from RAR!\n", Encoding.UTF8.GetString(output.ToArray())); + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(null, false)] + [InlineData(null, true)] + [InlineData("wrong", false)] + [InlineData("wrong", true)] + public async Task Rar5_Comment_Rejects_Invalid_Password(string? password, bool useAsync) + { + using var stream = File.OpenRead( + Path.Combine(TEST_ARCHIVES_PATH, "Rar5.comment.encrypted17.rar") + ); + var options = ReaderOptions.ForExternalStream with { Password = password }; + if (useAsync) + { + await Assert.ThrowsAsync(async () => + { + await using var archive = await RarArchive.OpenAsyncArchive(stream, options); + await foreach (var entry in archive.EntriesAsync) + { + _ = entry.Key; + } + }); + } + else + { + Assert.Throws(() => + { + using var archive = RarArchive.OpenArchive(stream, options); + _ = archive.Entries.ToArray(); + }); + } + } + + [Theory] + [MemberData(nameof(Comments))] + public async Task Rar5_Comment_Reader(string filename, string expected, bool useAsync) + { + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, filename)); + using var forwardOnly = new ForwardOnlyStream(stream); + var options = ReaderOptions.ForExternalStream.WithPassword("test"); + using var output = new MemoryStream(); + if (useAsync) + { + await using var reader = await ReaderFactory.OpenAsyncReader(forwardOnly, options); + Assert.True(await reader.MoveToNextEntryAsync(CancellationToken.None)); + Assert.Equal(expected, ((RarReader)reader).Volume!.Comment); + await reader.WriteEntryToAsync(output, CancellationToken.None); + Assert.False(await reader.MoveToNextEntryAsync(CancellationToken.None)); + } + else + { + using var reader = RarReader.OpenReader(forwardOnly, options); + Assert.True(reader.MoveToNextEntry()); + Assert.Equal(expected, ((RarReader)reader).Volume!.Comment); + reader.WriteEntryTo(output); + Assert.False(reader.MoveToNextEntry()); + } + Assert.Equal("hello from RAR!\n", Encoding.UTF8.GetString(output.ToArray())); + Assert.True(forwardOnly.CanRead); + } + + [Fact] + public async Task Rar5_Comment_Volume_Async_Cancellation() + { + using var stream = File.OpenRead( + Path.Combine(TEST_ARCHIVES_PATH, "Rar5.comment.encrypted17.rar") + ); + using var volume = new StreamRarArchiveVolume( + stream, + ReaderOptions.ForExternalStream.WithPassword("test"), + 0 + ); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(async () => + { + await foreach (var part in volume.GetVolumeFilePartsAsync(cancellation.Token)) + { + _ = part.FileHeader; + } + }); + } + + public static IEnumerable InvalidCommentSizes() + { + foreach (var useAsync in new[] { false, true }) + { + foreach (var useReader in new[] { false, true }) + { + foreach ( + var size in new long[] { 16 * 1024 * 1024 + 1, int.MaxValue, long.MaxValue } + ) + { + yield return [size, useAsync, useReader]; + } + } + } + } + + [Theory] + [MemberData(nameof(InvalidCommentSizes))] + public async Task Rar5_Comment_Rejects_Oversized_Data(long size, bool useAsync, bool useReader) + { + using var stream = CreateCommentArchive(size, 0, [1]); + var exception = await Assert.ThrowsAsync(() => + ReadCommentArchive(stream, useAsync, useReader) + ); + Assert.Contains("16 MiB", exception.Message); + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Rar5_Comment_Allows_Size_Limit(bool useAsync, bool useReader) + { + var data = new byte[16 * 1024 * 1024]; + data[0] = (byte)'a'; + using var stream = CreateCommentArchive(data.Length, 0, data); + Assert.Equal("a", await ReadCommentArchive(stream, useAsync, useReader)); + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Rar5_Comment_Rejects_Truncated_Data(bool useAsync, bool useReader) + { + using var stream = CreateCommentArchive(10, 0, [1, 2, 3]); + await Assert.ThrowsAsync(() => + ReadCommentArchive(stream, useAsync, useReader) + ); + Assert.True(stream.CanRead); + } + + public static IEnumerable UnsupportedCommentMethods() + { + foreach (var useAsync in new[] { false, true }) + { + foreach (var useReader in new[] { false, true }) + { + foreach (var method in Enumerable.Range(1, 7)) + { + yield return [method, useAsync, useReader]; + } + } + } + } + + [Theory] + [MemberData(nameof(UnsupportedCommentMethods))] + public async Task Rar5_Comment_Skips_Nonzero_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]); + Assert.Null(await ReadCommentArchive(stream, useAsync, useReader)); + Assert.True(stream.CanRead); + } + + private static async Task ReadCommentArchive( + Stream stream, + bool useAsync, + bool useReader + ) + { + var options = ReaderOptions.ForExternalStream; + if (useReader) + { + using var forwardOnly = new ForwardOnlyStream( + SharpCompressStream.CreateNonDisposing(stream) + ); + using var output = new MemoryStream(); + string? comment; + if (useAsync) + { + await using var reader = await ReaderFactory.OpenAsyncReader( + forwardOnly, + options, + CancellationToken.None + ); + Assert.True(await reader.MoveToNextEntryAsync(CancellationToken.None)); + Assert.Equal("payload.txt", reader.Entry.Key); + comment = ((RarReader)reader).Volume!.Comment; + await reader.WriteEntryToAsync(output, CancellationToken.None); + Assert.False(await reader.MoveToNextEntryAsync(CancellationToken.None)); + } + else + { + using var reader = RarReader.OpenReader(forwardOnly, options); + Assert.True(reader.MoveToNextEntry()); + Assert.Equal("payload.txt", reader.Entry.Key); + comment = ((RarReader)reader).Volume!.Comment; + reader.WriteEntryTo(output); + Assert.False(reader.MoveToNextEntry()); + } + Assert.Equal("hello from RAR!\n", Encoding.UTF8.GetString(output.ToArray())); + return comment; + } + + using var extracted = new MemoryStream(); + if (useAsync) + { + await using var archive = await RarArchive.OpenAsyncArchive( + stream, + options, + CancellationToken.None + ); + var count = 0; + await foreach (var entry in archive.EntriesAsync) + { + Assert.Equal("payload.txt", entry.Key); + await entry.WriteToAsync(extracted, cancellationToken: CancellationToken.None); + count++; + } + Assert.Equal(1, count); + Assert.Equal("hello from RAR!\n", Encoding.UTF8.GetString(extracted.ToArray())); + await foreach (var volume in archive.VolumesAsync) + { + return ((RarVolume)volume).Comment; + } + throw new InvalidOperationException("Expected a RAR volume."); + } + + using var syncArchive = RarArchive.OpenArchive(stream, options); + var syncEntry = Assert.Single(syncArchive.Entries); + Assert.Equal("payload.txt", syncEntry.Key); + syncEntry.WriteTo(extracted); + Assert.Equal("hello from RAR!\n", Encoding.UTF8.GetString(extracted.ToArray())); + return syncArchive.Volumes.OfType().First().Comment; + } + + private static MemoryStream CreateCommentArchive(long size, int method, byte[] commentData) + { + // Build small headers with valid CRCs so malformed sizes reach comment validation. + var archive = new MemoryStream(); + using var writer = new BinaryWriter(archive, Encoding.UTF8, leaveOpen: true); + writer.Write(new byte[] { 0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00 }); + WriteHeader(writer, [1, 0, 0]); + + using var body = new MemoryStream(); + using var header = new BinaryWriter(body, Encoding.UTF8, leaveOpen: true); + WriteVInts( + header, + 3, + 2, + commentData.Length, + size == long.MaxValue ? 8 : 0, + size, + 0, + method << 7, + 1, + 3 + ); + header.Write(Encoding.UTF8.GetBytes("CMT")); + WriteHeader(writer, body.ToArray()); + writer.Write(commentData); + + body.SetLength(0); + var payload = Encoding.UTF8.GetBytes("hello from RAR!\n"); + WriteVInts(header, 2, 2, payload.Length, 4, payload.Length, 0); + header.Write(~RarCRC.CheckCrc(uint.MaxValue, payload, 0, payload.Length)); + WriteVInts(header, 0, 1, 11); + header.Write(Encoding.UTF8.GetBytes("payload.txt")); + WriteHeader(writer, body.ToArray()); + writer.Write(payload); + WriteHeader(writer, [5, 0, 0]); + archive.Position = 0; + return archive; + } + + private static void WriteHeader(BinaryWriter writer, byte[] body) + { + using var bytes = new MemoryStream(); + using var header = new BinaryWriter(bytes); + WriteVInts(header, body.Length); + header.Write(body); + var data = bytes.ToArray(); + writer.Write(~RarCRC.CheckCrc(uint.MaxValue, data, 0, data.Length)); + writer.Write(data); + } + + private static void WriteVInts(BinaryWriter writer, params long[] values) + { + foreach (var value in values) + { + var remaining = (ulong)value; + while (remaining >= 0x80) + { + writer.Write((byte)((remaining & 0x7f) | 0x80)); + remaining >>= 7; + } + writer.Write((byte)remaining); + } + } +} diff --git a/tests/SharpCompress.Test/Rar/RarEncryptedHeaderScanTests.cs b/tests/SharpCompress.Test/Rar/RarEncryptedHeaderScanTests.cs new file mode 100644 index 00000000..9f6e9094 --- /dev/null +++ b/tests/SharpCompress.Test/Rar/RarEncryptedHeaderScanTests.cs @@ -0,0 +1,140 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives.Rar; +using SharpCompress.Common; +using SharpCompress.Common.Rar.Headers; +using SharpCompress.IO; +using SharpCompress.Readers; +using Xunit; + +namespace SharpCompress.Test.Rar; + +public class RarEncryptedHeaderScanTests : TestBase +{ + [Theory] + [InlineData("Rar.encrypted_filesAndHeader.rar", false)] + [InlineData("Rar.encrypted_filesAndHeader.rar", true)] + [InlineData("Rar5.encrypted_filesAndHeader.rar", false)] + [InlineData("Rar5.encrypted_filesAndHeader.rar", true)] + public async Task Rar_EncryptedHeaders_Rescan(string filename, bool useAsync) + { + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, filename)); + var factory = new RarHeaderFactory( + StreamingMode.Seekable, + ReaderOptions.ForExternalStream.WithPassword("test") + ); + var expected = await ReadNames(false); + Assert.Equal(6, expected.Count); + stream.Position = 0; + Assert.Single(await ReadNames(true)); + stream.Position = 0; + Assert.Equal(expected, await ReadNames(false)); + + async Task> ReadNames(bool stopAfterFirst) + { + var names = new List(); + if (useAsync) + { + await foreach (var header in factory.ReadHeadersAsync(stream)) + { + if (header is FileHeader file && header.HeaderType == HeaderType.File) + { + Assert.NotNull(file.FileName); + names.Add(file.FileName); + if (stopAfterFirst) + { + break; + } + } + } + } + else + { + foreach (var header in factory.ReadHeaders(stream)) + { + if (header is FileHeader file && header.HeaderType == HeaderType.File) + { + Assert.NotNull(file.FileName); + names.Add(file.FileName); + if (stopAfterFirst) + { + break; + } + } + } + } + return names; + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Rar5_Solid_EncryptedHeaders_ExtractAll(bool readMetadataFirst) + { + using var stream = File.OpenRead( + Path.Combine(TEST_ARCHIVES_PATH, "Rar5.solid.encrypted_headers.rar") + ); + using var archive = RarArchive.OpenArchive( + stream, + ReaderOptions.ForExternalStream.WithPassword("test") + ); + if (readMetadataFirst) + { + Assert.True(archive.IsSolid); + Assert.Equal(3, archive.Entries.Count()); + } + using var reader = archive.ExtractAllEntries(); + var count = 0; + while (reader.MoveToNextEntry()) + { + Assert.Equal($"f{count:D3}.txt", reader.Entry.Key); + using var entryStream = reader.OpenEntryStream(); + using var textReader = new StreamReader(entryStream); + Assert.Equal("Solid encrypted header regression test.\n", textReader.ReadToEnd()); + count++; + } + Assert.Equal(3, count); + Assert.Equal(3, archive.Entries.Count()); + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Rar5_Solid_EncryptedHeaders_ExtractAll_Async(bool readMetadataFirst) + { + using var stream = File.OpenRead( + Path.Combine(TEST_ARCHIVES_PATH, "Rar5.solid.encrypted_headers.rar") + ); + await using var archive = await RarArchive.OpenAsyncArchive( + stream, + ReaderOptions.ForExternalStream.WithPassword("test"), + CancellationToken.None + ); + if (readMetadataFirst) + { + Assert.True(await archive.IsSolidAsync()); + Assert.Equal(3, (await archive.EntriesAsync.ToListAsync()).Count); + } + await using var reader = await archive.ExtractAllEntriesAsync(); + var count = 0; + while (await reader.MoveToNextEntryAsync(CancellationToken.None)) + { + Assert.Equal($"f{count:D3}.txt", reader.Entry.Key); + using var entryStream = await reader.OpenEntryStreamAsync(CancellationToken.None); + using var textReader = new StreamReader(entryStream); + Assert.Equal( + "Solid encrypted header regression test.\n", + await textReader.ReadToEndAsync() + ); + count++; + } + Assert.Equal(3, count); + Assert.Equal(3, (await archive.EntriesAsync.ToListAsync()).Count); + Assert.True(stream.CanRead); + } +} diff --git a/tests/SharpCompress.Test/Rar/RarHeaderFactoryTest.cs b/tests/SharpCompress.Test/Rar/RarHeaderFactoryTest.cs index 6fd42ba9..0f8e92c7 100644 --- a/tests/SharpCompress.Test/Rar/RarHeaderFactoryTest.cs +++ b/tests/SharpCompress.Test/Rar/RarHeaderFactoryTest.cs @@ -1,4 +1,6 @@ using System.IO; +using System.Threading.Tasks; +using SharpCompress.Common; using SharpCompress.Common.Rar.Headers; using SharpCompress.IO; using SharpCompress.Readers; @@ -52,4 +54,64 @@ public class RarHeaderFactoryTest : TestBase } } } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Rar5_ReadHeaders_WithCachedKey(bool useAsync) + { + var options = ReaderOptions.ForExternalStream.WithPassword("test"); + Assert.Equal(6, await CountEncryptedFileHeaders(useAsync, options, false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Rar5_ReadHeaders_RejectChangedPassword(bool useAsync) + { + var options = ReaderOptions.ForExternalStream.WithPassword("test"); + await Assert.ThrowsAsync(async () => + await CountEncryptedFileHeaders(useAsync, options, true) + ); + } + + private async Task CountEncryptedFileHeaders( + bool useAsync, + ReaderOptions options, + bool changePassword + ) + { + using var stream = File.OpenRead( + Path.Combine(TEST_ARCHIVES_PATH, "Rar5.encrypted_filesAndHeader.rar") + ); + var factory = new RarHeaderFactory(StreamingMode.Seekable, options); + var count = 0; + if (useAsync) + { + await foreach (var header in factory.ReadHeadersAsync(stream)) + { + CountHeader(header); + } + } + else + { + foreach (var header in factory.ReadHeaders(stream)) + { + CountHeader(header); + } + } + return count; + + void CountHeader(IRarHeader header) + { + if (header.HeaderType == HeaderType.File) + { + count++; + if (changePassword) + { + options.Password = "failed"; + } + } + } + } } diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipHeaderValidationTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipHeaderValidationTests.cs new file mode 100644 index 00000000..2d932d8a --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/SevenZipHeaderValidationTests.cs @@ -0,0 +1,128 @@ +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Common; +using SharpCompress.Common.SevenZip; +using SharpCompress.Crypto; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test.SevenZip; + +public class SevenZipHeaderValidationTests +{ + [Theory] + [InlineData(0, 64 * 1024 * 1024, 0)] + [InlineData(0, 256 * 1024 * 1024, 0)] + [InlineData(0, 2, 1)] + [InlineData(1, 2, 2)] + [InlineData(2, 1, 2)] + public void SevenZipArchive_TruncatedNextHeader_ThrowsBeforeReadingHeader( + int offset, + int size, + int available + ) + { + using var stream = CreateArchive(offset, size, available); + using var archive = ArchiveFactory.OpenArchive(stream); + + var exception = Assert.Throws(() => archive.Entries.ToList()); + + Assert.Equal("Next header extends beyond the end of stream.", exception.Message); + Assert.Equal(32L, stream.Position); + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(0, 64 * 1024 * 1024, 0)] + [InlineData(0, 256 * 1024 * 1024, 0)] + [InlineData(0, 2, 1)] + [InlineData(1, 2, 2)] + [InlineData(2, 1, 2)] + public async Task SevenZipArchive_TruncatedNextHeader_AsyncThrowsBeforeReadingHeader( + int offset, + int size, + int available + ) + { + using var stream = CreateArchive(offset, size, available); + using var asyncStream = new AsyncOnlyStream(stream, disposeStream: false); + await using var archive = await ArchiveFactory.OpenAsyncArchive( + asyncStream, + cancellationToken: CancellationToken.None + ); + + var exception = await Assert.ThrowsAsync(async () => + { + await foreach ( + var entry in archive.EntriesAsync.WithCancellation(CancellationToken.None) + ) { } + }); + + Assert.Equal("Next header extends beyond the end of stream.", exception.Message); + Assert.Equal(32L, stream.Position); + Assert.True(stream.CanRead); + } + + [Theory] + [InlineData(0, 0, 0)] + [InlineData(0, 2, 2)] + [InlineData(3, 2, 5)] + public void SevenZipArchive_NextHeaderAtEnd_IsValid(int offset, int size, int available) + { + using var stream = CreateArchive(offset, size, available); + using var archive = ArchiveFactory.OpenArchive(stream); + + Assert.Empty(archive.Entries); + } + + [Theory] + [InlineData(0, 0, 0)] + [InlineData(0, 2, 2)] + [InlineData(3, 2, 5)] + public async Task SevenZipArchive_NextHeaderAtEnd_AsyncIsValid( + int offset, + int size, + int available + ) + { + using var stream = CreateArchive(offset, size, available); + using var asyncStream = new AsyncOnlyStream(stream, disposeStream: false); + await using var archive = await ArchiveFactory.OpenAsyncArchive( + asyncStream, + cancellationToken: CancellationToken.None + ); + + var count = 0; + await foreach (var entry in archive.EntriesAsync.WithCancellation(CancellationToken.None)) + { + count++; + } + + Assert.Equal(0, count); + } + + private static MemoryStream CreateArchive(int offset, int size, int available) + { + // A minimal uncompressed next header: kHeader followed by kEnd. + byte[] header = [1, 0]; + var crc = Crc32Stream.Compute( + Crc32Stream.DEFAULT_POLYNOMIAL, + Crc32Stream.DEFAULT_SEED, + header + ); + var stream = new MemoryStream(); + SevenZipSignatureHeaderWriter.WriteFinal(stream, (ulong)offset, (ulong)size, crc); + stream.SetLength(32 + available); + if (available >= offset + header.Length) + { + stream.Position = 32 + offset; + stream.Write(header, 0, header.Length); + } + + stream.Position = 0; + return stream; + } +} diff --git a/tests/SharpCompress.Test/Zip/ZipArchiveTests.cs b/tests/SharpCompress.Test/Zip/ZipArchiveTests.cs index ebee095a..52078ef7 100644 --- a/tests/SharpCompress.Test/Zip/ZipArchiveTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipArchiveTests.cs @@ -148,6 +148,17 @@ public class ZipArchiveTests : ArchiveTests //uses first part to search for all parts and compares against this array ArchiveGetParts(new[] { "WinZip26.nocomp.multi.zipx", "WinZip26.nocomp.multi.zx01" }); + [Fact] + public void Zip_GetParts_UpperCaseZip() + { + var zip = Path.Combine(SCRATCH2_FILES_PATH, "MULTI.ZIP"); + var z01 = Path.Combine(SCRATCH2_FILES_PATH, "MULTI.z01"); + File.Copy(Path.Combine(TEST_ARCHIVES_PATH, "Infozip.nocomp.multi.zip"), zip); + File.Copy(Path.Combine(TEST_ARCHIVES_PATH, "Infozip.nocomp.multi.z01"), z01); + + Assert.Equal(new[] { zip, z01 }, ArchiveFactory.GetFileParts(zip)); + } + [Fact] public void Zip_GetPartsSplit() => //uses first part to search for all parts and compares against this array diff --git a/tests/TestArchives/Archives/Rar5.comment.encrypted15.rar b/tests/TestArchives/Archives/Rar5.comment.encrypted15.rar new file mode 100644 index 00000000..89c618bb Binary files /dev/null and b/tests/TestArchives/Archives/Rar5.comment.encrypted15.rar differ diff --git a/tests/TestArchives/Archives/Rar5.comment.encrypted16.rar b/tests/TestArchives/Archives/Rar5.comment.encrypted16.rar new file mode 100644 index 00000000..421541d5 Binary files /dev/null and b/tests/TestArchives/Archives/Rar5.comment.encrypted16.rar differ diff --git a/tests/TestArchives/Archives/Rar5.comment.encrypted17.rar b/tests/TestArchives/Archives/Rar5.comment.encrypted17.rar new file mode 100644 index 00000000..e38ba392 Binary files /dev/null and b/tests/TestArchives/Archives/Rar5.comment.encrypted17.rar differ diff --git a/tests/TestArchives/Archives/Rar5.comment.filesOnly.rar b/tests/TestArchives/Archives/Rar5.comment.filesOnly.rar new file mode 100644 index 00000000..1c9e65b1 Binary files /dev/null and b/tests/TestArchives/Archives/Rar5.comment.filesOnly.rar differ diff --git a/tests/TestArchives/Archives/Rar5.comment.plain.rar b/tests/TestArchives/Archives/Rar5.comment.plain.rar new file mode 100644 index 00000000..5ab75b3d Binary files /dev/null and b/tests/TestArchives/Archives/Rar5.comment.plain.rar differ diff --git a/tests/TestArchives/Archives/Rar5.solid.encrypted_headers.rar b/tests/TestArchives/Archives/Rar5.solid.encrypted_headers.rar new file mode 100644 index 00000000..a9c035a3 Binary files /dev/null and b/tests/TestArchives/Archives/Rar5.solid.encrypted_headers.rar differ