diff --git a/docs/API.md b/docs/API.md index ec01d5d9..e9febe9c 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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`, diff --git a/src/SharpCompress/Common/SevenZip/ArchiveDatabase.Async.cs b/src/SharpCompress/Common/SevenZip/ArchiveDatabase.Async.cs index 785b082e..4d70cd21 100644 --- a/src/SharpCompress/Common/SevenZip/ArchiveDatabase.Async.cs +++ b/src/SharpCompress/Common/SevenZip/ArchiveDatabase.Async.cs @@ -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 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); diff --git a/src/SharpCompress/Common/SevenZip/ArchiveDatabase.cs b/src/SharpCompress/Common/SevenZip/ArchiveDatabase.cs index 612a0b15..7499071d 100644 --- a/src/SharpCompress/Common/SevenZip/ArchiveDatabase.cs +++ b/src/SharpCompress/Common/SevenZip/ArchiveDatabase.cs @@ -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; /// /// Returns a stream positioned at bytes into the decompressed /// contents of . Reuses a cached decoder stream for the folder when /// possible instead of recreating and re-decoding from the start. /// + /// + /// 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. + /// 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; } } diff --git a/src/SharpCompress/Common/SevenZip/FolderStreamTracker.cs b/src/SharpCompress/Common/SevenZip/FolderStreamTracker.cs new file mode 100644 index 00000000..4d69bc42 --- /dev/null +++ b/src/SharpCompress/Common/SevenZip/FolderStreamTracker.cs @@ -0,0 +1,169 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.IO; + +namespace SharpCompress.Common.SevenZip; + +/// +/// Keeps track of how much of a solid folder has actually been consumed from its shared decoder. +/// +/// +/// +/// 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. +/// +/// +/// 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. +/// +/// +/// The tracker does not own the decoder: the ArchiveDatabase creates and disposes it. Disposing +/// the tracker therefore leaves the shared decoder alone. +/// +/// +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)); + + /// + /// The number of decompressed bytes consumed from the folder so far. + /// + 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 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 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(); +} diff --git a/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs b/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs index 2b3f6af3..c9552fc8 100644 --- a/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs +++ b/src/SharpCompress/Common/SevenZip/SevenZipFilePart.cs @@ -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); diff --git a/tests/SharpCompress.Test/SevenZip/FolderStreamTrackerTests.cs b/tests/SharpCompress.Test/SevenZip/FolderStreamTrackerTests.cs new file mode 100644 index 00000000..99c2fe47 --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/FolderStreamTrackerTests.cs @@ -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; + +/// +/// Unit tests for the bookkeeping of the shared solid-folder decoder. +/// +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); + } +} diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipSolidCacheTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipSolidCacheTests.cs new file mode 100644 index 00000000..c90938c2 --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/SevenZipSolidCacheTests.cs @@ -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; + +/// +/// 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. +/// +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 FileEntries(IArchive archive) => + archive.Entries.Where(e => !e.IsDirectory).ToList(); + + private static async Task> FileEntriesAsync(IAsyncArchive archive) => + ( + await archive + .EntriesAsync.Where(e => !e.IsDirectory) + .ToListAsync() + .ConfigureAwait(false) + ).ToList(); + + /// + /// Reads a single entry from its own archive instance so that the expected content never + /// depends on the shared folder stream. + /// + 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 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 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(); + 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); + } +}