Fix solid 7z cache positioning after partial entry reads

This commit is contained in:
Adam Hathcock
2026-10-22 08:22:48 +01:00
parent b8e81bd3f5
commit 2c29a6bfab
7 changed files with 694 additions and 65 deletions

View File

@@ -914,6 +914,14 @@ For solid archives, prefer `ExtractAllEntries()` / `ExtractAllEntriesAsync()` to
reuse the folder decoder across entries. A reader borrowed from an archive can
be disposed without closing that archive's source.
The entries of a solid folder share one forward-only decoder. Reading only part
of an entry does not corrupt the entries that follow: the decoder skips forward
over the bytes that were not read when the next entry is opened. Read an entry
before opening the next entry of the same folder - the shared decoder cannot
serve two open entry streams of the same folder at the same time. Mixing
synchronous and asynchronous entry reads of the same folder is supported; the
decoder keeps the read mode it was first used with.
## 7z Encrypted Decoding Errors
AES-encrypted 7z codec data errors and encrypted-header CRC failures throw `SharpCompress.Common.CryptographicException`,

View File

@@ -46,54 +46,53 @@ internal sealed partial class ArchiveDatabase
CFolder folder,
IPasswordProvider pw,
long skipSize,
long entrySize,
CancellationToken cancellationToken
)
{
if (_cachedFolder == folder && _cachedFolderStream != null)
var tracker = await GetCachedFolderTrackerAsync(stream, folder, pw, cancellationToken)
.ConfigureAwait(false);
if (skipSize < tracker.Position)
{
if (skipSize >= _cachedFolderStreamPosition)
{
var delta = skipSize - _cachedFolderStreamPosition;
if (delta > 0)
{
await _cachedFolderStream
.SkipAsync(delta, cancellationToken)
.ConfigureAwait(false);
}
// Assume the caller will fully consume the returned entry stream, advancing the
// shared stream by entrySize bytes.
_cachedFolderStreamPosition = skipSize + entrySize;
return _cachedFolderStream;
}
// Non-sequential (backward) access within the same folder requires restarting.
await DisposeCachedFolderStreamAsync().ConfigureAwait(false);
tracker = await GetCachedFolderTrackerAsync(stream, folder, pw, cancellationToken)
.ConfigureAwait(false);
}
else if (_cachedFolderStream != null)
var delta = skipSize - tracker.Position;
if (delta > 0)
{
await tracker.SkipAsync(delta, cancellationToken).ConfigureAwait(false);
}
return tracker;
}
private async ValueTask<FolderStreamTracker> GetCachedFolderTrackerAsync(
Stream stream,
CFolder folder,
IPasswordProvider pw,
CancellationToken cancellationToken
)
{
if (_cachedFolderStream is null || _cachedFolder != folder)
{
await DisposeCachedFolderStreamAsync().ConfigureAwait(false);
_cachedFolderStream = await GetFolderStreamAsync(stream, folder, pw, cancellationToken)
.ConfigureAwait(false);
_cachedFolderTracker = new FolderStreamTracker(_cachedFolderStream);
_cachedFolder = folder;
}
var newStream = await GetFolderStreamAsync(stream, folder, pw, cancellationToken)
.ConfigureAwait(false);
if (skipSize > 0)
{
await newStream.SkipAsync(skipSize, cancellationToken).ConfigureAwait(false);
}
_cachedFolder = folder;
_cachedFolderStream = newStream;
_cachedFolderStreamPosition = skipSize + entrySize;
return newStream;
return _cachedFolderTracker.NotNull();
}
internal async ValueTask DisposeCachedFolderStreamAsync()
{
var stream = _cachedFolderStream;
_cachedFolderStream = null;
_cachedFolderTracker = null;
_cachedFolder = null;
_cachedFolderStreamPosition = 0;
if (stream is IAsyncDisposable asyncDisposable)
{
await asyncDisposable.DisposeAsync().ConfigureAwait(false);

View File

@@ -9,6 +9,11 @@ using SharpCompress.IO;
namespace SharpCompress.Common.SevenZip;
[SuppressMessage(
"Design",
"CA1001:Types that own disposable fields should be disposable",
Justification = "The cached folder decoder is released by DisposeCachedFolderStream and DisposeCachedFolderStreamAsync, which the owning SevenZipArchive calls. The tracker only observes the decoder and does not own it."
)]
internal partial class ArchiveDatabase
{
internal byte _majorVersion;
@@ -324,66 +329,67 @@ internal partial class ArchiveDatabase
// file's stream was previously created fresh from the folder start and skipped forward.
private CFolder? _cachedFolder;
private Stream? _cachedFolderStream;
private long _cachedFolderStreamPosition;
private FolderStreamTracker? _cachedFolderTracker;
/// <summary>
/// Returns a stream positioned at <paramref name="skipSize"/> bytes into the decompressed
/// contents of <paramref name="folder"/>. Reuses a cached decoder stream for the folder when
/// possible instead of recreating and re-decoding from the start.
/// </summary>
/// <remarks>
/// The returned stream is shared by every entry of the folder, so the caller must limit the
/// reads to the entry it is interested in. The tracker records how much of the folder was
/// really consumed, which is what allows the next entry to be positioned correctly even when
/// the previous one was not read completely.
/// </remarks>
internal Stream GetFolderStream(
Stream stream,
CFolder folder,
IPasswordProvider pw,
long skipSize,
long entrySize,
bool enableParallelism = false
)
{
if (_cachedFolder == folder && _cachedFolderStream != null)
var tracker = GetCachedFolderTracker(stream, folder, pw, enableParallelism);
if (skipSize < tracker.Position)
{
if (skipSize >= _cachedFolderStreamPosition)
{
var delta = skipSize - _cachedFolderStreamPosition;
if (delta > 0)
{
_cachedFolderStream.Skip(delta);
}
// Assume the caller will fully consume the returned entry stream, advancing the
// shared stream by entrySize bytes.
_cachedFolderStreamPosition = skipSize + entrySize;
return _cachedFolderStream;
}
// Non-sequential (backward) access within the same folder requires restarting.
_cachedFolderStream.Dispose();
_cachedFolderStream = null;
_cachedFolder = null;
}
else if (_cachedFolderStream != null)
{
_cachedFolderStream.Dispose();
_cachedFolderStream = null;
_cachedFolder = null;
DisposeCachedFolderStream();
tracker = GetCachedFolderTracker(stream, folder, pw, enableParallelism);
}
var newStream = GetFolderStream(stream, folder, pw, enableParallelism);
if (skipSize > 0)
var delta = skipSize - tracker.Position;
if (delta > 0)
{
newStream.Skip(skipSize);
tracker.Skip(delta);
}
_cachedFolder = folder;
_cachedFolderStream = newStream;
_cachedFolderStreamPosition = skipSize + entrySize;
return newStream;
return tracker;
}
private FolderStreamTracker GetCachedFolderTracker(
Stream stream,
CFolder folder,
IPasswordProvider pw,
bool enableParallelism
)
{
if (_cachedFolderStream is null || _cachedFolder != folder)
{
DisposeCachedFolderStream();
_cachedFolderStream = GetFolderStream(stream, folder, pw, enableParallelism);
_cachedFolderTracker = new FolderStreamTracker(_cachedFolderStream);
_cachedFolder = folder;
}
return _cachedFolderTracker.NotNull();
}
internal void DisposeCachedFolderStream()
{
_cachedFolderStream?.Dispose();
_cachedFolderStream = null;
_cachedFolderTracker = null;
_cachedFolder = null;
_cachedFolderStreamPosition = 0;
}
}

View File

@@ -0,0 +1,169 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.IO;
namespace SharpCompress.Common.SevenZip;
/// <summary>
/// Keeps track of how much of a solid folder has actually been consumed from its shared decoder.
/// </summary>
/// <remarks>
/// <para>
/// Every entry of a solid folder is decoded by the same forward-only stream, and that stream
/// cannot be rewound. The position therefore has to be tracked explicitly: it tells the caller
/// whether the next entry can be reached by skipping forward over the bytes that were not read
/// (the common case) or whether the folder has to be decoded again from its start.
/// </para>
/// <para>
/// The tracker also pins the decoder to a single read mode. LzmaStream refuses to mix synchronous
/// and asynchronous reads on the same instance, and the decoder is shared by every entry of the
/// folder, so the first read decides the mode used by all later reads. Reads that arrive in the
/// other mode are routed to the mode the decoder was established with instead of failing.
/// </para>
/// <para>
/// The tracker does not own the decoder: the ArchiveDatabase creates and disposes it. Disposing
/// the tracker therefore leaves the shared decoder alone.
/// </para>
/// </remarks>
internal sealed class FolderStreamTracker : Stream, IStreamStack
{
Stream IStreamStack.BaseStream() => _stream;
private readonly Stream _stream;
private readonly byte[] _singleByte = new byte[1];
private long _position;
private bool _asyncMode;
private bool _modeEstablished;
public FolderStreamTracker(Stream stream) =>
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
/// <summary>
/// The number of decompressed bytes consumed from the folder so far.
/// </summary>
public override long Position
{
get => _position;
set
{
if (!_stream.CanSeek)
{
throw new NotSupportedException();
}
_stream.Position = value;
_position = value;
}
}
public override bool CanRead => _stream.CanRead;
public override bool CanSeek => _stream.CanSeek;
public override bool CanWrite => false;
public override void Flush() { }
public override long Length => _stream.Length;
public override int Read(byte[] buffer, int offset, int count)
{
if (count <= 0)
{
return 0;
}
if (_modeEstablished && _asyncMode)
{
// The decoder is already used asynchronously, so it must not be read synchronously.
#pragma warning disable VSTHRD002 // The decoder cannot be read synchronously in this mode.
return ReadAsyncCore(buffer, offset, count, CancellationToken.None)
.GetAwaiter()
.GetResult();
#pragma warning restore VSTHRD002
}
_modeEstablished = true;
_asyncMode = false;
var read = _stream.Read(buffer, offset, count);
_position += read;
return read;
}
public override int ReadByte()
{
if (_modeEstablished && _asyncMode)
{
// The decoder is already used asynchronously, so it must not be read synchronously.
#pragma warning disable VSTHRD002 // The decoder cannot be read synchronously in this mode.
var read = ReadAsyncCore(_singleByte, 0, 1, CancellationToken.None)
.GetAwaiter()
.GetResult();
#pragma warning restore VSTHRD002
return read == 0 ? -1 : _singleByte[0];
}
_modeEstablished = true;
_asyncMode = false;
var value = _stream.ReadByte();
if (value != -1)
{
_position++;
}
return value;
}
public override Task<int> ReadAsync(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken
)
{
if (count <= 0)
{
return Task.FromResult(0);
}
if (_modeEstablished && !_asyncMode)
{
// The decoder is already used synchronously, so it must not be read asynchronously.
cancellationToken.ThrowIfCancellationRequested();
return Task.FromResult(Read(buffer, offset, count));
}
_modeEstablished = true;
_asyncMode = true;
return ReadAsyncCore(buffer, offset, count, cancellationToken);
}
private async Task<int> ReadAsyncCore(
byte[] buffer,
int offset,
int count,
CancellationToken cancellationToken
)
{
var read = await _stream
.ReadAsync(buffer, offset, count, cancellationToken)
.ConfigureAwait(false);
_position += read;
return read;
}
public override long Seek(long offset, SeekOrigin origin)
{
var position = _stream.Seek(offset, origin);
_position = position;
return position;
}
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) =>
throw new NotSupportedException();
public override void WriteByte(byte value) => throw new NotSupportedException();
}

View File

@@ -61,7 +61,6 @@ internal class SevenZipFilePart : FilePart
Folder!,
_database.PasswordProvider,
skipSize,
Header.Size,
_enableParallelism
);
return new ReadOnlySubStream(folderStream, Header.Size, leaveOpen: true);
@@ -90,7 +89,6 @@ internal class SevenZipFilePart : FilePart
Folder!,
_database.PasswordProvider,
skipSize,
Header.Size,
cancellationToken
)
.ConfigureAwait(false);

