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