diff --git a/src/SharpCompress/Common/Zip/SeekableZipHeaderFactory.Async.cs b/src/SharpCompress/Common/Zip/SeekableZipHeaderFactory.Async.cs index e9065f80..93b7dd63 100644 --- a/src/SharpCompress/Common/Zip/SeekableZipHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Zip/SeekableZipHeaderFactory.Async.cs @@ -138,15 +138,16 @@ internal sealed partial class SeekableZipHeaderFactory using var reader = new AsyncBinaryReader(stream, leaveOpen: true); #endif var signature = await reader.ReadUInt32Async().ConfigureAwait(false); - if ( - await ReadHeader(signature, reader, _zip64).ConfigureAwait(false) - is not LocalEntryHeader localEntryHeader - ) + if (signature != ENTRY_HEADER_BYTES) { throw new ArchiveOperationException(); } + // Sizes written to a data descriptor are only known from the central directory and are + // needed before the encryption header is loaded. + var localEntryHeader = await ReadLocalHeader(reader).ConfigureAwait(false); PopulateDirectoryEntryMetadata(localEntryHeader, directoryEntryHeader); + await LoadHeaderAsync(localEntryHeader, stream).ConfigureAwait(false); return localEntryHeader; } diff --git a/src/SharpCompress/Common/Zip/SeekableZipHeaderFactory.cs b/src/SharpCompress/Common/Zip/SeekableZipHeaderFactory.cs index c20163b9..52337b25 100644 --- a/src/SharpCompress/Common/Zip/SeekableZipHeaderFactory.cs +++ b/src/SharpCompress/Common/Zip/SeekableZipHeaderFactory.cs @@ -146,12 +146,16 @@ internal sealed partial class SeekableZipHeaderFactory : ZipHeaderFactory stream.Seek(directoryEntryHeader.RelativeOffsetOfEntryHeader, SeekOrigin.Begin); var reader = new BinaryReader(stream); var signature = reader.ReadUInt32(); - if (ReadHeader(signature, reader, _zip64) is not LocalEntryHeader localEntryHeader) + if (signature != ENTRY_HEADER_BYTES) { throw new ArchiveOperationException(); } + // Sizes written to a data descriptor are only known from the central directory and are + // needed before the encryption header is loaded. + var localEntryHeader = ReadLocalHeader(reader); PopulateDirectoryEntryMetadata(localEntryHeader, directoryEntryHeader); + LoadHeader(localEntryHeader, stream); return localEntryHeader; } diff --git a/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.Async.cs b/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.Async.cs index 3c5bd692..ca49a7d5 100644 --- a/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.Async.cs @@ -110,6 +110,8 @@ internal sealed partial class StreamingZipHeaderFactory continue; } + _headerFactory.SeekPastResolvedEntry(lastEntryHeader, _sharpCompressStream); + var pos = _sharpCompressStream.CanSeek ? (long?)_sharpCompressStream.Position : null; @@ -301,8 +303,12 @@ internal sealed partial class StreamingZipHeaderFactory localHeader.IsCrcAvailable = true; } + if (_headerFactory.IsResolvedDirectory(localHeader)) + { + header.HasData = false; + } // If we have CompressedSize, there is data to be read - if (localHeader.CompressedSize > 0) + else if (localHeader.CompressedSize > 0) { header.HasData = true; } // Check if zip is streaming ( Length is 0 and is declared in PostDataDescriptor ) @@ -317,6 +323,7 @@ internal sealed partial class StreamingZipHeaderFactory // Check if next data is PostDataDescriptor, streamed file with 0 length header.HasData = !IsHeader(nextHeaderBytes); + ThrowIfUnreadableEncryptedDirectory(localHeader, header.HasData); } else // We are not streaming and compressed size is 0, we have no data { diff --git a/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.CentralDirectory.cs b/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.CentralDirectory.cs new file mode 100644 index 00000000..86a56148 --- /dev/null +++ b/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.CentralDirectory.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using SharpCompress.Common.Zip.Headers; +using SharpCompress.IO; + +namespace SharpCompress.Common.Zip; + +/// +/// Encrypted entries written with a post data descriptor have no sizes in their local header, and the +/// descriptor cannot be found by scanning encrypted data. When the archive stream is seekable, the sizes +/// are taken from the central directory, walked by a forward-only cursor so memory use stays constant +/// regardless of the number of entries. +/// +internal sealed partial class StreamingZipHeaderFactory +{ + private const string EncryptedDataDescriptorNotSupported = + "SharpCompress cannot currently read non-seekable Zip Streams with encrypted data that has been written in a non-seekable manner."; + + private IEnumerator? _centralDirectory; + private IAsyncEnumerator? _centralDirectoryAsync; + private bool _centralDirectoryUnavailable; + private bool _centralDirectoryExhausted; + private LocalEntryHeader? _resolvedEntry; + private long _resolvedEntryDataEnd; + + protected override bool TryResolveDescriptorSizes(ZipFileEntry entryHeader, Stream stream) + { + var seekableStream = GetSeekableStream(stream); + if ( + _centralDirectoryUnavailable + || seekableStream is null + || entryHeader is not LocalEntryHeader localHeader + ) + { + return false; + } + + var dataStart = seekableStream.Position; + try + { + var directoryEntry = FindDirectoryEntry(seekableStream, localHeader); + return directoryEntry is not null && Resolve(localHeader, directoryEntry, dataStart); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // An unreadable central directory leaves the forward-only behavior in place. + _centralDirectoryUnavailable = true; + ResetCentralDirectory(); + return false; + } + finally + { + seekableStream.Position = dataStart; + } + } + + protected override async ValueTask TryResolveDescriptorSizesAsync( + ZipFileEntry entryHeader, + Stream stream + ) + { + var seekableStream = GetSeekableStream(stream); + if ( + _centralDirectoryUnavailable + || seekableStream is null + || entryHeader is not LocalEntryHeader localHeader + ) + { + return false; + } + + var dataStart = seekableStream.Position; + try + { + var directoryEntry = await FindDirectoryEntryAsync(seekableStream, localHeader) + .ConfigureAwait(false); + return directoryEntry is not null && Resolve(localHeader, directoryEntry, dataStart); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // An unreadable central directory leaves the forward-only behavior in place. + _centralDirectoryUnavailable = true; + await ResetCentralDirectoryAsync().ConfigureAwait(false); + return false; + } + finally + { + seekableStream.Position = dataStart; + } + } + + private DirectoryEntryHeader? FindDirectoryEntry(Stream stream, LocalEntryHeader localHeader) + { + // The central directory normally lists entries in local header order, so the cursor only + // moves forward; a single restart covers archives that order it differently. + for (var pass = 0; pass < 2; pass++) + { + _centralDirectory ??= new SeekableZipHeaderFactory(null, ArchiveEncoding) + .ReadSeekableHeader(stream) + .GetEnumerator(); + while (!_centralDirectoryExhausted && _centralDirectory.MoveNext()) + { + if (IsMatch(_centralDirectory.Current, localHeader) is { } directoryEntry) + { + return directoryEntry; + } + } + + ResetCentralDirectory(); + } + + return null; + } + + private async ValueTask FindDirectoryEntryAsync( + Stream stream, + LocalEntryHeader localHeader + ) + { + // The central directory normally lists entries in local header order, so the cursor only + // moves forward; a single restart covers archives that order it differently. + for (var pass = 0; pass < 2; pass++) + { + _centralDirectoryAsync ??= new SeekableZipHeaderFactory(null, ArchiveEncoding) + .ReadSeekableHeaderAsync(stream) + .GetAsyncEnumerator(); + while ( + !_centralDirectoryExhausted + && await _centralDirectoryAsync.MoveNextAsync().ConfigureAwait(false) + ) + { + if (IsMatch(_centralDirectoryAsync.Current, localHeader) is { } directoryEntry) + { + return directoryEntry; + } + } + + await ResetCentralDirectoryAsync().ConfigureAwait(false); + } + + return null; + } + + private DirectoryEntryHeader? IsMatch(ZipHeader header, LocalEntryHeader localHeader) + { + switch (header) + { + case DirectoryEntryHeader directoryEntry + when string.Equals(directoryEntry.Name, localHeader.Name, StringComparison.Ordinal): + return directoryEntry; + case DirectoryEndHeader: + // The central directory enumerator does not stop at the end record. + _centralDirectoryExhausted = true; + return null; + default: + return null; + } + } + + private bool Resolve( + LocalEntryHeader localHeader, + DirectoryEntryHeader directoryEntry, + long dataStart + ) + { + localHeader.CompressedSize = directoryEntry.CompressedSize; + localHeader.UncompressedSize = directoryEntry.UncompressedSize; + localHeader.Crc = directoryEntry.Crc; + localHeader.IsCrcAvailable = true; + _resolvedEntry = localHeader; + _resolvedEntryDataEnd = dataStart + directoryEntry.CompressedSize; + return true; + } + + private void ResetCentralDirectory() + { + _centralDirectory?.Dispose(); + _centralDirectory = null; + _centralDirectoryExhausted = false; + } + + private async ValueTask ResetCentralDirectoryAsync() + { + if (_centralDirectoryAsync is not null) + { + await _centralDirectoryAsync.DisposeAsync().ConfigureAwait(false); + } + _centralDirectoryAsync = null; + _centralDirectoryExhausted = false; + } + + /// + /// Positions the stream at the data descriptor of an entry whose sizes came from the central + /// directory; decoders may stop short of the entry end (e.g. the WinZip AES authentication code). + /// + private void SeekPastResolvedEntry(LocalEntryHeader lastEntryHeader, Stream stream) + { + if (ReferenceEquals(lastEntryHeader, _resolvedEntry)) + { + stream.Position = _resolvedEntryDataEnd; + _resolvedEntry = null; + } + } + + /// + /// An encrypted directory resolved from the central directory carries only its encryption header, + /// which is skipped together with the data descriptor. + /// + private bool IsResolvedDirectory(LocalEntryHeader localHeader) => + localHeader.IsDirectory && ReferenceEquals(localHeader, _resolvedEntry); + + /// + /// Fails instead of silently ending the entry enumeration when an encrypted directory written with + /// a data descriptor has payload of unknown size (the following entries could not be located). + /// + private static void ThrowIfUnreadableEncryptedDirectory( + LocalEntryHeader localHeader, + bool hasData + ) + { + if ( + hasData + && localHeader.IsDirectory + && FlagUtility.HasFlag(localHeader.Flags, HeaderFlags.Encrypted) + ) + { + throw new NotSupportedException(EncryptedDataDescriptorNotSupported); + } + } + + /// + /// Returns the seekable stream underneath SharpCompress wrappers, or null when the archive can + /// only be read forward (a buffered only seeks within its buffer). + /// + private static Stream? GetSeekableStream(Stream stream) + { + while (stream is SharpCompressStream { IsPassthrough: true } sharpCompressStream) + { + stream = sharpCompressStream.BaseStream(); + } + + if (stream is SeekableSharpCompressStream seekableSharpCompressStream) + { + stream = seekableSharpCompressStream.BaseStream(); + } + + return stream is SharpCompressStream ? null + : stream.CanSeek ? stream + : null; + } +} diff --git a/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.cs b/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.cs index df993a43..5684ed80 100644 --- a/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.cs +++ b/src/SharpCompress/Common/Zip/StreamingZipHeaderFactory.cs @@ -6,7 +6,7 @@ using SharpCompress.IO; namespace SharpCompress.Common.Zip; -internal partial class StreamingZipHeaderFactory : ZipHeaderFactory +internal sealed partial class StreamingZipHeaderFactory : ZipHeaderFactory { private IEnumerable? _entries; @@ -47,6 +47,7 @@ internal partial class StreamingZipHeaderFactory : ZipHeaderFactory } // removed requirement for FixStreamedFileLocation() + SeekPastResolvedEntry(_lastEntryHeader, sharpCompressStream); var pos = sharpCompressStream.CanSeek ? (long?)sharpCompressStream.Position @@ -209,8 +210,12 @@ internal partial class StreamingZipHeaderFactory : ZipHeaderFactory localHeader.IsCrcAvailable = true; } + if (IsResolvedDirectory(localHeader)) + { + header.HasData = false; + } // If we have CompressedSize, there is data to be read - if (localHeader.CompressedSize > 0) + else if (localHeader.CompressedSize > 0) { header.HasData = true; } // Check if zip is streaming ( Length is 0 and is declared in PostDataDescriptor ) @@ -223,6 +228,7 @@ internal partial class StreamingZipHeaderFactory : ZipHeaderFactory // Check if next data is PostDataDescriptor, streamed file with 0 length header.HasData = !IsHeader(nextHeaderBytes); + ThrowIfUnreadableEncryptedDirectory(localHeader, header.HasData); } else // We are not streaming and compressed size is 0, we have no data { diff --git a/src/SharpCompress/Common/Zip/ZipHeaderFactory.Async.cs b/src/SharpCompress/Common/Zip/ZipHeaderFactory.Async.cs index bb420a59..2387a89c 100644 --- a/src/SharpCompress/Common/Zip/ZipHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Zip/ZipHeaderFactory.Async.cs @@ -93,13 +93,31 @@ internal partial class ZipHeaderFactory return entryHeader; } + /// + /// Asynchronous counterpart of . + /// + protected virtual ValueTask TryResolveDescriptorSizesAsync( + ZipFileEntry entryHeader, + Stream stream + ) => new(false); + /// /// Loads encryption metadata and stream positioning for a header using async reads where needed. /// - private async ValueTask LoadHeaderAsync(ZipFileEntry entryHeader, Stream stream) + protected async ValueTask LoadHeaderAsync(ZipFileEntry entryHeader, Stream stream) { if (FlagUtility.HasFlag(entryHeader.Flags, HeaderFlags.Encrypted)) { + if ( + entryHeader.CompressedSize == 0 + && FlagUtility.HasFlag(entryHeader.Flags, HeaderFlags.UsePostDataDescriptor) + ) + { + // The sizes are only in the data descriptor, which cannot be located by scanning + // encrypted data; a seekable stream can take them from the central directory. + await TryResolveDescriptorSizesAsync(entryHeader, stream).ConfigureAwait(false); + } + if ( !entryHeader.IsDirectory && entryHeader.CompressedSize == 0 diff --git a/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs b/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs index cabe6376..d8c4fb25 100644 --- a/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs +++ b/src/SharpCompress/Common/Zip/ZipHeaderFactory.cs @@ -36,6 +36,16 @@ internal partial class ZipHeaderFactory _archiveEncoding = archiveEncoding; } + protected IArchiveEncoding ArchiveEncoding => _archiveEncoding; + + /// + /// Populates the sizes of an encrypted entry that was written with a post data descriptor and + /// therefore has no sizes in its local header. + /// + /// when was populated. + protected virtual bool TryResolveDescriptorSizes(ZipFileEntry entryHeader, Stream stream) => + false; + protected ZipHeader? ReadHeader(uint headerBytes, BinaryReader reader, bool zip64 = false) { switch (headerBytes) @@ -133,10 +143,20 @@ internal partial class ZipHeaderFactory } } - private void LoadHeader(ZipFileEntry entryHeader, Stream stream) + protected void LoadHeader(ZipFileEntry entryHeader, Stream stream) { if (FlagUtility.HasFlag(entryHeader.Flags, HeaderFlags.Encrypted)) { + if ( + entryHeader.CompressedSize == 0 + && FlagUtility.HasFlag(entryHeader.Flags, HeaderFlags.UsePostDataDescriptor) + ) + { + // The sizes are only in the data descriptor, which cannot be located by scanning + // encrypted data; a seekable stream can take them from the central directory. + TryResolveDescriptorSizes(entryHeader, stream); + } + if ( !entryHeader.IsDirectory && entryHeader.CompressedSize == 0 diff --git a/tests/SharpCompress.Test/Zip/ZipEncryptedDataDescriptorTests.cs b/tests/SharpCompress.Test/Zip/ZipEncryptedDataDescriptorTests.cs new file mode 100644 index 00000000..0eb13ab2 --- /dev/null +++ b/tests/SharpCompress.Test/Zip/ZipEncryptedDataDescriptorTests.cs @@ -0,0 +1,347 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Readers; +using SharpCompress.Readers.Zip; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test.Zip; + +/// +/// Archives written by streaming encryptors (these fixtures by Aspose.Zip) encrypt every entry, including +/// directories, and set the post data descriptor flag with zero sizes in the local header. The sizes are +/// only in the data descriptor and the central directory. +/// +public class ZipEncryptedDataDescriptorTests : TestBase +{ + private const string Password = "test"; + + public static TheoryData Archives => + new() + { + "Zip.deflate.zipcrypto.datadescriptor.zip", + "Zip.deflate.aes128.datadescriptor.zip", + "Zip.deflate.aes192.datadescriptor.zip", + "Zip.deflate.aes256.datadescriptor.zip", + "Zip.store.zipcrypto.datadescriptor.zip", + "Zip.store.aes256.datadescriptor.zip", + }; + + [Theory] + [MemberData(nameof(Archives))] + public void Reader_Reads_All_Entries(string archive) + { + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archive)); + using var reader = ZipReader.OpenReader(stream, Options()); + + var contents = new Dictionary(); + while (reader.MoveToNextEntry()) + { + contents.Add(reader.Entry.Key!, reader.Entry.IsDirectory ? [] : Read(reader)); + } + + AssertExpected(contents); + } + + [Theory] + [MemberData(nameof(Archives))] + public async ValueTask Reader_Reads_All_Entries_Async(string archive) + { + using Stream stream = new AsyncOnlyStream( + File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archive)) + ); + await using var reader = await ReaderFactory.OpenAsyncReader(stream, Options()); + + var contents = new Dictionary(); + while (await reader.MoveToNextEntryAsync()) + { + contents.Add( + reader.Entry.Key!, + reader.Entry.IsDirectory ? [] : await ReadAsync(reader) + ); + } + + AssertExpected(contents); + } + + [Theory] + [MemberData(nameof(Archives))] + public void ReaderFactory_Reads_Entries_When_Some_Are_Skipped(string archive) + { + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archive)); + using var reader = ReaderFactory.OpenReader(stream, Options().WithLookForHeader(true)); + + var keys = new List(); + while (reader.MoveToNextEntry()) + { + keys.Add(reader.Entry.Key!); + if (reader.Entry.Key == "data/sub/binary.bin") + { + // partially read, the rest is skipped by the reader + using var entryStream = reader.OpenEntryStream(); + Assert.Equal(1, entryStream.Read(new byte[1], 0, 1)); + } + else if (reader.Entry.Key == "data/empty.txt") + { + Assert.Empty(Read(reader)); + } + // data/readme.txt is never opened + } + + Assert.Equal(ExpectedEntries.Keys.OrderBy(k => k), keys.OrderBy(k => k)); + } + + [Theory] + [MemberData(nameof(Archives))] + public async ValueTask Reader_Reads_Entries_When_Some_Are_Skipped_Async(string archive) + { + using Stream stream = new AsyncOnlyStream( + File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archive)) + ); + await using var reader = await ReaderFactory.OpenAsyncReader(stream, Options()); + + string? readmeKey = null; + var count = 0; + while (await reader.MoveToNextEntryAsync()) + { + count++; + if (reader.Entry.Key == "data/readme.txt") + { + readmeKey = reader.Entry.Key; + Assert.Equal(ExpectedEntries[readmeKey], await ReadAsync(reader)); + } + // every other entry is skipped without being opened + } + + Assert.Equal(ExpectedEntries.Count, count); + Assert.NotNull(readmeKey); + } + + [Theory] + [MemberData(nameof(Archives))] + public void Reader_Reports_Sizes_From_Central_Directory(string archive) + { + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archive)); + using var reader = ZipReader.OpenReader(stream, Options()); + + var files = 0; + while (reader.MoveToNextEntry()) + { + if (!reader.Entry.IsDirectory) + { + files++; + Assert.Equal(ExpectedEntries[reader.Entry.Key!].Length, reader.Entry.Size); + } + } + + Assert.Equal(3, files); + } + + [Theory] + [MemberData(nameof(Archives))] + public void Archive_Reads_All_Entries(string archive) + { + using var zip = ZipArchive.OpenArchive( + Path.Combine(TEST_ARCHIVES_PATH, archive), + Options() + ); + + var contents = new Dictionary(); + foreach (var entry in zip.Entries) + { + if (entry.IsDirectory) + { + contents.Add(entry.Key!, []); + continue; + } + + using var entryStream = entry.OpenEntryStream(); + using var output = new MemoryStream(); + entryStream.CopyTo(output); + contents.Add(entry.Key!, output.ToArray()); + } + + AssertExpected(contents); + } + + [Theory] + [MemberData(nameof(Archives))] + public async ValueTask Archive_Reads_All_Entries_Async(string archive) + { + using Stream stream = new AsyncOnlyStream( + File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archive)) + ); + await using var zip = await ZipArchive.OpenAsyncArchive(stream, Options()); + + var contents = new Dictionary(); + await foreach (var entry in zip.EntriesAsync) + { + if (entry.IsDirectory) + { + contents.Add(entry.Key!, []); + continue; + } + +#if LEGACY_DOTNET + using var entryStream = await entry.OpenEntryStreamAsync(); +#else + await using var entryStream = await entry.OpenEntryStreamAsync(); +#endif + using var output = new MemoryStream(); + await entryStream.CopyToAsync(output); + contents.Add(entry.Key!, output.ToArray()); + } + + AssertExpected(contents); + } + + [Theory] + [MemberData(nameof(Archives))] + public void Wrong_Password_Is_Rejected(string archive) + { + var options = Options() with { Password = "wrong" }; + var expected = archive.Contains("zipcrypto") + ? typeof(CryptographicException) + : typeof(InvalidFormatException); + + using (var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archive))) + { + using var reader = ZipReader.OpenReader(stream, options); + var exception = Record.Exception(() => + { + while (reader.MoveToNextEntry()) + { + if (!reader.Entry.IsDirectory) + { + Read(reader); + } + } + }); + Assert.IsType(expected, exception); + } + + using var zip = ZipArchive.OpenArchive(Path.Combine(TEST_ARCHIVES_PATH, archive), options); + var archiveException = Record.Exception(() => + { + foreach (var entry in zip.Entries.Where(e => !e.IsDirectory)) + { + using var entryStream = entry.OpenEntryStream(); + entryStream.CopyTo(Stream.Null); + } + }); + Assert.IsType(expected, archiveException); + } + + [Theory] + [MemberData(nameof(Archives))] + public void NonSeekable_Stream_Is_Not_Supported(string archive) + { + // Without the central directory the encrypted entries cannot be delimited; this must fail + // instead of silently ending the enumeration after the first entry. + using Stream stream = new ForwardOnlyStream( + File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archive)) + ); + using var reader = ZipReader.OpenReader(stream, Options()); + + Assert.Throws(() => + { + while (reader.MoveToNextEntry()) + { + if (!reader.Entry.IsDirectory) + { + Read(reader); + } + } + }); + } + + [Theory] + [MemberData(nameof(Archives))] + public async ValueTask NonSeekable_Stream_Is_Not_Supported_Async(string archive) + { + using Stream stream = new ForwardOnlyStream( + File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archive)) + ); + // ZipReader directly: ReaderFactory format detection would report the failure as an unknown format. + await using var reader = (IAsyncReader) + ZipReader.OpenReader(new AsyncOnlyStream(stream, disposeStream: false), Options()); + + await Assert.ThrowsAsync(async () => + { + while (await reader.MoveToNextEntryAsync()) + { + if (!reader.Entry.IsDirectory) + { + await ReadAsync(reader); + } + } + }); + } + + private static ReaderOptions Options() => new() { Password = Password }; + + private static Dictionary ExpectedEntries => + new() + { + ["data/"] = [], + ["data/readme.txt"] = Encoding.UTF8.GetBytes( + string.Concat( + Enumerable + .Range(0, 100) + .Select(i => $"line {i}: the quick brown fox jumps over the lazy dog\n") + ) + ), + ["data/empty.txt"] = [], + ["data/sub/"] = [], + ["data/sub/binary.bin"] = Binary(20000), + }; + + internal static byte[] Binary(int length) + { + var bytes = new byte[length]; + for (var i = 0; i < length; i++) + { + bytes[i] = (byte)(((uint)i * 2654435761u) >> 24); + } + return bytes; + } + + private static void AssertExpected(Dictionary contents) + { + var expected = ExpectedEntries; + Assert.Equal(expected.Keys.OrderBy(k => k), contents.Keys.OrderBy(k => k)); + foreach (var entry in expected) + { + Assert.True( + entry.Value.SequenceEqual(contents[entry.Key]), + $"content mismatch for {entry.Key}" + ); + } + } + + private static byte[] Read(IReader reader) + { + using var entryStream = reader.OpenEntryStream(); + using var output = new MemoryStream(); + entryStream.CopyTo(output); + return output.ToArray(); + } + + private static async ValueTask ReadAsync(IAsyncReader reader) + { +#if LEGACY_DOTNET + using var entryStream = await reader.OpenEntryStreamAsync(); +#else + await using var entryStream = await reader.OpenEntryStreamAsync(); +#endif + using var output = new MemoryStream(); + await entryStream.CopyToAsync(output); + return output.ToArray(); + } +} diff --git a/tests/TestArchives/Archives/Zip.deflate.aes128.datadescriptor.zip b/tests/TestArchives/Archives/Zip.deflate.aes128.datadescriptor.zip new file mode 100644 index 00000000..7ffe47f7 Binary files /dev/null and b/tests/TestArchives/Archives/Zip.deflate.aes128.datadescriptor.zip differ diff --git a/tests/TestArchives/Archives/Zip.deflate.aes192.datadescriptor.zip b/tests/TestArchives/Archives/Zip.deflate.aes192.datadescriptor.zip new file mode 100644 index 00000000..8f3251f9 Binary files /dev/null and b/tests/TestArchives/Archives/Zip.deflate.aes192.datadescriptor.zip differ diff --git a/tests/TestArchives/Archives/Zip.deflate.aes256.datadescriptor.zip b/tests/TestArchives/Archives/Zip.deflate.aes256.datadescriptor.zip new file mode 100644 index 00000000..cdb98236 Binary files /dev/null and b/tests/TestArchives/Archives/Zip.deflate.aes256.datadescriptor.zip differ diff --git a/tests/TestArchives/Archives/Zip.deflate.zipcrypto.datadescriptor.zip b/tests/TestArchives/Archives/Zip.deflate.zipcrypto.datadescriptor.zip new file mode 100644 index 00000000..8a78f052 Binary files /dev/null and b/tests/TestArchives/Archives/Zip.deflate.zipcrypto.datadescriptor.zip differ diff --git a/tests/TestArchives/Archives/Zip.store.aes256.datadescriptor.zip b/tests/TestArchives/Archives/Zip.store.aes256.datadescriptor.zip new file mode 100644 index 00000000..e2b86db0 Binary files /dev/null and b/tests/TestArchives/Archives/Zip.store.aes256.datadescriptor.zip differ diff --git a/tests/TestArchives/Archives/Zip.store.zipcrypto.datadescriptor.zip b/tests/TestArchives/Archives/Zip.store.zipcrypto.datadescriptor.zip new file mode 100644 index 00000000..1a712b2a Binary files /dev/null and b/tests/TestArchives/Archives/Zip.store.zipcrypto.datadescriptor.zip differ