View File

@@ -0,0 +1,104 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Common.SevenZip;
using SharpCompress.Test.Mocks;
using Xunit;
namespace SharpCompress.Test.SevenZip;
/// <summary>
/// Unit tests for the bookkeeping of the shared solid-folder decoder.
/// </summary>
public class FolderStreamTrackerTests
{
private static byte[] Payload(int size)
{
var data = new byte[size];
for (var i = 0; i < size; i++)
{
data[i] = (byte)(i % 251);
}
return data;
}
[Fact]
public void Reads_AdvanceTheTrackedPosition()
{
var payload = Payload(64);
var tracker = new FolderStreamTracker(new MemoryStream(payload));
Assert.Equal(0, tracker.Position);
var buffer = new byte[10];
Assert.Equal(10, tracker.Read(buffer, 0, buffer.Length));
Assert.Equal(10, tracker.Position);
Assert.Equal(payload[10], tracker.ReadByte());
Assert.Equal(11, tracker.Position);
}
[Fact]
public async Task AsyncReads_AdvanceTheTrackedPosition()
{
var tracker = new FolderStreamTracker(new MemoryStream(Payload(64)));
var buffer = new byte[10];
Assert.Equal(10, await tracker.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None));
Assert.Equal(10, tracker.Position);
}
[Fact]
public void Skip_OverAForwardOnlyStream_ReadsTheSkippedBytes()
{
var payload = Payload(64);
var tracker = new FolderStreamTracker(new ForwardOnlyStream(new MemoryStream(payload)));
Assert.False(tracker.CanSeek);
tracker.Skip(16);
Assert.Equal(16, tracker.Position);
var buffer = new byte[4];
Assert.Equal(4, tracker.Read(buffer, 0, buffer.Length));
Assert.Equal(payload.Skip(16).Take(4), buffer);
Assert.Equal(20, tracker.Position);
}
[Fact]
public void Skip_OverASeekableStream_SeeksAndKeepsThePosition()
{
// The parallel decode path hands out a seekable temp file instead of a decoder.
var payload = Payload(64);
var tracker = new FolderStreamTracker(new MemoryStream(payload));
Assert.True(tracker.CanSeek);
tracker.Skip(16);
Assert.Equal(16, tracker.Position);
var buffer = new byte[4];
Assert.Equal(4, tracker.Read(buffer, 0, buffer.Length));
Assert.Equal(payload.Skip(16).Take(4), buffer);
Assert.Equal(20, tracker.Position);
}
[Fact]
public async Task MixingReadModes_KeepsTheDecoderInASingleMode()
{
var payload = Payload(64);
var tracker = new FolderStreamTracker(new MemoryStream(payload));
var buffer = new byte[10];
Assert.Equal(10, tracker.Read(buffer, 0, buffer.Length));
// An asynchronous read after a synchronous one must not fail and must keep counting.
Assert.Equal(10, await tracker.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None));
Assert.Equal(20, tracker.Position);
// ... and the other way around.
var other = new FolderStreamTracker(new MemoryStream(payload));
Assert.Equal(10, await other.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None));
Assert.Equal(10, other.Read(buffer, 0, buffer.Length));
Assert.Equal(20, other.Position);
}
}

