Merge pull request #1452 from romaklayt/fix/zip-encrypted-data-descriptor

Read encrypted zip entries written with a data descriptor
This commit is contained in:
Adam Hathcock
2026-10-16 08:42:07 +01:00
committed by GitHub
14 changed files with 666 additions and 10 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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
{

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
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<ZipHeader>? _centralDirectory;
private IAsyncEnumerator<ZipHeader>? _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<bool> 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<DirectoryEntryHeader?> 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;
}
/// <summary>
/// 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).
/// </summary>
private void SeekPastResolvedEntry(LocalEntryHeader lastEntryHeader, Stream stream)
{
if (ReferenceEquals(lastEntryHeader, _resolvedEntry))
{
stream.Position = _resolvedEntryDataEnd;
_resolvedEntry = null;
}
}
/// <summary>
/// An encrypted directory resolved from the central directory carries only its encryption header,
/// which is skipped together with the data descriptor.
/// </summary>
private bool IsResolvedDirectory(LocalEntryHeader localHeader) =>
localHeader.IsDirectory && ReferenceEquals(localHeader, _resolvedEntry);
/// <summary>
/// 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).
/// </summary>
private static void ThrowIfUnreadableEncryptedDirectory(
LocalEntryHeader localHeader,
bool hasData
)
{
if (
hasData
&& localHeader.IsDirectory
&& FlagUtility.HasFlag(localHeader.Flags, HeaderFlags.Encrypted)
)
{
throw new NotSupportedException(EncryptedDataDescriptorNotSupported);
}
}
/// <summary>
/// Returns the seekable stream underneath SharpCompress wrappers, or null when the archive can
/// only be read forward (a buffered <see cref="SharpCompressStream"/> only seeks within its buffer).
/// </summary>
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;
}
}

View File

@@ -6,7 +6,7 @@ using SharpCompress.IO;
namespace SharpCompress.Common.Zip;
internal partial class StreamingZipHeaderFactory : ZipHeaderFactory
internal sealed partial class StreamingZipHeaderFactory : ZipHeaderFactory
{
private IEnumerable<ZipEntry>? _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
{

View File

@@ -93,13 +93,31 @@ internal partial class ZipHeaderFactory
return entryHeader;
}
/// <summary>
/// Asynchronous counterpart of <see cref="TryResolveDescriptorSizes"/>.
/// </summary>
protected virtual ValueTask<bool> TryResolveDescriptorSizesAsync(
ZipFileEntry entryHeader,
Stream stream
) => new(false);
/// <summary>
/// Loads encryption metadata and stream positioning for a header using async reads where needed.
/// </summary>
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

View File

@@ -36,6 +36,16 @@ internal partial class ZipHeaderFactory
_archiveEncoding = archiveEncoding;
}
protected IArchiveEncoding ArchiveEncoding => _archiveEncoding;
/// <summary>
/// Populates the sizes of an encrypted entry that was written with a post data descriptor and
/// therefore has no sizes in its local header.
/// </summary>
/// <returns><see langword="true"/> when <see cref="ZipFileEntry.CompressedSize"/> was populated.</returns>
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

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
public class ZipEncryptedDataDescriptorTests : TestBase
{
private const string Password = "test";
public static TheoryData<string> 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<string, byte[]>();
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<string, byte[]>();
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<string>();
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<string, byte[]>();
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<string, byte[]>();
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<NotSupportedException>(() =>
{
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<NotSupportedException>(async () =>
{
while (await reader.MoveToNextEntryAsync())
{
if (!reader.Entry.IsDirectory)
{
await ReadAsync(reader);
}
}
});
}
private static ReaderOptions Options() => new() { Password = Password };
private static Dictionary<string, byte[]> 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<string, byte[]> 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<byte[]> 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();
}
}