diff --git a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs index 8bbb51349de5d8..c90eebe7b6325f 100644 --- a/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs +++ b/src/libraries/System.IO.Compression/ref/System.IO.Compression.cs @@ -175,10 +175,10 @@ internal ZipArchiveEntry() { } public void Delete() { } public System.IO.Stream Open() { throw null; } public System.IO.Stream Open(System.IO.FileAccess access) { throw null; } + public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.IO.Stream Open(System.IO.FileAccess access, System.ReadOnlySpan password) { throw null; } public System.IO.Stream Open(System.ReadOnlySpan password) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.ReadOnlySpan password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } - public System.Threading.Tasks.Task OpenAsync(System.IO.FileAccess access, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.ReadOnlySpan password, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public System.Threading.Tasks.Task OpenAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } public override string ToString() { throw null; } @@ -204,6 +204,15 @@ public enum ZipEncryptionMethod Aes192 = 3, Aes256 = 4, } + public sealed partial class ZipStreamReader : System.IAsyncDisposable, System.IDisposable + { + public ZipStreamReader(System.IO.Stream stream, bool leaveOpen = false) { } + public ZipStreamReader(System.IO.Stream stream, System.Text.Encoding? entryNameEncoding, bool leaveOpen = false) { } + public void Dispose() { } + public System.Threading.Tasks.ValueTask DisposeAsync() { throw null; } + public System.IO.Compression.ZipArchiveEntry? GetNextEntry(bool copyData = false) { throw null; } + public System.Threading.Tasks.ValueTask GetNextEntryAsync(bool copyData = false, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) { throw null; } + } public sealed partial class ZLibCompressionOptions { public static int DefaultWindowLog2 { get { throw null; } } diff --git a/src/libraries/System.IO.Compression/src/Resources/Strings.resx b/src/libraries/System.IO.Compression/src/Resources/Strings.resx index 8f70a3e2dea3d2..62521c1a49966c 100644 --- a/src/libraries/System.IO.Compression/src/Resources/Strings.resx +++ b/src/libraries/System.IO.Compression/src/Resources/Strings.resx @@ -408,12 +408,24 @@ Stream does not support writing. + + Stored compression entries with data descriptors cannot be read in forward-only mode because the compressed size is unknown. + The CRC32 checksum of the extracted data does not match the expected value from the archive. The decompressed data length does not match the expected value from the archive. + + Encrypted entries with data descriptors cannot be read in forward-only mode because the compressed size is unknown. + + + The forward-only data stream for this entry has already been opened. A ZipStreamReader entry can only be read once. + + + The ZIP archive contains an invalid local file header signature. + Encryption type is not specified. @@ -429,4 +441,4 @@ The entry's encryption method is not supported. - \ No newline at end of file + diff --git a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj index c71e8f1e4b181f..0f7e572c472e71 100644 --- a/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj +++ b/src/libraries/System.IO.Compression/src/System.IO.Compression.csproj @@ -1,4 +1,4 @@ - + $(NetCoreAppCurrent)-windows;$(NetCoreAppCurrent)-unix;$(NetCoreAppCurrent)-browser;$(NetCoreAppCurrent)-wasi;$(NetCoreAppCurrent) @@ -64,6 +64,7 @@ + diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs index cb5a365bbe3e7d..0360b7d1ade67e 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/WinZipAesStream.cs @@ -64,7 +64,7 @@ internal static WinZipAesKeyMaterial CreateKey(ReadOnlySpan password, byte /// /// Creates a WinZipAesStream synchronously. Reads and validates the header for decryption. /// - internal static WinZipAesStream Create(Stream baseStream, WinZipAesKeyMaterial keyMaterial, long totalStreamSize, bool encrypting, bool leaveOpen = false) + internal static WinZipAesStream Create(Stream baseStream, WinZipAesKeyMaterial keyMaterial, long totalStreamSize, bool encrypting, bool leaveOpen = false, bool saltAlreadyRead = false) { if (OperatingSystem.IsBrowser()) { @@ -75,7 +75,7 @@ internal static WinZipAesStream Create(Stream baseStream, WinZipAesKeyMaterial k if (!encrypting) { - ReadAndValidateHeaderCore(isAsync: false, baseStream, keyMaterial, CancellationToken.None).GetAwaiter().GetResult(); + ReadAndValidateHeaderCore(isAsync: false, baseStream, keyMaterial, saltAlreadyRead, CancellationToken.None).GetAwaiter().GetResult(); } return new WinZipAesStream(baseStream, keyMaterial, totalStreamSize, encrypting, leaveOpen); @@ -84,7 +84,7 @@ internal static WinZipAesStream Create(Stream baseStream, WinZipAesKeyMaterial k /// /// Creates a WinZipAesStream asynchronously. Reads and validates the header for decryption. /// - internal static async Task CreateAsync(Stream baseStream, WinZipAesKeyMaterial keyMaterial, long totalStreamSize, bool encrypting, bool leaveOpen = false, CancellationToken cancellationToken = default) + internal static async Task CreateAsync(Stream baseStream, WinZipAesKeyMaterial keyMaterial, long totalStreamSize, bool encrypting, bool leaveOpen = false, bool saltAlreadyRead = false, CancellationToken cancellationToken = default) { if (OperatingSystem.IsBrowser()) { @@ -95,7 +95,7 @@ internal static async Task CreateAsync(Stream baseStream, WinZi if (!encrypting) { - await ReadAndValidateHeaderCore(isAsync: true, baseStream, keyMaterial, cancellationToken).ConfigureAwait(false); + await ReadAndValidateHeaderCore(isAsync: true, baseStream, keyMaterial, saltAlreadyRead, cancellationToken).ConfigureAwait(false); } return new WinZipAesStream(baseStream, keyMaterial, totalStreamSize, encrypting, leaveOpen); @@ -103,49 +103,49 @@ internal static async Task CreateAsync(Stream baseStream, WinZi /// /// Reads and validates the WinZip AES header (salt + password verifier) from the stream. + /// When is , the salt has already been + /// consumed from the stream by the caller (e.g. the forward-only reader, which must read it to + /// derive the key before this stream is created), so only the password verifier is read here. /// - private static async Task ReadAndValidateHeaderCore(bool isAsync, Stream baseStream, WinZipAesKeyMaterial keyMaterial, CancellationToken cancellationToken) + private static async Task ReadAndValidateHeaderCore(bool isAsync, Stream baseStream, WinZipAesKeyMaterial keyMaterial, bool saltAlreadyRead, CancellationToken cancellationToken) { if (OperatingSystem.IsBrowser()) { throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); } - int saltSize = keyMaterial.SaltSize; - // Read salt from stream - byte[] fileSalt = new byte[saltSize]; - if (isAsync) - { - await baseStream.ReadExactlyAsync(fileSalt, cancellationToken).ConfigureAwait(false); - } - else + if (!saltAlreadyRead) { - baseStream.ReadExactly(fileSalt); + // Read and verify the salt. In WinZip AES the salt is stored in the archive header and + // is not secret; FixedTimeEquals is used here for consistency. + byte[] fileSalt = new byte[keyMaterial.SaltSize]; + await ReadExactly(fileSalt).ConfigureAwait(false); + + if (!CryptographicOperations.FixedTimeEquals(fileSalt, keyMaterial.Salt)) + { + throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + } } - // Read the 2-byte password verifier from stream + // Read and compare the 2-byte password verifier. This is a weak check (only 2 bytes) used + // to fail fast on an obviously wrong password; it is not a security guarantee. byte[] verifier = new byte[2]; - if (isAsync) - { - await baseStream.ReadExactlyAsync(verifier, cancellationToken).ConfigureAwait(false); - } - else - { - baseStream.ReadExactly(verifier); - } + await ReadExactly(verifier).ConfigureAwait(false); - // Verify the salt matches. In WinZip AES, the salt is stored in the archive - // header and is not secret; FixedTimeEquals is used here for consistency. - if (!CryptographicOperations.FixedTimeEquals(fileSalt, keyMaterial.Salt)) + if (!CryptographicOperations.FixedTimeEquals(verifier, keyMaterial.PasswordVerifier)) { - throw new InvalidDataException(SR.LocalFileHeaderCorrupt); + throw new InvalidDataException(SR.InvalidPassword); } - // Compare the 2-byte password verifier. This is a weak check (only 2 bytes) used to - // fail fast on an obviously wrong password; it is not a security guarantee. - if (!CryptographicOperations.FixedTimeEquals(verifier, keyMaterial.PasswordVerifier)) + ValueTask ReadExactly(byte[] buffer) { - throw new InvalidDataException(SR.InvalidPassword); + if (isAsync) + { + return baseStream.ReadExactlyAsync(buffer, cancellationToken); + } + + baseStream.ReadExactly(buffer); + return ValueTask.CompletedTask; } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs index 6a74dc6f308525..bb8f885722d575 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.Async.cs @@ -22,6 +22,12 @@ public partial class ZipArchiveEntry public Task OpenAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + + if (_isForwardReadEntry) + { + return OpenForwardReadModeAsync(default, cancellationToken); + } + ThrowIfInvalidArchive(); return OpenAsyncCore(InferAccessFromMode(), default, cancellationToken); } @@ -48,6 +54,17 @@ public Task OpenAsync(CancellationToken cancellationToken = default) public Task OpenAsync(FileAccess access, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + + if (_isForwardReadEntry) + { + if (access != FileAccess.Read) + { + throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); + } + + return OpenForwardReadModeAsync(default, cancellationToken); + } + ThrowIfInvalidArchive(); ValidateAccessForMode(access); return OpenAsyncCore(access, default, cancellationToken); @@ -75,6 +92,17 @@ public Task OpenAsync(FileAccess access, CancellationToken cancellationT public Task OpenAsync(FileAccess access, ReadOnlySpan password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + + if (_isForwardReadEntry) + { + if (access != FileAccess.Read) + { + throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); + } + + return OpenForwardReadModeAsync(password, cancellationToken); + } + ThrowIfInvalidArchive(); ValidateAccessForMode(access); @@ -103,6 +131,12 @@ public Task OpenAsync(FileAccess access, ReadOnlySpan password, Ca public Task OpenAsync(ReadOnlySpan password, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); + + if (_isForwardReadEntry) + { + return OpenForwardReadModeAsync(password, cancellationToken); + } + ThrowIfInvalidArchive(); if (IsEncrypted && password.IsEmpty) @@ -145,6 +179,92 @@ private Task OpenAsyncCore(FileAccess access, ReadOnlySpan passwor } } + // Async counterpart of OpenForwardReadMode. Non-encrypted entries complete synchronously; encrypted + // entries read the encryption header (AES salt / ZipCrypto header) asynchronously from the raw stream. + private Task OpenForwardReadModeAsync(ReadOnlySpan password, CancellationToken cancellationToken) + { + if (_forwardDataStream is null) + { + return Task.FromResult(Stream.Null); + } + + if (_forwardStreamOpened) + { + throw new InvalidOperationException(SR.ZipStreamEntryAlreadyConsumed); + } + + if (!IsEncrypted) + { + _forwardStreamOpened = true; + return Task.FromResult(_forwardDataStream); + } + + // Validate before marking the single-use stream consumed so that an attempt which reads no + // bytes (missing password or unsupported scheme) can be retried with a valid password. + if (Encryption == ZipEncryptionMethod.Unknown) + { + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } + + if (password.IsEmpty) + { + throw new InvalidDataException(SR.PasswordRequired); + } + + _forwardStreamOpened = true; + + Stream rawStream = _forwardDataStream; + + if (IsAesEncrypted) + { + if (OperatingSystem.IsBrowser()) + { + throw new PlatformNotSupportedException(SR.WinZipEncryptionNotSupportedOnBrowser); + } + + int keySizeBits = GetAesKeySizeBits(Encryption); + // The salt must be read from the stream before the key can be derived, so the password + // has to cross the async boundary; copy it out of the span into a local array. + return OpenForwardAesAsyncCore(rawStream, password.ToArray(), keySizeBits, cancellationToken); + } + + if (IsZipCryptoEncrypted) + { + byte expectedCheckByte = CalculateZipCryptoCheckByte(); + ZipCryptoKeys keyMaterial = ZipCryptoStream.CreateKey(password); + return OpenForwardZipCryptoAsyncCore(rawStream, keyMaterial, expectedCheckByte, cancellationToken); + } + + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + + async Task OpenForwardAesAsyncCore(Stream stream, char[] pwd, int keySizeBits, CancellationToken ct) + { + int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); + byte[] salt = new byte[saltSize]; + await stream.ReadExactlyAsync(salt, ct).ConfigureAwait(false); + + WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(pwd, salt, keySizeBits); + Stream decrypted = await WinZipAesStream.CreateAsync( + baseStream: stream, + keyMaterial: keyMaterial, + totalStreamSize: _compressedSize, + encrypting: false, + // The forward reader owns the raw stream and drains/disposes it when it advances, + // so the decryption stream must not dispose it on the caller's behalf. + leaveOpen: true, + saltAlreadyRead: true, + cancellationToken: ct).ConfigureAwait(false); + + return BuildDecompressionPipeline(decrypted); + } + + async Task OpenForwardZipCryptoAsyncCore(Stream stream, ZipCryptoKeys keyMaterial, byte checkByte, CancellationToken ct) + { + Stream decrypted = await ZipCryptoStream.CreateAsync(stream, keyMaterial, checkByte, encrypting: false, ct, leaveOpen: true).ConfigureAwait(false); + return BuildDecompressionPipeline(decrypted); + } + } + internal async Task GetOffsetOfCompressedDataAsync(CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs index f6f0dfc2af3106..f73bcf727be7d1 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchiveEntry.cs @@ -48,6 +48,13 @@ public partial class ZipArchiveEntry private byte[] _fileComment; private ZipEncryptionMethod _encryptionMethod; private readonly CompressionLevel _compressionLevel; + // Set when this entry is produced by ZipStreamReader for forward-only reading. + // Such an entry is not attached to a ZipArchive (_archive is null) and exposes a + // single-use data stream through Open(). + private readonly bool _isForwardReadEntry; + private readonly Stream? _forwardDataStream; + private bool _forwardStreamOpened; + private ZipCompressionMethod _headerCompressionMethod; private ushort? _aeVersion; // Cached derived key material for encrypted entries to allow updating in place. @@ -209,6 +216,69 @@ internal ZipArchiveEntry(ZipArchive archive, string entryName) Changes = ZipArchive.ChangeState.Unchanged; } + // Initializes a ZipArchiveEntry instance for an entry read sequentially from a + // ZipStreamReader. Such an entry is not attached to a ZipArchive (Archive is null), + // is populated from the local file header, and exposes a single-use forward-only + // data stream through Open(). + internal ZipArchiveEntry( + string fullName, + byte[] fullNameBytes, + ZipCompressionMethod compressionMethod, + DateTimeOffset lastModified, + uint crc32, + long compressedSize, + long uncompressedSize, + ushort generalPurposeBitFlag, + ushort versionNeeded, + Stream? dataStream, + ZipEncryptionMethod encryptionMethod, + ZipCompressionMethod headerCompressionMethod, + ushort aeVersion) + { + _archive = null!; + + _isForwardReadEntry = true; + _originallyInArchive = true; + _forwardDataStream = dataStream; + + _diskNumberStart = 0; + _versionMadeByPlatform = CurrentZipPlatform; + _versionMadeBySpecification = ZipVersionNeededValues.Default; + _versionToExtract = (ZipVersionNeededValues)versionNeeded; + _generalPurposeBitFlag = (BitFlagValues)generalPurposeBitFlag; + // For AES entries the header compression method is 99 (the WinZip AES wrapper indicator), + // while CompressionMethod carries the real method parsed from the AES extra field. + _headerCompressionMethod = headerCompressionMethod; + Encryption = encryptionMethod; + _aeVersion = aeVersion; + CompressionMethod = compressionMethod; + _lastModified = lastModified; + _compressedSize = compressedSize; + _uncompressedSize = uncompressedSize; + _externalFileAttr = 0; + _offsetOfLocalHeader = 0; + _storedOffsetOfCompressedData = null; + _crc32 = crc32; + + _compressedBytes = null; + _storedUncompressedData = null; + _currentlyOpenForWrite = false; + _everOpenedForWrite = false; + _outstandingWriteStream = null; + + _storedEntryNameBytes = fullNameBytes; + _storedEntryName = fullName; + DetectEntryNameVersion(); + + _lhUnknownExtraFields = null; + _cdUnknownExtraFields = null; + _fileComment = Array.Empty(); + + _compressionLevel = MapCompressionLevel(_generalPurposeBitFlag, CompressionMethod); + + Changes = ZipArchive.ChangeState.Unchanged; + } + /// /// The ZipArchive that this entry belongs to. If this entry has been deleted, this will return null. /// @@ -419,6 +489,34 @@ public long Length internal long OffsetOfLocalHeader => _offsetOfLocalHeader; + // True when the local file header set the data-descriptor bit, meaning the CRC-32 + // and sizes are not known until the entry's data has been fully read. + internal bool HasDataDescriptor => (_generalPurposeBitFlag & BitFlagValues.DataDescriptor) != 0; + + // The single-use forward-only data stream for a ZipStreamReader entry, or null when + // the entry has no data (for example a directory entry). Null for archive-backed entries. + internal Stream? ForwardDataStream => _forwardDataStream; + + // Populates the CRC-32 and sizes of a forward-read data-descriptor entry after its data + // has been drained, validating them against the values accumulated while reading. + internal void UpdateDataDescriptor(uint crc32, long compressedLength, long length, + uint runningCrc, long totalBytesRead) + { + if (runningCrc != crc32) + { + throw new InvalidDataException(SR.CrcMismatch); + } + + if (totalBytesRead != length) + { + throw new InvalidDataException(SR.UnexpectedStreamLength); + } + + _crc32 = crc32; + _compressedSize = compressedLength; + _uncompressedSize = length; + } + /// /// Deletes the entry from the archive. /// @@ -458,6 +556,11 @@ public void Delete() /// The ZipArchive that this entry belongs to has been disposed. public Stream Open() { + if (_isForwardReadEntry) + { + return OpenForwardReadMode(); + } + ThrowIfInvalidArchive(); return OpenCore(InferAccessFromMode()); } @@ -475,6 +578,11 @@ public Stream Open() /// The password used to decrypt the entry. If the entry is not encrypted, this parameter is ignored. public Stream Open(ReadOnlySpan password) { + if (_isForwardReadEntry) + { + return OpenForwardReadMode(password); + } + ThrowIfInvalidArchive(); if (IsEncrypted && password.IsEmpty) @@ -505,6 +613,16 @@ public Stream Open(ReadOnlySpan password) /// The ZipArchive that this entry belongs to has been disposed. public Stream Open(FileAccess access) { + if (_isForwardReadEntry) + { + if (access != FileAccess.Read) + { + throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); + } + + return OpenForwardReadMode(); + } + ThrowIfInvalidArchive(); ValidateAccessForMode(access); return OpenCore(access); @@ -512,6 +630,16 @@ public Stream Open(FileAccess access) public Stream Open(FileAccess access, ReadOnlySpan password) { + if (_isForwardReadEntry) + { + if (access != FileAccess.Read) + { + throw new InvalidOperationException(SR.CannotBeWrittenInReadMode); + } + + return OpenForwardReadMode(password); + } + ThrowIfInvalidArchive(); ValidateAccessForMode(access); @@ -1137,6 +1265,91 @@ private Stream OpenInReadMode(bool checkOpenable, ReadOnlySpan password = return OpenInReadModeGetDataCompressor(GetOffsetOfCompressedData(), password); } + // Returns the forward-only data stream for a ZipStreamReader entry. The stream is + // single-use: once returned it is consumed as it is read, and it is invalidated when + // the reader advances to the next entry. Directory or empty entries return Stream.Null. + // For encrypted entries the raw bounded stream is decrypted and decompressed here, reusing + // the same pipeline as the seekable read path. + private Stream OpenForwardReadMode(ReadOnlySpan password = default) + { + if (_forwardDataStream is null) + { + return Stream.Null; + } + + if (_forwardStreamOpened) + { + throw new InvalidOperationException(SR.ZipStreamEntryAlreadyConsumed); + } + + if (!IsEncrypted) + { + _forwardStreamOpened = true; + return _forwardDataStream; + } + + // Validate before marking the single-use stream consumed so that an attempt which reads no + // bytes (missing password or unsupported scheme) can be retried with a valid password. + if (Encryption == ZipEncryptionMethod.Unknown) + { + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } + + if (password.IsEmpty) + { + throw new InvalidDataException(SR.PasswordRequired); + } + + _forwardStreamOpened = true; + + Stream decrypted = WrapForwardReadStreamWithDecryption(_forwardDataStream, password); + return BuildDecompressionPipeline(decrypted); + } + + // Decrypts a forward-only raw entry stream. Unlike WrapWithDecryptionIfNeeded, the AES salt is + // not pre-read (there is no central directory pass), so it is consumed sequentially from the raw + // stream here and the AES stream is created in salt-already-read mode. + private Stream WrapForwardReadStreamWithDecryption(Stream rawStream, ReadOnlySpan password) + { + if (Encryption == ZipEncryptionMethod.Unknown) + { + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } + + if (password.IsEmpty) + { + throw new InvalidDataException(SR.PasswordRequired); + } + + if (IsAesEncrypted) + { + int keySizeBits = GetAesKeySizeBits(Encryption); + int saltSize = WinZipAesStream.GetSaltSize(keySizeBits); + byte[] salt = new byte[saltSize]; + rawStream.ReadExactly(salt); + + WinZipAesKeyMaterial keyMaterial = WinZipAesStream.CreateKey(password, salt, keySizeBits); + return WinZipAesStream.Create( + baseStream: rawStream, + keyMaterial: keyMaterial, + totalStreamSize: _compressedSize, + encrypting: false, + // The forward reader owns rawStream and drains/disposes it when it advances, + // so the decryption stream must not dispose it on the caller's behalf. + leaveOpen: true, + saltAlreadyRead: true); + } + + if (IsZipCryptoEncrypted) + { + byte expectedCheckByte = CalculateZipCryptoCheckByte(); + ZipCryptoKeys keyMaterial = ZipCryptoStream.CreateKey(password); + return ZipCryptoStream.Create(rawStream, keyMaterial, expectedCheckByte, encrypting: false, leaveOpen: true); + } + + throw new NotSupportedException(SR.UnsupportedEncryptionMethod); + } + private Stream OpenInReadModeGetDataCompressor(long offsetOfCompressedData, ReadOnlySpan password = default) { Stream compressedStream = new SubReadStream(_archive.ArchiveStream, offsetOfCompressedData, _compressedSize); diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs index 90f5ea6c0f2a6c..206392d30ab1e7 100644 --- a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipCustomStreams.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; @@ -96,10 +95,8 @@ private void ThrowIfCantSeek() public override int Read(byte[] buffer, int offset, int count) { - ThrowIfDisposed(); - ThrowIfCantRead(); - - return _baseStream.Read(buffer, offset, count); + ValidateBufferArguments(buffer, offset, count); + return Read(buffer.AsSpan(offset, count)); } public override int Read(Span buffer) @@ -120,10 +117,8 @@ public override int ReadByte() public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) { - ThrowIfDisposed(); - ThrowIfCantRead(); - - return _baseStream.ReadAsync(buffer, offset, count, cancellationToken); + ValidateBufferArguments(buffer, offset, count); + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); } public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) @@ -210,7 +205,6 @@ public override void Flush() { ThrowIfDisposed(); ThrowIfCantWrite(); - _baseStream.Flush(); } @@ -218,7 +212,6 @@ public override Task FlushAsync(CancellationToken cancellationToken) { ThrowIfDisposed(); ThrowIfCantWrite(); - return _baseStream.FlushAsync(cancellationToken); } @@ -522,6 +515,9 @@ public CrcValidatingReadStream(Stream baseStream, uint expectedCrc, long expecte _runningCrc = 0; } + internal uint RunningCrc => _runningCrc; + internal long TotalBytesRead => _totalBytesRead; + public override bool CanRead => !_isDisposed && _baseStream.CanRead; public override bool CanSeek => !_isDisposed && _baseStream.CanSeek; public override bool CanWrite => false; @@ -754,4 +750,246 @@ public override async ValueTask DisposeAsync() await base.DisposeAsync().ConfigureAwait(false); } } + + /// + /// Wraps a non-seekable stream and reports as + /// so that can rewind + /// unconsumed input via Seek(-n, SeekOrigin.Current) after + /// decompression finishes. Maintains a rolling history buffer of recently + /// read bytes to satisfy that backward seek. + /// + internal sealed class ReadAheadStream : Stream + { + private readonly Stream _baseStream; + private readonly byte[] _history; + private int _historyCount; + private byte[]? _pushback; + private int _pushbackOffset; + private int _pushbackCount; + private long _position; + private bool _isDisposed; + + public ReadAheadStream(Stream baseStream, int historyCapacity = 8192) + { + _baseStream = baseStream; + _history = new byte[historyCapacity]; + } + + public override bool CanRead => !_isDisposed && _baseStream.CanRead; + public override bool CanSeek => !_isDisposed; + public override bool CanWrite => false; + + public override long Length + { + get + { + ThrowIfDisposed(); + throw new NotSupportedException(); + } + } + + public override long Position + { + get + { + ThrowIfDisposed(); + return _position; + } + set + { + ThrowIfDisposed(); + throw new NotSupportedException(); + } + } + + public override int Read(byte[] buffer, int offset, int count) + { + ValidateBufferArguments(buffer, offset, count); + return Read(buffer.AsSpan(offset, count)); + } + + public override int Read(Span buffer) + { + ThrowIfDisposed(); + + int totalRead = 0; + + if (_pushbackCount > 0) + { + int fromPushback = Math.Min(buffer.Length, _pushbackCount); + _pushback.AsSpan(_pushbackOffset, fromPushback).CopyTo(buffer); + RecordHistory(buffer.Slice(0, fromPushback)); + _pushbackOffset += fromPushback; + _pushbackCount -= fromPushback; + totalRead += fromPushback; + buffer = buffer.Slice(fromPushback); + + if (_pushbackCount == 0) + { + _pushback = null; + } + } + + if (buffer.Length > 0) + { + int fromBase = _baseStream.Read(buffer); + if (fromBase > 0) + { + RecordHistory(buffer.Slice(0, fromBase)); + totalRead += fromBase; + } + } + + _position += totalRead; + return totalRead; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + ValidateBufferArguments(buffer, offset, count); + return ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ThrowIfDisposed(); + + int totalRead = 0; + + if (_pushbackCount > 0) + { + int fromPushback = Math.Min(buffer.Length, _pushbackCount); + _pushback.AsSpan(_pushbackOffset, fromPushback).CopyTo(buffer.Span); + RecordHistory(buffer.Span.Slice(0, fromPushback)); + _pushbackOffset += fromPushback; + _pushbackCount -= fromPushback; + totalRead += fromPushback; + buffer = buffer.Slice(fromPushback); + + if (_pushbackCount == 0) + { + _pushback = null; + } + } + + if (buffer.Length > 0) + { + int fromBase = await _baseStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + if (fromBase > 0) + { + RecordHistory(buffer.Span.Slice(0, fromBase)); + totalRead += fromBase; + } + } + + _position += totalRead; + return totalRead; + } + + public override long Seek(long offset, SeekOrigin origin) + { + ThrowIfDisposed(); + + if (origin is SeekOrigin.Current && offset < 0) + { + int rewindBytes = checked((int)(-offset)); + + if (rewindBytes > _historyCount) + { + throw new IOException(SR.IO_SeekBeforeBegin); + } + + // Create new pushback by prepending rewound history bytes to any + // existing unread pushback. This preserves bytes that haven't been + // consumed yet (from a previous seek) so they are not lost. + int existingPushback = _pushbackCount; + byte[] newPushback = new byte[rewindBytes + existingPushback]; + Array.Copy(_history, _historyCount - rewindBytes, newPushback, 0, rewindBytes); + + if (existingPushback > 0) + { + Array.Copy(_pushback!, _pushbackOffset, newPushback, rewindBytes, existingPushback); + } + + _pushback = newPushback; + _pushbackOffset = 0; + _pushbackCount = newPushback.Length; + _historyCount -= rewindBytes; + _position -= rewindBytes; + + return _position; + } + + throw new NotSupportedException(); + } + + private void RecordHistory(ReadOnlySpan data) + { + if (data.Length >= _history.Length) + { + data.Slice(data.Length - _history.Length).CopyTo(_history); + _historyCount = _history.Length; + } + else if (_historyCount + data.Length <= _history.Length) + { + data.CopyTo(_history.AsSpan(_historyCount)); + _historyCount += data.Length; + } + else + { + int toKeep = _history.Length - data.Length; + Array.Copy(_history, _historyCount - toKeep, _history, 0, toKeep); + data.CopyTo(_history.AsSpan(toKeep)); + _historyCount = _history.Length; + } + } + + public override void Flush() + { + ThrowIfDisposed(); + } + + public override Task FlushAsync(CancellationToken cancellationToken) + { + ThrowIfDisposed(); + return Task.CompletedTask; + } + + public override void SetLength(long value) + { + ThrowIfDisposed(); + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + ThrowIfDisposed(); + throw new NotSupportedException(); + } + + private void ThrowIfDisposed() + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + } + + protected override void Dispose(bool disposing) + { + if (disposing && !_isDisposed) + { + _baseStream.Dispose(); + _isDisposed = true; + } + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + await _baseStream.DisposeAsync().ConfigureAwait(false); + _isDisposed = true; + } + await base.DisposeAsync().ConfigureAwait(false); + } + } } diff --git a/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipStreamReader.cs b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipStreamReader.cs new file mode 100644 index 00000000000000..78c68404a14d7f --- /dev/null +++ b/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipStreamReader.cs @@ -0,0 +1,769 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers.Binary; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace System.IO.Compression; + +/// +/// Provides a forward-only reader for ZIP archives that reads entries sequentially +/// from a stream without requiring the stream to be seekable. +/// +/// +/// +/// Unlike , which reads the central directory at the end +/// of the archive, walks local file headers in order +/// and decompresses data on the fly. This makes it suitable for network streams, +/// pipes, and other non-seekable sources. +/// +/// +/// This mirrors the TarReader / TarEntry pattern in +/// System.Formats.Tar. +/// +/// +/// Once enumeration completes (that is, once a GetNextEntry call returns +/// ), the reader has consumed into the archive's trailing +/// central directory, so the position of the underlying stream is unspecified. +/// Reading additional data from the stream after enumeration ends is not supported. +/// +/// +public sealed class ZipStreamReader : IDisposable, IAsyncDisposable +{ + private const ushort DataDescriptorBitFlag = 0x8; + private const ushort UnicodeFileNameBitFlag = 0x800; + private const ushort EncryptionBitFlag = 0x1; + private const ushort StrongEncryptionBitFlag = 0x40; + + private bool _isDisposed; + private readonly bool _leaveOpen; + private readonly Encoding? _entryNameEncoding; + private ZipArchiveEntry? _previousEntry; + private readonly Stream _archiveStream; + private bool _reachedEnd; + + /// + /// Initializes a new that reads from the specified stream. + /// + /// The archive stream to read from. + /// + /// to leave the stream open after the reader is disposed; + /// otherwise, . + /// + public ZipStreamReader(Stream stream, bool leaveOpen = false) + : this(stream, entryNameEncoding: null, leaveOpen) + { + } + + /// + /// Initializes a new that reads from the specified stream + /// using the given encoding for entry names. + /// + /// The archive stream to read from. + /// + /// The encoding to use when reading entry names that do not have the UTF-8 bit flag set, + /// or to use UTF-8. + /// + /// + /// to leave the stream open after the reader is disposed; + /// otherwise, . + /// + public ZipStreamReader(Stream stream, Encoding? entryNameEncoding, bool leaveOpen = false) + { + ArgumentNullException.ThrowIfNull(stream); + + if (!stream.CanRead) + { + throw new ArgumentException(SR.NotSupported_UnreadableStream, nameof(stream)); + } + + // ReadAheadStream makes non-seekable streams appear seekable so that + // DeflateStream.TryRewindStream can push back unconsumed input after + // decompression finishes. Already-seekable streams need no wrapper. + _archiveStream = stream.CanSeek ? stream : new ReadAheadStream(stream); + _leaveOpen = leaveOpen; + _entryNameEncoding = entryNameEncoding; + } + + /// + /// Reads the next entry from the ZIP archive stream by parsing the local file header. + /// + /// + /// to copy the entry's decompressed data into a + /// that remains valid after the reader advances; to read directly + /// from the archive stream (invalidated on the next call). + /// + /// + /// The next , or if there are no more entries. + /// When is returned, the underlying stream has been consumed into the + /// trailing central directory and its position is unspecified. + /// + /// The reader has been disposed. + /// The archive stream contains invalid data. + public ZipArchiveEntry? GetNextEntry(bool copyData = false) + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + + if (_reachedEnd) + { + return null; + } + + AdvanceDataStreamIfNeeded(); + + byte[] headerBytes = new byte[ZipLocalFileHeader.SizeOfLocalHeader]; + int bytesRead = _archiveStream.ReadAtLeast(headerBytes, headerBytes.Length, throwOnEndOfStream: false); + + if (bytesRead < ZipLocalFileHeader.SizeOfLocalHeader) + { + _reachedEnd = true; + + // A clean end of the archive is either no more bytes at all, or a short trailing + // end-of-entries record such as the 22-byte end-of-central-directory of an empty + // archive. Any other non-empty partial read means the stream ended in the middle of + // a local file header, i.e. the archive is truncated. + if (bytesRead != 0 && + !(bytesRead >= sizeof(uint) && IsKnownEndOfEntriesSignature(headerBytes))) + { + throw new InvalidDataException(SR.UnexpectedEndOfStream); + } + + return null; + } + + if (!headerBytes.AsSpan().StartsWith(ZipLocalFileHeader.SignatureConstantBytes)) + { + if (IsKnownEndOfEntriesSignature(headerBytes)) + { + _reachedEnd = true; + return null; + } + + throw new InvalidDataException(SR.ZipStreamInvalidLocalFileHeader); + } + + int dynamicLength = GetDynamicHeaderLength(headerBytes); + byte[] dynamicBuffer = new byte[dynamicLength]; + _archiveStream.ReadExactly(dynamicBuffer); + + ParseLocalFileHeader(headerBytes, dynamicBuffer, + out string fullName, out byte[] fullNameBytes, out ushort versionNeeded, out ushort generalPurposeBitFlags, + out ushort compressionMethod, out DateTimeOffset lastModified, out uint crc32, + out long compressedSize, out long uncompressedSize, out bool hasDataDescriptor, + out ZipEncryptionMethod encryptionMethod, out ushort realCompressionMethod, out ushort aeVersion); + + bool isEncrypted = (generalPurposeBitFlags & EncryptionBitFlag) != 0; + + ZipCompressionMethod method = (ZipCompressionMethod)compressionMethod; + ZipCompressionMethod realMethod = (ZipCompressionMethod)realCompressionMethod; + + Stream? dataStream = CreateDataStream( + method, compressedSize, uncompressedSize, + crc32, hasDataDescriptor, isEncrypted, out CrcValidatingReadStream? crcStream); + + Stream? originalDataStream = null; + if (copyData && dataStream is not null) + { + originalDataStream = dataStream; + MemoryStream ms = new(); + dataStream.CopyTo(ms); + ms.Position = 0; + dataStream = ms; + } + + ZipArchiveEntry entry = new( + fullName, fullNameBytes, realMethod, lastModified, crc32, + compressedSize, uncompressedSize, generalPurposeBitFlags, versionNeeded, dataStream, + encryptionMethod, method, aeVersion); + + if (copyData && hasDataDescriptor) + { + if (crcStream is not null) + { + ReadDataDescriptor(entry, crcStream); + } + else if (isEncrypted) + { + SkipDataDescriptor(); + } + } + + // Dispose the original decompression/CRC stream after copying (and after + // reading the data descriptor when applicable) to release inflater resources. + originalDataStream?.Dispose(); + + if (!copyData) + { + _previousEntry = entry; + } + + return entry; + } + + /// + /// Asynchronously reads the next entry from the ZIP archive stream. + /// + /// + /// to copy the entry's decompressed data into a + /// that remains valid after the reader advances; to read directly + /// from the archive stream (invalidated on the next call). + /// + /// A token to monitor for cancellation requests. + /// + /// The next , or if there are no more entries. + /// When is returned, the underlying stream has been consumed into the + /// trailing central directory and its position is unspecified. + /// + public async ValueTask GetNextEntryAsync( + bool copyData = false, CancellationToken cancellationToken = default) + { + ObjectDisposedException.ThrowIf(_isDisposed, this); + + if (_reachedEnd) + { + return null; + } + + await AdvanceDataStreamIfNeededAsync(cancellationToken).ConfigureAwait(false); + + byte[] headerBytes = new byte[ZipLocalFileHeader.SizeOfLocalHeader]; + int bytesRead = await _archiveStream.ReadAtLeastAsync( + headerBytes.AsMemory(0, ZipLocalFileHeader.SizeOfLocalHeader), + ZipLocalFileHeader.SizeOfLocalHeader, + throwOnEndOfStream: false, + cancellationToken).ConfigureAwait(false); + + if (bytesRead < ZipLocalFileHeader.SizeOfLocalHeader) + { + _reachedEnd = true; + + // A clean end of the archive is either no more bytes at all, or a short trailing + // end-of-entries record such as the 22-byte end-of-central-directory of an empty + // archive. Any other non-empty partial read means the stream ended in the middle of + // a local file header, i.e. the archive is truncated. + if (bytesRead != 0 && + !(bytesRead >= sizeof(uint) && IsKnownEndOfEntriesSignature(headerBytes))) + { + throw new InvalidDataException(SR.UnexpectedEndOfStream); + } + + return null; + } + + if (!headerBytes.AsSpan().StartsWith(ZipLocalFileHeader.SignatureConstantBytes)) + { + if (IsKnownEndOfEntriesSignature(headerBytes)) + { + _reachedEnd = true; + return null; + } + + throw new InvalidDataException(SR.ZipStreamInvalidLocalFileHeader); + } + + int dynamicLength = GetDynamicHeaderLength(headerBytes); + byte[] dynamicBuffer = new byte[dynamicLength]; + await _archiveStream.ReadExactlyAsync(dynamicBuffer.AsMemory(0, dynamicLength), cancellationToken).ConfigureAwait(false); + + ParseLocalFileHeader(headerBytes, dynamicBuffer, + out string fullName, out byte[] fullNameBytes, out ushort versionNeeded, out ushort generalPurposeBitFlags, + out ushort compressionMethod, out DateTimeOffset lastModified, out uint crc32, + out long compressedSize, out long uncompressedSize, out bool hasDataDescriptor, + out ZipEncryptionMethod encryptionMethod, out ushort realCompressionMethod, out ushort aeVersion); + + ZipCompressionMethod method = (ZipCompressionMethod)compressionMethod; + ZipCompressionMethod realMethod = (ZipCompressionMethod)realCompressionMethod; + bool isEncrypted = (generalPurposeBitFlags & EncryptionBitFlag) != 0; + + Stream? dataStream = CreateDataStream( + method, compressedSize, uncompressedSize, crc32, + hasDataDescriptor, isEncrypted, out CrcValidatingReadStream? crcStream); + + Stream? originalDataStream = null; + if (copyData && dataStream is not null) + { + originalDataStream = dataStream; + MemoryStream ms = new(); + await dataStream.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); + ms.Position = 0; + dataStream = ms; + } + + ZipArchiveEntry entry = new( + fullName, fullNameBytes, realMethod, lastModified, crc32, + compressedSize, uncompressedSize, generalPurposeBitFlags, versionNeeded, dataStream, + encryptionMethod, method, aeVersion); + + if (copyData && hasDataDescriptor) + { + if (crcStream is not null) + { + await ReadDataDescriptorAsync(entry, crcStream, cancellationToken).ConfigureAwait(false); + } + else if (isEncrypted) + { + await SkipDataDescriptorAsync(cancellationToken).ConfigureAwait(false); + } + } + + // Dispose the original decompression/CRC stream after copying (and after + // reading the data descriptor when applicable) to release inflater resources. + if (originalDataStream is not null) + { + await originalDataStream.DisposeAsync().ConfigureAwait(false); + } + + if (!copyData) + { + _previousEntry = entry; + } + + return entry; + } + + private Stream? CreateDataStream( + ZipCompressionMethod compressionMethod, + long compressedSize, + long uncompressedSize, + uint crc32, + bool hasDataDescriptor, + bool isEncrypted, + out CrcValidatingReadStream? crcStream) + { + crcStream = null; + + if (!hasDataDescriptor && compressedSize == 0) + { + return null; + } + + // Encrypted entries cannot be decompressed without decryption, which requires a password that + // is not available until the entry is opened. As long as the compressed size is known (present + // in the local header), the raw bytes can still be bounded so the reader can drain past them and + // decrypt them later. Only when the compressed size is unknown (a streamed data-descriptor entry + // whose local header stores a zero size) can the entry boundary not be determined. + if (isEncrypted) + { + if (compressedSize == 0) + { + throw new NotSupportedException(SR.ZipStreamEncryptedDataDescriptorNotSupported); + } + + return CreateBoundedStream(compressedSize); + } + + Stream source = hasDataDescriptor + ? _archiveStream + : CreateBoundedStream(compressedSize); + + Stream decompressed = CreateDecompressionStream(source, compressionMethod, uncompressedSize, leaveOpen: hasDataDescriptor); + + crcStream = hasDataDescriptor + // Data-descriptor entries: CRC and length are unknown until after the data is read. + // Use sentinel values to disable validation while still tracking RunningCrc and TotalBytesRead + // for later verification against the data descriptor. + ? new CrcValidatingReadStream(decompressed, expectedCrc: 0, expectedLength: long.MaxValue) + : new CrcValidatingReadStream(decompressed, crc32, uncompressedSize); + + return crcStream; + } + + // Bounds forward reads to exactly compressedSize bytes so the decompressor (or the + // encrypted-drain path) cannot read past the entry into the next local file header. + // SubReadStream supports non-seekable super streams; anchoring the window at the + // current position keeps its internal position in lockstep with _archiveStream so it + // never issues a SeekOrigin.Begin (which ReadAheadStream does not support) on a + // non-seekable source. + private SubReadStream CreateBoundedStream(long compressedSize) + => new SubReadStream(_archiveStream, _archiveStream.Position, compressedSize); + + /// + /// Creates the appropriate decompression stream for the given compression method. + /// + private static Stream CreateDecompressionStream( + Stream source, ZipCompressionMethod compressionMethod, long uncompressedSize, bool leaveOpen) + { + return compressionMethod switch + { + ZipCompressionMethod.Deflate when leaveOpen => + new DeflateStream(source, CompressionMode.Decompress, leaveOpen: true), + ZipCompressionMethod.Deflate => + new DeflateStream(source, CompressionMode.Decompress, uncompressedSize), + ZipCompressionMethod.Deflate64 => + new DeflateManagedStream(source, ZipCompressionMethod.Deflate64, leaveOpen ? -1 : uncompressedSize), + ZipCompressionMethod.Stored when leaveOpen => + throw new NotSupportedException(SR.ZipStreamStoredDataDescriptorNotSupported), + ZipCompressionMethod.Stored => source, + _ => throw new NotSupportedException(SR.UnsupportedCompression) + }; + } + + private void AdvanceDataStreamIfNeeded() + { + if (_previousEntry is null) + { + return; + } + + ZipArchiveEntry entry = _previousEntry; + _previousEntry = null; + + DrainStream(entry.ForwardDataStream); + + if (entry.HasDataDescriptor) + { + if (entry.ForwardDataStream is CrcValidatingReadStream crcStream) + { + ReadDataDescriptor(entry, crcStream); + } + else + { + // Encrypted entries expose a raw bounded stream with no CRC tracking, so the trailing + // data descriptor is skipped without validation to reach the next local file header. + SkipDataDescriptor(); + } + } + + // The forward-only stream is invalidated once the reader advances, so dispose it here to + // release the inflater and other native resources instead of waiting for finalization. + entry.ForwardDataStream?.Dispose(); + } + + private async ValueTask AdvanceDataStreamIfNeededAsync(CancellationToken cancellationToken) + { + if (_previousEntry is null) + { + return; + } + + ZipArchiveEntry entry = _previousEntry; + _previousEntry = null; + + await DrainStreamAsync(entry.ForwardDataStream, cancellationToken).ConfigureAwait(false); + + if (entry.HasDataDescriptor) + { + if (entry.ForwardDataStream is CrcValidatingReadStream crcStream) + { + await ReadDataDescriptorAsync(entry, crcStream, cancellationToken).ConfigureAwait(false); + } + else + { + // Encrypted entries expose a raw bounded stream with no CRC tracking, so the trailing + // data descriptor is skipped without validation to reach the next local file header. + await SkipDataDescriptorAsync(cancellationToken).ConfigureAwait(false); + } + } + + // The forward-only stream is invalidated once the reader advances, so dispose it here to + // release the inflater and other native resources instead of waiting for finalization. + if (entry.ForwardDataStream is not null) + { + await entry.ForwardDataStream.DisposeAsync().ConfigureAwait(false); + } + } + + private static void DrainStream(Stream? stream) + { + if (stream is not null) + { + stream.CopyTo(Stream.Null); + } + } + + private static async ValueTask DrainStreamAsync(Stream? stream, CancellationToken cancellationToken) + { + if (stream is not null) + { + await stream.CopyToAsync(Stream.Null, cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Returns the combined length of the filename and extra field from the fixed local file header, + /// so the caller can read exactly that many bytes via sync or async I/O. + /// + private static int GetDynamicHeaderLength(ReadOnlySpan headerBytes) + { + ushort filenameLength = BinaryPrimitives.ReadUInt16LittleEndian(headerBytes[ZipLocalFileHeader.FieldLocations.FilenameLength..]); + ushort extraFieldLength = BinaryPrimitives.ReadUInt16LittleEndian(headerBytes[ZipLocalFileHeader.FieldLocations.ExtraFieldLength..]); + return filenameLength + extraFieldLength; + } + + /// + /// Parses all local file header fields from the fixed header bytes and the already-read + /// dynamic buffer (filename + extra field). This method performs no I/O. + /// + private void ParseLocalFileHeader( + ReadOnlySpan headerBytes, + ReadOnlySpan dynamicBuffer, + out string fullName, + out byte[] fullNameBytes, + out ushort versionNeeded, + out ushort generalPurposeBitFlags, + out ushort compressionMethod, + out DateTimeOffset lastModified, + out uint crc32, + out long compressedSize, + out long uncompressedSize, + out bool hasDataDescriptor, + out ZipEncryptionMethod encryptionMethod, + out ushort realCompressionMethod, + out ushort aeVersion) + { + versionNeeded = BinaryPrimitives.ReadUInt16LittleEndian(headerBytes[ZipLocalFileHeader.FieldLocations.VersionNeededToExtract..]); + generalPurposeBitFlags = BinaryPrimitives.ReadUInt16LittleEndian(headerBytes[ZipLocalFileHeader.FieldLocations.GeneralPurposeBitFlags..]); + compressionMethod = BinaryPrimitives.ReadUInt16LittleEndian(headerBytes[ZipLocalFileHeader.FieldLocations.CompressionMethod..]); + uint lastModifiedRaw = BinaryPrimitives.ReadUInt32LittleEndian(headerBytes[ZipLocalFileHeader.FieldLocations.LastModified..]); + crc32 = BinaryPrimitives.ReadUInt32LittleEndian(headerBytes[ZipLocalFileHeader.FieldLocations.Crc32..]); + uint compressedSizeSmall = BinaryPrimitives.ReadUInt32LittleEndian(headerBytes[ZipLocalFileHeader.FieldLocations.CompressedSize..]); + uint uncompressedSizeSmall = BinaryPrimitives.ReadUInt32LittleEndian(headerBytes[ZipLocalFileHeader.FieldLocations.UncompressedSize..]); + ushort filenameLength = BinaryPrimitives.ReadUInt16LittleEndian(headerBytes[ZipLocalFileHeader.FieldLocations.FilenameLength..]); + ushort extraFieldLength = BinaryPrimitives.ReadUInt16LittleEndian(headerBytes[ZipLocalFileHeader.FieldLocations.ExtraFieldLength..]); + + lastModified = new DateTimeOffset(ZipHelper.DosTimeToDateTime(lastModifiedRaw)); + hasDataDescriptor = (generalPurposeBitFlags & DataDescriptorBitFlag) != 0; + + Encoding encoding = (generalPurposeBitFlags & UnicodeFileNameBitFlag) != 0 + ? Encoding.UTF8 + : _entryNameEncoding ?? Encoding.UTF8; + + fullNameBytes = dynamicBuffer[..filenameLength].ToArray(); + fullName = encoding.GetString(dynamicBuffer[..filenameLength]); + + ReadOnlySpan extraField = dynamicBuffer.Slice(filenameLength, extraFieldLength); + + ResolveEncryption(generalPurposeBitFlags, compressionMethod, extraField, + out encryptionMethod, out realCompressionMethod, out aeVersion); + + bool compressedSizeInZip64 = compressedSizeSmall == ZipHelper.Mask32Bit; + bool uncompressedSizeInZip64 = uncompressedSizeSmall == ZipHelper.Mask32Bit; + + if (compressedSizeInZip64 || uncompressedSizeInZip64) + { + Zip64ExtraField zip64 = Zip64ExtraField.GetJustZip64Block( + extraField, + readUncompressedSize: uncompressedSizeInZip64, + readCompressedSize: compressedSizeInZip64, + readLocalHeaderOffset: false, + readStartDiskNumber: false, + isInLocalHeader: true); + + compressedSize = zip64.CompressedSize ?? compressedSizeSmall; + uncompressedSize = zip64.UncompressedSize ?? uncompressedSizeSmall; + } + else + { + compressedSize = compressedSizeSmall; + uncompressedSize = uncompressedSizeSmall; + } + } + + // Determines the entry's encryption method from the general-purpose bit flag and, for WinZip AES + // entries, the local extra field (0x9901). The AES extra field also carries the *real* compression + // method (the local header stores 99 for AES) and the AE version, both required to decrypt and + // decompress the entry through the standard ZipArchiveEntry.Open(password) pipeline. + private static void ResolveEncryption( + ushort generalPurposeBitFlags, + ushort compressionMethod, + ReadOnlySpan extraField, + out ZipEncryptionMethod encryptionMethod, + out ushort realCompressionMethod, + out ushort aeVersion) + { + realCompressionMethod = compressionMethod; + aeVersion = 0; + + if ((generalPurposeBitFlags & EncryptionBitFlag) == 0) + { + encryptionMethod = ZipEncryptionMethod.None; + return; + } + + if (compressionMethod == ZipArchiveEntry.WinZipAesMethod && + WinZipAesExtraField.TryGetFromRawExtraFieldData(extraField, out WinZipAesExtraField aesField)) + { + realCompressionMethod = aesField.CompressionMethod; + aeVersion = aesField.VendorVersion; + encryptionMethod = aesField.AesStrength switch + { + 1 => ZipEncryptionMethod.Aes128, + 2 => ZipEncryptionMethod.Aes192, + 3 => ZipEncryptionMethod.Aes256, + _ => throw new InvalidDataException(SR.InvalidAesStrength) + }; + return; + } + + // Encrypted but not AES: the strong-encryption flag marks an unsupported scheme; otherwise ZipCrypto. + encryptionMethod = (generalPurposeBitFlags & StrongEncryptionBitFlag) != 0 + ? ZipEncryptionMethod.Unknown + : ZipEncryptionMethod.ZipCrypto; + } + + private void ReadDataDescriptor(ZipArchiveEntry entry, CrcValidatingReadStream crcStream) + { + (byte[] buffer, int offset, bool isZip64) = ReadDataDescriptorCore(); + ParseDataDescriptor(buffer, offset, isZip64, entry, crcStream); + } + + // Advances the archive stream past a trailing data descriptor without parsing or validating it. + // Used for encrypted entries whose data was drained raw (no CRC stream to validate against). + private void SkipDataDescriptor() => ReadDataDescriptorCore(); + + // Reads a trailing data descriptor into a buffer and leaves the archive stream positioned at the + // start of the next record. Returns the buffer along with the payload offset (past an optional + // signature) and whether the descriptor uses 64-bit sizes. + private (byte[] buffer, int offset, bool isZip64) ReadDataDescriptorCore() + { + byte[] buffer = new byte[28]; // Max: sig(4) + crc(4) + sizes64(16) + peek(4) + + _archiveStream.ReadExactly(buffer.AsSpan(0, 4)); + int offset = 0; + int totalRead = 4; + + if (buffer.AsSpan(0, 4).SequenceEqual(ZipLocalFileHeader.DataDescriptorSignatureConstantBytes)) + { + offset = 4; + _archiveStream.ReadExactly(buffer.AsSpan(4, 4)); + totalRead = 8; + } + + // Read 20 bytes: up to 16 for sizes (64-bit) + 4 to peek at the next signature. + _archiveStream.ReadExactly(buffer.AsSpan(totalRead, 20)); + + // Probe: if 4 bytes after 32-bit sizes form a known ZIP signature, + // the descriptor uses 32-bit sizes; otherwise assume 64-bit. + bool isZip64 = !IsKnownZipSignature(buffer.AsSpan(totalRead + 8, 4)); + int sizesBytes = isZip64 ? 16 : 8; + + // Seek back over the bytes we read past the actual sizes. + int overRead = 20 - sizesBytes; + _archiveStream.Seek(-overRead, SeekOrigin.Current); + + return (buffer, offset, isZip64); + } + + private async ValueTask ReadDataDescriptorAsync( + ZipArchiveEntry entry, CrcValidatingReadStream crcStream, CancellationToken cancellationToken) + { + (byte[] buffer, int offset, bool isZip64) = await ReadDataDescriptorCoreAsync(cancellationToken).ConfigureAwait(false); + ParseDataDescriptor(buffer, offset, isZip64, entry, crcStream); + } + + private async ValueTask SkipDataDescriptorAsync(CancellationToken cancellationToken) => + await ReadDataDescriptorCoreAsync(cancellationToken).ConfigureAwait(false); + + private async ValueTask<(byte[] buffer, int offset, bool isZip64)> ReadDataDescriptorCoreAsync(CancellationToken cancellationToken) + { + byte[] buffer = new byte[28]; // Max: sig(4) + crc(4) + sizes64(16) + peek(4) + + await _archiveStream.ReadExactlyAsync(buffer.AsMemory(0, 4), cancellationToken).ConfigureAwait(false); + int offset = 0; + int totalRead = 4; + + if (buffer.AsSpan(0, 4).SequenceEqual(ZipLocalFileHeader.DataDescriptorSignatureConstantBytes)) + { + offset = 4; + await _archiveStream.ReadExactlyAsync(buffer.AsMemory(4, 4), cancellationToken).ConfigureAwait(false); + totalRead = 8; + } + + // Read 20 bytes: up to 16 for sizes (64-bit) + 4 to peek at the next signature. + await _archiveStream.ReadExactlyAsync(buffer.AsMemory(totalRead, 20), cancellationToken).ConfigureAwait(false); + + // Probe: if 4 bytes after 32-bit sizes form a known ZIP signature, + // the descriptor uses 32-bit sizes; otherwise assume 64-bit. + bool isZip64 = !IsKnownZipSignature(buffer.AsSpan(totalRead + 8, 4)); + int sizesBytes = isZip64 ? 16 : 8; + + // Seek back over the bytes we read past the actual sizes. + int overRead = 20 - sizesBytes; + _archiveStream.Seek(-overRead, SeekOrigin.Current); + + return (buffer, offset, isZip64); + } + + /// + /// Parses the data descriptor fields from an already-read buffer and updates + /// the entry with the CRC-32, compressed size, and uncompressed size. No I/O. + /// + private static void ParseDataDescriptor( + ReadOnlySpan buffer, int offset, bool isZip64, + ZipArchiveEntry entry, CrcValidatingReadStream crcStream) + { + uint crc32 = BinaryPrimitives.ReadUInt32LittleEndian(buffer[offset..]); + int sizesOffset = offset + 4; + + if (isZip64) + { + entry.UpdateDataDescriptor( + crc32, + compressedLength: BinaryPrimitives.ReadInt64LittleEndian(buffer[sizesOffset..]), + length: BinaryPrimitives.ReadInt64LittleEndian(buffer[(sizesOffset + 8)..]), + crcStream.RunningCrc, crcStream.TotalBytesRead); + } + else + { + entry.UpdateDataDescriptor( + crc32, + compressedLength: BinaryPrimitives.ReadUInt32LittleEndian(buffer[sizesOffset..]), + length: BinaryPrimitives.ReadUInt32LittleEndian(buffer[(sizesOffset + 4)..]), + crcStream.RunningCrc, crcStream.TotalBytesRead); + } + } + + /// + /// Returns when the first four bytes of + /// match a known end-of-entries signature + /// (central directory header or end-of-central-directory). + /// + private static bool IsKnownEndOfEntriesSignature(ReadOnlySpan headerBytes) + { + ReadOnlySpan sig = headerBytes[..4]; + return sig.SequenceEqual(ZipCentralDirectoryFileHeader.SignatureConstantBytes) + || sig.SequenceEqual(ZipEndOfCentralDirectoryBlock.SignatureConstantBytes) + || sig.SequenceEqual(Zip64EndOfCentralDirectoryRecord.SignatureConstantBytes); + } + + /// + /// Returns when starts with any + /// recognized ZIP structure signature (local header, central directory, EOCD, or ZIP64 EOCD). + /// Used to probe the data descriptor format by peeking at the bytes that follow. + /// + private static bool IsKnownZipSignature(ReadOnlySpan bytes) + { + return bytes.StartsWith(ZipLocalFileHeader.SignatureConstantBytes) + || bytes.StartsWith(ZipCentralDirectoryFileHeader.SignatureConstantBytes) + || bytes.StartsWith(ZipEndOfCentralDirectoryBlock.SignatureConstantBytes) + || bytes.StartsWith(Zip64EndOfCentralDirectoryRecord.SignatureConstantBytes); + } + + public void Dispose() + { + if (!_isDisposed) + { + _isDisposed = true; + + if (!_leaveOpen) + { + _archiveStream.Dispose(); + } + } + } + + public async ValueTask DisposeAsync() + { + if (!_isDisposed) + { + _isDisposed = true; + + if (!_leaveOpen) + { + await _archiveStream.DisposeAsync().ConfigureAwait(false); + } + } + } +} diff --git a/src/libraries/System.IO.Compression/tests/System.IO.Compression.Tests.csproj b/src/libraries/System.IO.Compression/tests/System.IO.Compression.Tests.csproj index e425efa75a8334..e50cc647adc758 100644 --- a/src/libraries/System.IO.Compression/tests/System.IO.Compression.Tests.csproj +++ b/src/libraries/System.IO.Compression/tests/System.IO.Compression.Tests.csproj @@ -30,6 +30,7 @@ + diff --git a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs index b609f981d32847..cb13827f4c1f00 100644 --- a/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs +++ b/src/libraries/System.IO.Compression/tests/WinZipAesStreamConformanceTests.cs @@ -78,7 +78,7 @@ static WinZipAesStreamConformanceTests() s_createMethod = winZipAesStreamType.GetMethod("Create", BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Static, null, - new[] { typeof(Stream), winZipAesKeyMaterialType, typeof(long), typeof(bool), typeof(bool) }, + new[] { typeof(Stream), winZipAesKeyMaterialType, typeof(long), typeof(bool), typeof(bool), typeof(bool) }, null)!; } @@ -94,7 +94,7 @@ static WinZipAesStreamConformanceTests() var encryptStream = (Stream)s_createMethod.Invoke(null, new object[] { - ms, keyMaterial, -1L, true, false + ms, keyMaterial, -1L, true, false, false })!; if (initialData != null && initialData.Length > 0) @@ -116,7 +116,7 @@ static WinZipAesStreamConformanceTests() using var encryptedMs = new MemoryStream(); using (var encryptStream = (Stream)s_createMethod.Invoke(null, new object[] { - encryptedMs, encryptKeyMaterial, -1L, true, true + encryptedMs, encryptKeyMaterial, -1L, true, true, false })!) { encryptStream.Write(plaintext); @@ -133,7 +133,7 @@ static WinZipAesStreamConformanceTests() var ms = new MemoryStream(encryptedData); var decryptStream = (Stream)s_createMethod.Invoke(null, new object[] { - ms, decryptKeyMaterial, (long)encryptedData.Length, false, false + ms, decryptKeyMaterial, (long)encryptedData.Length, false, false, false })!; return Task.FromResult(decryptStream); diff --git a/src/libraries/System.IO.Compression/tests/ZipArchive/zip_StreamEntryReadTests.cs b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_StreamEntryReadTests.cs new file mode 100644 index 00000000000000..e3467838825d98 --- /dev/null +++ b/src/libraries/System.IO.Compression/tests/ZipArchive/zip_StreamEntryReadTests.cs @@ -0,0 +1,894 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace System.IO.Compression.Tests +{ + public partial class zip_StreamEntryReadTests : ZipFileTestBase + { + private static readonly byte[] s_smallContent = "Hello, small world!"u8.ToArray(); + private static readonly byte[] s_mediumContent = new byte[8192]; + private static readonly byte[] s_largeContent = new byte[65536]; + + static zip_StreamEntryReadTests() + { + Random rng = new(42); + rng.NextBytes(s_mediumContent); + rng.NextBytes(s_largeContent); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Read_DeflateWithKnownSize_ReturnsDecompressedData(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + byte[][] expectedContents = [s_smallContent, s_mediumContent, s_largeContent]; + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + for (int i = 0; i < expectedContents.Length; i++) + { + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + + Assert.NotNull(entry); + Assert.False(IsDirectory(entry)); + Assert.Equal(ZipCompressionMethod.Deflate, entry.CompressionMethod); + + Stream dataStream = entry.Open(); + byte[] decompressed = await ReadStreamFully(dataStream, async); + Assert.Equal(expectedContents[i], decompressed); + } + + ZipArchiveEntry? end = await GetNextEntry(reader, async); + Assert.Null(end); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Read_StoredWithKnownSize_ReturnsUncompressedData(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.NoCompression, seekable: true); + byte[][] expectedContents = [s_smallContent, s_mediumContent, s_largeContent]; + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + for (int i = 0; i < expectedContents.Length; i++) + { + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + + Assert.NotNull(entry); + Assert.Equal(ZipCompressionMethod.Stored, entry.CompressionMethod); + + Stream dataStream = entry.Open(); + byte[] decompressed = await ReadStreamFully(dataStream, async); + Assert.Equal(expectedContents[i], decompressed); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Read_DeflateWithDataDescriptor_ReturnsDecompressedData(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: false); + byte[][] expectedContents = [s_smallContent, s_mediumContent, s_largeContent]; + + using MemoryStream archiveStream = new(zipBytes); + using WrappedStream nonSeekableStream = new(archiveStream, canRead: true, canWrite: false, canSeek: false); + using ZipStreamReader reader = new(nonSeekableStream); + + for (int i = 0; i < expectedContents.Length; i++) + { + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + + Assert.NotNull(entry); + Assert.Equal(ZipCompressionMethod.Deflate, entry.CompressionMethod); + + Stream dataStream = entry.Open(); + byte[] decompressed = await ReadStreamFully(dataStream, async); + Assert.Equal(expectedContents[i], decompressed); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Read_StoredWithDataDescriptor_ThrowsNotSupported(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.NoCompression, seekable: false); + + using MemoryStream archiveStream = new(zipBytes); + using WrappedStream nonSeekableStream = new(archiveStream, canRead: true, canWrite: false, canSeek: false); + using ZipStreamReader reader = new(nonSeekableStream); + + if (async) + { + await Assert.ThrowsAsync(() => reader.GetNextEntryAsync().AsTask()); + } + else + { + Assert.Throws(() => reader.GetNextEntry()); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Read_TruncatedLocalFileHeader_ThrowsInvalidData(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + // Cut the stream in the middle of the first local file header. The reader sees a + // non-empty partial read that is not an end-of-entries record, so it must treat the + // archive as truncated rather than silently reporting end-of-archive. + byte[] truncated = zipBytes[..15]; + + using MemoryStream archiveStream = new(truncated); + using ZipStreamReader reader = new(archiveStream); + + if (async) + { + await Assert.ThrowsAsync(() => reader.GetNextEntryAsync().AsTask()); + } + else + { + Assert.Throws(() => reader.GetNextEntry()); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task CopyData_PreservesEntryAfterAdvancing(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + using MemoryStream archiveStream = new(zipBytes); + using WrappedStream nonSeekableStream = new(archiveStream, canRead: true, canWrite: false, canSeek: false); + using ZipStreamReader reader = new(nonSeekableStream); + + ZipArchiveEntry? entry = await GetNextEntry(reader, async, copyData: true); + + Assert.NotNull(entry); + Stream dataStream = entry.Open(); + + ZipArchiveEntry? next = await GetNextEntry(reader, async); + Assert.NotNull(next); + + dataStream.Position = 0; + byte[] decompressed = await ReadStreamFully(dataStream, async); + Assert.Equal(s_smallContent, decompressed); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task PartialRead_ThenGetNextEntry_AdvancesCorrectly(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + ZipArchiveEntry? first = await GetNextEntry(reader, async); + Assert.NotNull(first); + + byte[] partial = new byte[5]; + await ReadStream(first.Open(), partial, async); + + ZipArchiveEntry? second = await GetNextEntry(reader, async); + + Assert.NotNull(second); + Assert.Equal("medium.bin", second.FullName); + + byte[] decompressed = await ReadStreamFully(second.Open(), async); + Assert.Equal(s_mediumContent, decompressed); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task PartialRead_DataDescriptor_ThenGetNextEntry_AdvancesCorrectly(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: false); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + ZipArchiveEntry? first = await GetNextEntry(reader, async); + Assert.NotNull(first); + + byte[] partial = new byte[3]; + await ReadStream(first.Open(), partial, async); + + ZipArchiveEntry? second = await GetNextEntry(reader, async); + + Assert.NotNull(second); + Assert.Equal("medium.bin", second.FullName); + + byte[] decompressed = await ReadStreamFully(second.Open(), async); + Assert.Equal(s_mediumContent, decompressed); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Deflate64Entry_ReturnsDecompressedData(bool async) + { + MemoryStream ms = await StreamHelpers.CreateTempCopyStream(compat("deflate64.zip")); + + using ZipStreamReader reader = new(ms); + + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + + Assert.NotNull(entry); + Assert.Equal(ZipCompressionMethod.Deflate64, entry.CompressionMethod); + + byte[] data = await ReadStreamFully(entry.Open(), async); + Assert.True(data.Length > 0); + } + + [Theory] + [InlineData("empty.txt", false, true)] + [InlineData("empty.txt", false, false)] + [InlineData("mydir/", true, true)] + [InlineData("mydir/", true, false)] + public async Task ZeroLengthEntry_OpenReturnsEmptyStream(string entryName, bool expectedIsDirectory, bool async) + { + using MemoryStream ms = new(); + using (ZipArchive archive = new(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + archive.CreateEntry(entryName); + } + + ms.Position = 0; + using ZipStreamReader reader = new(ms); + + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + + Assert.NotNull(entry); + Assert.Equal(entryName, entry.FullName); + Assert.Equal(expectedIsDirectory, IsDirectory(entry)); + Assert.Equal(0, entry.CompressedLength); + + byte[] data = await ReadStreamFully(entry.Open(), async); + Assert.Empty(data); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task EncryptedEntry_ReportsIsEncrypted(bool async) + { + MemoryStream ms = await StreamHelpers.CreateTempCopyStream(zfile("encrypted_entries_weak.zip")); + + using ZipStreamReader reader = new(ms); + + bool foundEncrypted = false; + bool foundUnencrypted = false; + + ZipArchiveEntry? entry; + while ((entry = await GetNextEntry(reader, async)) is not null) + { + if (entry.IsEncrypted) + foundEncrypted = true; + else + foundUnencrypted = true; + } + + Assert.True(foundEncrypted); + Assert.True(foundUnencrypted); + } + + [Theory] + [SkipOnPlatform(TestPlatforms.Browser, "ZIP encryption is not supported on browser.")] + [InlineData(ZipEncryptionMethod.ZipCrypto, true)] + [InlineData(ZipEncryptionMethod.ZipCrypto, false)] + [InlineData(ZipEncryptionMethod.Aes128, true)] + [InlineData(ZipEncryptionMethod.Aes128, false)] + [InlineData(ZipEncryptionMethod.Aes192, true)] + [InlineData(ZipEncryptionMethod.Aes192, false)] + [InlineData(ZipEncryptionMethod.Aes256, true)] + [InlineData(ZipEncryptionMethod.Aes256, false)] + public async Task EncryptedEntry_ReportsMetadata(ZipEncryptionMethod method, bool async) + { + // A seekable write stores the real compressed size in the local header for every encryption + // method (ZipCrypto keeps a trailing data descriptor, AES does not), so the entry can be read + // forward-only either way. + byte[] zipBytes = CreateZipWithEncryptedEntry(method, seekable: true, s_smallContent); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + + Assert.NotNull(entry); + Assert.Equal("secret.bin", entry.FullName); + Assert.True(entry.IsEncrypted); + Assert.Equal(method, entry.EncryptionMethod); + Assert.False(IsDirectory(entry)); + Assert.True(entry.CompressedLength > 0); + + ZipArchiveEntry? end = await GetNextEntry(reader, async); + Assert.Null(end); + } + + [Theory] + [SkipOnPlatform(TestPlatforms.Browser, "ZIP encryption is not supported on browser.")] + [InlineData(ZipEncryptionMethod.ZipCrypto, true)] + [InlineData(ZipEncryptionMethod.ZipCrypto, false)] + [InlineData(ZipEncryptionMethod.Aes256, true)] + [InlineData(ZipEncryptionMethod.Aes256, false)] + public async Task EncryptedEntry_CanAdvancePastToNextEntry(ZipEncryptionMethod method, bool async) + { + // Encryption written to a seekable stream carries the compressed size in the local header, so + // the reader can drain past the encrypted entry (and skip a ZipCrypto data descriptor) without + // a password to reach the following plain entry. + byte[] zipBytes = CreateZipWithEncryptedThenPlainEntry(method); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + ZipArchiveEntry? encrypted = await GetNextEntry(reader, async); + Assert.NotNull(encrypted); + Assert.True(encrypted.IsEncrypted); + + ZipArchiveEntry? plain = await GetNextEntry(reader, async); + Assert.NotNull(plain); + Assert.False(plain.IsEncrypted); + Assert.Equal("plain.bin", plain.FullName); + + byte[] data = await ReadStreamFully(plain.Open(), async); + Assert.Equal(s_mediumContent, data); + } + + [Theory] + [SkipOnPlatform(TestPlatforms.Browser, "ZIP encryption is not supported on browser.")] + [InlineData(ZipEncryptionMethod.ZipCrypto, true)] + [InlineData(ZipEncryptionMethod.ZipCrypto, false)] + [InlineData(ZipEncryptionMethod.Aes128, true)] + [InlineData(ZipEncryptionMethod.Aes128, false)] + [InlineData(ZipEncryptionMethod.Aes192, true)] + [InlineData(ZipEncryptionMethod.Aes192, false)] + [InlineData(ZipEncryptionMethod.Aes256, true)] + [InlineData(ZipEncryptionMethod.Aes256, false)] + public async Task EncryptedEntry_OpenWithPassword_ReturnsDecryptedData(ZipEncryptionMethod method, bool async) + { + byte[] zipBytes = CreateZipWithEncryptedEntry(method, seekable: true, s_mediumContent); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + using Stream dataStream = await OpenEntry(entry, EncryptionPassword, async); + byte[] decrypted = await ReadStreamFully(dataStream, async); + Assert.Equal(s_mediumContent, decrypted); + } + + [Theory] + [SkipOnPlatform(TestPlatforms.Browser, "ZIP encryption is not supported on browser.")] + [MemberData(nameof(Get_Booleans_Data))] + public async Task EncryptedEntry_OpenWithoutPassword_Throws(bool async) + { + byte[] zipBytes = CreateZipWithEncryptedEntry(ZipEncryptionMethod.Aes256, seekable: true, s_smallContent); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + if (async) + { + await Assert.ThrowsAsync(() => entry.OpenAsync()); + } + else + { + Assert.Throws(() => entry.Open()); + } + } + + [Theory] + [SkipOnPlatform(TestPlatforms.Browser, "ZIP encryption is not supported on browser.")] + [MemberData(nameof(Get_Booleans_Data))] + public async Task EncryptedEntry_OpenWithWrongPassword_Throws(bool async) + { + byte[] zipBytes = CreateZipWithEncryptedEntry(ZipEncryptionMethod.Aes256, seekable: true, s_smallContent); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + // A wrong password is rejected by the AES password verifier when the stream is opened. + await Assert.ThrowsAsync(async () => + { + using Stream dataStream = await OpenEntry(entry, "wrong-password", async); + await ReadStreamFully(dataStream, async); + }); + } + + [Theory] + [SkipOnPlatform(TestPlatforms.Browser, "ZIP encryption is not supported on browser.")] + [MemberData(nameof(Get_Booleans_Data))] + public async Task EncryptedEntry_StreamedUnknownSize_ThrowsNotSupported(bool async) + { + // A ZipCrypto entry written to a non-seekable stream stores a zero compressed size in its + // local header (the real size lives in the trailing data descriptor), so the forward reader + // cannot determine the entry boundary and must throw rather than read past the entry. + byte[] zipBytes = CreateZipWithEncryptedEntry(ZipEncryptionMethod.ZipCrypto, seekable: false, s_smallContent); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + if (async) + { + await Assert.ThrowsAsync(() => reader.GetNextEntryAsync().AsTask()); + } + else + { + Assert.Throws(() => reader.GetNextEntry()); + } + } + + [Theory] + [SkipOnPlatform(TestPlatforms.Browser, "ZIP encryption is not supported on browser.")] + [MemberData(nameof(Get_Booleans_Data))] + public async Task EncryptedEntry_ExternalArchive_DecryptsForwardOnly(bool async) + { + // A real archive (produced by another tool) storing both a ZipCrypto entry (hello.txt) and an + // AES-256 entry (goodbye.txt) with the compressed size in each local header (no data descriptor), + // so both can be decrypted while reading forward-only. + const string password = "S3cur3P@ssw0rd"; + using Stream fileStream = await StreamHelpers.CreateTempCopyStream(passwordProtected("PasswordProtected_MixedEncryptions.zip")); + byte[] archiveBytes = ((MemoryStream)fileStream).ToArray(); + + using MemoryStream archiveStream = new(archiveBytes); + using ZipStreamReader reader = new(archiveStream); + + var decryptedContents = new Dictionary(); + var methods = new Dictionary(); + + ZipArchiveEntry? entry; + while ((entry = await GetNextEntry(reader, async)) is not null) + { + Assert.True(entry.IsEncrypted); + + methods[entry.FullName] = entry.EncryptionMethod; + + using Stream dataStream = await OpenEntry(entry, password, async); + byte[] data = await ReadStreamFully(dataStream, async); + decryptedContents[entry.FullName] = Encoding.UTF8.GetString(data).TrimEnd(); + } + + Assert.Equal(ZipEncryptionMethod.ZipCrypto, methods["hello.txt"]); + Assert.Equal(ZipEncryptionMethod.Aes256, methods["goodbye.txt"]); + Assert.Equal("Hello", decryptedContents["hello.txt"]); + Assert.Equal("Goodbye", decryptedContents["goodbye.txt"]); + } + + [Theory] + [SkipOnPlatform(TestPlatforms.Browser, "ZIP encryption is not supported on browser.")] + [InlineData(ZipEncryptionMethod.ZipCrypto, true)] + [InlineData(ZipEncryptionMethod.ZipCrypto, false)] + [InlineData(ZipEncryptionMethod.Aes256, true)] + [InlineData(ZipEncryptionMethod.Aes256, false)] + public async Task EncryptedEntry_CopyData_SkipsDataDescriptor_AndAdvances(ZipEncryptionMethod method, bool async) + { + // With copyData, the reader buffers the (still-encrypted) bytes and must consume any trailing + // data descriptor (ZipCrypto always has one) so the following entry is found. The buffered copy + // remains decryptable after the reader advances. + byte[] zipBytes = CreateZipWithEncryptedThenPlainEntry(method); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + ZipArchiveEntry? encrypted = await GetNextEntry(reader, async, copyData: true); + Assert.NotNull(encrypted); + Assert.True(encrypted.IsEncrypted); + + // If the trailing data descriptor were not skipped, the reader would be misaligned here. + ZipArchiveEntry? plain = await GetNextEntry(reader, async); + Assert.NotNull(plain); + Assert.Equal("plain.bin", plain.FullName); + Assert.Equal(s_mediumContent, await ReadStreamFully(plain.Open(), async)); + + // The buffered encrypted entry is still decryptable after advancing. + using Stream decrypted = await OpenEntry(encrypted, EncryptionPassword, async); + Assert.Equal(s_smallContent, await ReadStreamFully(decrypted, async)); + } + + [Theory] + [SkipOnPlatform(TestPlatforms.Browser, "ZIP encryption is not supported on browser.")] + [InlineData(ZipEncryptionMethod.ZipCrypto, true)] + [InlineData(ZipEncryptionMethod.ZipCrypto, false)] + [InlineData(ZipEncryptionMethod.Aes256, true)] + [InlineData(ZipEncryptionMethod.Aes256, false)] + public async Task EncryptedEntry_PartialRead_ThenGetNextEntry_AdvancesCorrectly(ZipEncryptionMethod method, bool async) + { + byte[] zipBytes = CreateZipWithEncryptedThenPlainEntry(method); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + ZipArchiveEntry? encrypted = await GetNextEntry(reader, async); + Assert.NotNull(encrypted); + Assert.True(encrypted.IsEncrypted); + + // Decrypt and read only a few bytes, then advance: the reader must drain the rest of the + // partially-consumed encrypted stream and skip any trailing descriptor. + using (Stream dataStream = await OpenEntry(encrypted, EncryptionPassword, async)) + { + byte[] partial = new byte[4]; + await ReadStream(dataStream, partial, async); + } + + ZipArchiveEntry? plain = await GetNextEntry(reader, async); + Assert.NotNull(plain); + Assert.Equal("plain.bin", plain.FullName); + Assert.Equal(s_mediumContent, await ReadStreamFully(plain.Open(), async)); + } + + [Theory] + [SkipOnPlatform(TestPlatforms.Browser, "ZIP encryption is not supported on browser.")] + [MemberData(nameof(Get_Booleans_Data))] + public async Task EncryptedEntry_OpenWithoutPassword_ThenRetryWithPassword_Succeeds(bool async) + { + // A failed open that reads no bytes (missing password) must not consume the single-use stream, + // so a subsequent open with the correct password still succeeds. + byte[] zipBytes = CreateZipWithEncryptedEntry(ZipEncryptionMethod.Aes256, seekable: true, s_mediumContent); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + Assert.NotNull(entry); + Assert.True(entry.IsEncrypted); + + if (async) + { + await Assert.ThrowsAsync(() => entry.OpenAsync()); + } + else + { + Assert.Throws(() => entry.Open()); + } + + using Stream dataStream = await OpenEntry(entry, EncryptionPassword, async); + byte[] decrypted = await ReadStreamFully(dataStream, async); + Assert.Equal(s_mediumContent, decrypted); + } + + [Fact] + public async Task AsyncCancellation_ThrowsOperationCanceled() + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + using CancellationTokenSource cts = new(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync( + () => reader.GetNextEntryAsync(cancellationToken: cts.Token).AsTask()); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Dispose_WhileEntryPartiallyRead_DoesNotThrow(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + using MemoryStream archiveStream = new(zipBytes); + ZipStreamReader reader = new(archiveStream); + + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + + Assert.NotNull(entry); + + byte[] partial = new byte[5]; + await ReadStream(entry.Open(), partial, async); + + await DisposeReader(reader, async); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task EmptyArchive_ReturnsNull(bool async) + { + using MemoryStream ms = new(); + using (new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { } + + ms.Position = 0; + using ZipStreamReader reader = new(ms); + + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + + Assert.Null(entry); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task LeaveOpen_DoesNotDisposeStream(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + using MemoryStream archiveStream = new(zipBytes); + + ZipStreamReader reader = new(archiveStream, leaveOpen: true); + await DisposeReader(reader, async); + + Assert.True(archiveStream.CanRead); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Constructor_WithEncoding_ReadsEntryNames(bool async) + { + using MemoryStream ms = new(); + using (ZipArchive archive = new(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + AddEntry(archive, "hello.txt", s_smallContent, CompressionLevel.Optimal); + } + + ms.Position = 0; + using ZipStreamReader reader = new(ms, entryNameEncoding: Encoding.UTF8, leaveOpen: true); + + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + + Assert.NotNull(entry); + Assert.Equal("hello.txt", entry.FullName); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task MultipleEntries_MixedSkipAndRead(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + // Skip first entry + ZipArchiveEntry? first = await GetNextEntry(reader, async); + Assert.NotNull(first); + + // Read second entry fully + ZipArchiveEntry? second = await GetNextEntry(reader, async); + Assert.NotNull(second); + byte[] data = await ReadStreamFully(second.Open(), async); + Assert.Equal(s_mediumContent, data); + + // Skip third entry + ZipArchiveEntry? third = await GetNextEntry(reader, async); + Assert.NotNull(third); + + // Confirm end + ZipArchiveEntry? end = await GetNextEntry(reader, async); + Assert.Null(end); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task GetNextEntry_AfterDispose_ThrowsObjectDisposedException(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + using MemoryStream archiveStream = new(zipBytes); + ZipStreamReader reader = new(archiveStream); + + // Read one entry to ensure the reader was functional. + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + Assert.NotNull(entry); + + await DisposeReader(reader, async); + + if (async) + { + await Assert.ThrowsAsync(() => reader.GetNextEntryAsync().AsTask()); + } + else + { + Assert.Throws(() => reader.GetNextEntry()); + } + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task CopyData_WithDataDescriptor_PreservesEntryAfterAdvancing(bool async) + { + // seekable: false triggers data descriptors for Deflate entries. + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: false); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + // Read first entry with copyData: true — exercises the path that + // eagerly decompresses, copies into a MemoryStream, then reads the + // data descriptor to validate CRC. + ZipArchiveEntry? first = await GetNextEntry(reader, async, copyData: true); + + Assert.NotNull(first); + Stream firstData = first.Open(); + + // Advance to the next entry to confirm the stream position is correct + // after consuming the data descriptor. + ZipArchiveEntry? second = await GetNextEntry(reader, async); + Assert.NotNull(second); + Assert.Equal("medium.bin", second.FullName); + + // The copied first entry's data should still be fully readable. + firstData.Position = 0; + byte[] decompressed = await ReadStreamFully(firstData, async); + Assert.Equal(s_smallContent, decompressed); + + // Also verify the second entry's data is correct. + byte[] secondData = await ReadStreamFully(second.Open(), async); + Assert.Equal(s_mediumContent, secondData); + } + + [Theory] + [MemberData(nameof(Get_Booleans_Data))] + public async Task Open_CalledTwice_ThrowsInvalidOperation(bool async) + { + byte[] zipBytes = CreateZipWithEntries(CompressionLevel.Optimal, seekable: true); + + using MemoryStream archiveStream = new(zipBytes); + using ZipStreamReader reader = new(archiveStream); + + ZipArchiveEntry? entry = await GetNextEntry(reader, async); + + Assert.NotNull(entry); + entry.Open(); + + Assert.Throws(() => entry.Open()); + } + + [Fact] + public void Constructor_NullStream_ThrowsArgumentNullException() + { + Assert.Throws("stream", () => new ZipStreamReader(null!)); + } + + [Fact] + public void Constructor_UnreadableStream_ThrowsArgumentException() + { + using MemoryStream ms = new(); + using WrappedStream unreadable = new(ms, canRead: false, canWrite: true, canSeek: true); + + Assert.Throws("stream", () => new ZipStreamReader(unreadable)); + } + + // ── Sync/async dispatch helpers ────────────────────────────────────── + + private static async ValueTask GetNextEntry( + ZipStreamReader reader, bool async, bool copyData = false) + { + return async + ? await reader.GetNextEntryAsync(copyData: copyData) + : reader.GetNextEntry(copyData: copyData); + } + + private static async ValueTask OpenEntry(ZipArchiveEntry entry, bool async) => + async ? await entry.OpenAsync() : entry.Open(); + + private static async ValueTask OpenEntry(ZipArchiveEntry entry, FileAccess access, bool async) => + async ? await entry.OpenAsync(access) : entry.Open(access); + + private static async ValueTask OpenEntry(ZipArchiveEntry entry, string password, bool async) => + async ? await entry.OpenAsync(password) : entry.Open(password); + + private static bool IsDirectory(ZipArchiveEntry entry) => + entry.FullName.Length > 0 && (entry.FullName[^1] is '/' or '\\'); + + private static async Task DisposeReader(ZipStreamReader reader, bool async) + { + if (async) + await reader.DisposeAsync(); + else + reader.Dispose(); + } + + private static async ValueTask ReadStream(Stream stream, byte[] buffer, bool async) + { + return async + ? await stream.ReadAsync(buffer) + : stream.Read(buffer); + } + + // ── Test data helpers ──────────────────────────────────────────────── + + private static byte[] CreateZipWithEntries(CompressionLevel compressionLevel, bool seekable) + { + MemoryStream ms = new(); + + Stream writeStream = seekable + ? ms + : new WrappedStream(ms, canRead: true, canWrite: true, canSeek: false); + + using (ZipArchive archive = new(writeStream, ZipArchiveMode.Create, leaveOpen: true)) + { + AddEntry(archive, "small.txt", s_smallContent, compressionLevel); + AddEntry(archive, "medium.bin", s_mediumContent, compressionLevel); + AddEntry(archive, "large.bin", s_largeContent, compressionLevel); + } + + return ms.ToArray(); + } + + private static void AddEntry(ZipArchive archive, string name, byte[] contents, CompressionLevel level) + { + ZipArchiveEntry entry = archive.CreateEntry(name, level); + using Stream stream = entry.Open(); + stream.Write(contents); + } + + private const string EncryptionPassword = "forward-read-secret"; + + private static byte[] CreateZipWithEncryptedEntry(ZipEncryptionMethod method, bool seekable, byte[] contents) + { + MemoryStream ms = new(); + + Stream writeStream = seekable + ? ms + : new WrappedStream(ms, canRead: true, canWrite: true, canSeek: false); + + using (ZipArchive archive = new(writeStream, ZipArchiveMode.Create, leaveOpen: true)) + { + ZipArchiveEntry entry = archive.CreateEntry("secret.bin", CompressionLevel.Optimal, EncryptionPassword.AsSpan(), method); + using Stream stream = entry.Open(); + stream.Write(contents); + } + + return ms.ToArray(); + } + + private static byte[] CreateZipWithEncryptedThenPlainEntry(ZipEncryptionMethod method) + { + MemoryStream ms = new(); + + using (ZipArchive archive = new(ms, ZipArchiveMode.Create, leaveOpen: true)) + { + ZipArchiveEntry encrypted = archive.CreateEntry("encrypted.bin", CompressionLevel.Optimal, EncryptionPassword.AsSpan(), method); + using (Stream stream = encrypted.Open()) + { + stream.Write(s_smallContent); + } + + AddEntry(archive, "plain.bin", s_mediumContent, CompressionLevel.Optimal); + } + + return ms.ToArray(); + } + + private static async Task ReadStreamFully(Stream stream, bool async) + { + using MemoryStream result = new(); + byte[] buffer = new byte[4096]; + + int bytesRead; + if (async) + { + while ((bytesRead = await stream.ReadAsync(buffer)) > 0) + { + result.Write(buffer, 0, bytesRead); + } + } + else + { + while ((bytesRead = stream.Read(buffer)) > 0) + { + result.Write(buffer, 0, bytesRead); + } + } + + return result.ToArray(); + } + } +}