View File

@@ -0,0 +1,345 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Archives;
using SharpCompress.Archives.SevenZip;
using SharpCompress.Common;
using SharpCompress.Readers;
using Xunit;
namespace SharpCompress.Test.SevenZip;
/// <summary>
/// A solid archive is decoded by a single shared, forward-only stream. These tests cover the
/// bookkeeping of that shared stream: entries that are not read completely must not make the
/// following entries start at the wrong offset.
/// </summary>
public class SevenZipSolidCacheTests : TestBase
{
private const string SolidArchive = "7Zip.solid.7z";
private const string SingleBlockSolidArchive = "7Zip.solid.1block.7z";
private static string PathOf(string archiveName) =>
Path.Combine(TEST_ARCHIVES_PATH, archiveName);
private static List<IArchiveEntry> FileEntries(IArchive archive) =>
archive.Entries.Where(e => !e.IsDirectory).ToList();
private static async Task<List<IArchiveEntry>> FileEntriesAsync(IAsyncArchive archive) =>
(
await archive
.EntriesAsync.Where(e => !e.IsDirectory)
.ToListAsync()
.ConfigureAwait(false)
).ToList();
/// <summary>
/// Reads a single entry from its own archive instance so that the expected content never
/// depends on the shared folder stream.
/// </summary>
private static byte[] ExpectedContent(string archiveName, int entryIndex)
{
using var archive = SevenZipArchive.OpenArchive(PathOf(archiveName));
var entry = FileEntries(archive)[entryIndex];
using var stream = entry.OpenEntryStream();
using var content = new MemoryStream();
stream.CopyTo(content);
return content.ToArray();
}
private static async Task<byte[]> ExpectedContentAsync(string archiveName, int entryIndex)
{
await using var archive = await SevenZipArchive
.OpenAsyncArchive(PathOf(archiveName))
.ConfigureAwait(false);
var entry = (await FileEntriesAsync(archive).ConfigureAwait(false))[entryIndex];
var stream = await entry.OpenEntryStreamAsync(CancellationToken.None).ConfigureAwait(false);
await using var scope = stream.DisposeAsyncScope().ConfigureAwait(false);
using var content = new MemoryStream();
await stream
.CopyToAsync(content, Constants.BufferSize, CancellationToken.None)
.ConfigureAwait(false);
return content.ToArray();
}
private static byte[] ReadAll(IArchiveEntry entry)
{
using var stream = entry.OpenEntryStream();
using var content = new MemoryStream();
stream.CopyTo(content);
return content.ToArray();
}
private static async Task<byte[]> ReadAllAsync(IArchiveEntry entry)
{
var stream = await entry.OpenEntryStreamAsync(CancellationToken.None).ConfigureAwait(false);
await using var scope = stream.DisposeAsyncScope().ConfigureAwait(false);
using var content = new MemoryStream();
await stream
.CopyToAsync(content, Constants.BufferSize, CancellationToken.None)
.ConfigureAwait(false);
return content.ToArray();
}
private static void ReadPartial(IArchiveEntry entry, int byteCount)
{
using var stream = entry.OpenEntryStream();
var buffer = new byte[byteCount];
Assert.Equal(byteCount, stream.Read(buffer, 0, buffer.Length));
}
private static async Task ReadPartialAsync(IArchiveEntry entry, int byteCount)
{
var stream = await entry.OpenEntryStreamAsync(CancellationToken.None).ConfigureAwait(false);
await using var scope = stream.DisposeAsyncScope().ConfigureAwait(false);
var buffer = new byte[byteCount];
Assert.Equal(
byteCount,
await stream
.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None)
.ConfigureAwait(false)
);
}
private static object? GetField(object instance, string name) =>
instance
.GetType()
.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(instance);
private static object? CachedFolderStream(object archive) =>
GetField(GetField(archive, "_database")!, "_cachedFolderStream");
[Theory]
[InlineData(SolidArchive)]
[InlineData(SingleBlockSolidArchive)]
public void PartialRead_ThenNextEntry_ReturnsEntryContent(string archiveName)
{
var expected = ExpectedContent(archiveName, 1);
using var archive = SevenZipArchive.OpenArchive(PathOf(archiveName));
var entries = FileEntries(archive);
Assert.True(entries.Count >= 2);
ReadPartial(entries[0], 16);
Assert.Equal(expected, ReadAll(entries[1]));
}
[Theory]
[InlineData(SolidArchive)]
[InlineData(SingleBlockSolidArchive)]
public void ZeroRead_ThenNextEntry_ReturnsEntryContent(string archiveName)
{
var expected = ExpectedContent(archiveName, 1);
using var archive = SevenZipArchive.OpenArchive(PathOf(archiveName));
var entries = FileEntries(archive);
Assert.True(entries.Count >= 2);
using (entries[0].OpenEntryStream()) { }
Assert.Equal(expected, ReadAll(entries[1]));
}
[Theory]
[InlineData(SolidArchive)]
[InlineData(SingleBlockSolidArchive)]
public void PartialRead_ThenEarlierEntry_ReturnsEntryContent(string archiveName)
{
var expected = ExpectedContent(archiveName, 0);
using var archive = SevenZipArchive.OpenArchive(PathOf(archiveName));
var entries = FileEntries(archive);
Assert.True(entries.Count >= 2);
ReadPartial(entries[1], 16);
Assert.Equal(expected, ReadAll(entries[0]));
}
[Fact]
public void PartialRead_ThenLaterEntry_ReturnsEntryContent()
{
var expected = ExpectedContent(SolidArchive, 2);
using var archive = SevenZipArchive.OpenArchive(PathOf(SolidArchive));
var entries = FileEntries(archive);
Assert.True(entries.Count >= 3);
ReadPartial(entries[0], 16);
Assert.Equal(expected, ReadAll(entries[2]));
}
[Theory]
[InlineData(SolidArchive)]
[InlineData(SingleBlockSolidArchive)]
public async Task PartialRead_ThenNextEntryAsync_ReturnsEntryContent(string archiveName)
{
var expected = await ExpectedContentAsync(archiveName, 1);
await using var archive = await SevenZipArchive
.OpenAsyncArchive(PathOf(archiveName))
.ConfigureAwait(false);
var entries = await FileEntriesAsync(archive).ConfigureAwait(false);
Assert.True(entries.Count >= 2);
await ReadPartialAsync(entries[0], 16).ConfigureAwait(false);
Assert.Equal(expected, await ReadAllAsync(entries[1]).ConfigureAwait(false));
}
[Theory]
[InlineData(SolidArchive)]
[InlineData(SingleBlockSolidArchive)]
public async Task ZeroRead_ThenNextEntryAsync_ReturnsEntryContent(string archiveName)
{
var expected = await ExpectedContentAsync(archiveName, 1);
await using var archive = await SevenZipArchive
.OpenAsyncArchive(PathOf(archiveName))
.ConfigureAwait(false);
var entries = await FileEntriesAsync(archive).ConfigureAwait(false);
Assert.True(entries.Count >= 2);
var first = await entries[0]
.OpenEntryStreamAsync(CancellationToken.None)
.ConfigureAwait(false);
await using (first.DisposeAsyncScope()) { }
Assert.Equal(expected, await ReadAllAsync(entries[1]).ConfigureAwait(false));
}
[Theory]
[InlineData(SolidArchive)]
[InlineData(SingleBlockSolidArchive)]
public async Task PartialRead_ThenEarlierEntryAsync_ReturnsEntryContent(string archiveName)
{
var expected = await ExpectedContentAsync(archiveName, 0);
await using var archive = await SevenZipArchive
.OpenAsyncArchive(PathOf(archiveName))
.ConfigureAwait(false);
var entries = await FileEntriesAsync(archive).ConfigureAwait(false);
Assert.True(entries.Count >= 2);
await ReadPartialAsync(entries[1], 16).ConfigureAwait(false);
Assert.Equal(expected, await ReadAllAsync(entries[0]).ConfigureAwait(false));
}
[Fact]
public void AlternatingSyncAndAsyncEntryReads_ReturnEntryContent()
{
var expected = FileEntries(SevenZipArchive.OpenArchive(PathOf(SolidArchive)))
.Select((_, index) => ExpectedContent(SolidArchive, index))
.ToList();
using var archive = SevenZipArchive.OpenArchive(PathOf(SolidArchive));
var entries = FileEntries(archive);
Assert.True(entries.Count >= 3);
ReadPartial(entries[0], 16);
var second = entries[1].OpenEntryStream();
using (second)
{
using var content = new MemoryStream();
second.CopyTo(content);
Assert.Equal(expected[1], content.ToArray());
}
Assert.Equal(expected[2], ReadAll(entries[2]));
}
[Fact]
public async Task AlternatingSyncAndAsyncEntryReads_ReturnEntryContentAsync()
{
var expected = new List<byte[]>();
for (var index = 0; index < 3; index++)
{
expected.Add(await ExpectedContentAsync(SolidArchive, index).ConfigureAwait(false));
}
await using var archive = await SevenZipArchive
.OpenAsyncArchive(PathOf(SolidArchive))
.ConfigureAwait(false);
var entries = await FileEntriesAsync(archive).ConfigureAwait(false);
Assert.True(entries.Count >= 3);
await ReadPartialAsync(entries[0], 16).ConfigureAwait(false);
var second = entries[1].OpenEntryStream();
using (second)
{
using var content = new MemoryStream();
await second
.CopyToAsync(content, Constants.BufferSize, CancellationToken.None)
.ConfigureAwait(false);
Assert.Equal(expected[1], content.ToArray());
}
Assert.Equal(expected[2], await ReadAllAsync(entries[2]).ConfigureAwait(false));
}
[Fact]
public void FullyReadEntries_ReuseTheCachedFolderStream()
{
using var archive = SevenZipArchive.OpenArchive(PathOf(SolidArchive));
var entries = FileEntries(archive);
Assert.True(entries.Count >= 2);
ReadAll(entries[0]);
var decoder = CachedFolderStream(archive);
Assert.NotNull(decoder);
ReadAll(entries[1]);
// The decoder of a solid folder is shared by all of its entries and must not be recreated
// for every entry, otherwise extracting N entries decodes O(N^2) bytes.
Assert.Same(decoder, CachedFolderStream(archive));
}
[Fact]
public void PartialRead_ThenNextEntry_ReusesTheCachedFolderStream()
{
using var archive = SevenZipArchive.OpenArchive(PathOf(SolidArchive));
var entries = FileEntries(archive);
Assert.True(entries.Count >= 2);
ReadPartial(entries[0], 16);
var decoder = CachedFolderStream(archive);
Assert.NotNull(decoder);
ReadAll(entries[1]);
// The bytes that were not read are skipped forward, the folder is not decoded again.
Assert.Same(decoder, CachedFolderStream(archive));
}
[Fact]
public void Dispose_ReleasesTheCachedFolderStream_AndKeepsTheSourceStreamUsable()
{
using var source = File.OpenRead(PathOf(SolidArchive));
var archive = SevenZipArchive.OpenArchive(source, ReaderOptions.ForExternalStream);
var entries = FileEntries(archive);
ReadPartial(entries[0], 16);
var decoder = CachedFolderStream(archive);
Assert.NotNull(decoder);
archive.Dispose();
Assert.Null(CachedFolderStream(archive));
// The source stream is borrowed from the caller and must stay usable.
Assert.True(source.CanRead);
source.Position = 0;
Assert.True(source.ReadByte() >= 0);
}
}