diff --git a/src/SharpCompress/Archives/IArchiveEntryExtensions.cs b/src/SharpCompress/Archives/IArchiveEntryExtensions.cs index 502a419e..c0e58656 100644 --- a/src/SharpCompress/Archives/IArchiveEntryExtensions.cs +++ b/src/SharpCompress/Archives/IArchiveEntryExtensions.cs @@ -108,15 +108,12 @@ public static class IArchiveEntryExtensions throw new ExtractionException("Entry is a file directory and cannot be extracted."); } -#if LEGACY_DOTNET - using var entryStream = await archiveEntry + var entryStream = await archiveEntry .OpenEntryStreamAsync(cancellationToken) .ConfigureAwait(false); -#else - await using var entryStream = await archiveEntry - .OpenEntryStreamAsync(cancellationToken) + await using var entryStreamScope = entryStream + .DisposeAsyncScope() .ConfigureAwait(false); -#endif var checkedStream = options is null ? entryStream : IEntryExtensions.WrapWithChecksumValidation(archiveEntry, entryStream, options); diff --git a/src/SharpCompress/Common/EntryStream.Async.cs b/src/SharpCompress/Common/EntryStream.Async.cs index a58afe70..b7112da7 100644 --- a/src/SharpCompress/Common/EntryStream.Async.cs +++ b/src/SharpCompress/Common/EntryStream.Async.cs @@ -17,7 +17,6 @@ public partial class EntryStream _completed = true; } -#if !LEGACY_DOTNET public override async ValueTask DisposeAsync() { if (_isDisposed) @@ -43,9 +42,8 @@ public partial class EntryStream } } await base.DisposeAsync().ConfigureAwait(false); - await _stream.DisposeAsync().ConfigureAwait(false); + await _stream.DisposeAsyncCompat().ConfigureAwait(false); } -#endif public override async Task ReadAsync( byte[] buffer, diff --git a/src/SharpCompress/Common/EntryStream.cs b/src/SharpCompress/Common/EntryStream.cs index b8ccf339..ed4e8fc6 100644 --- a/src/SharpCompress/Common/EntryStream.cs +++ b/src/SharpCompress/Common/EntryStream.cs @@ -8,7 +8,7 @@ using SharpCompress.Readers; namespace SharpCompress.Common; -public partial class EntryStream : Stream +public partial class EntryStream : AsyncDisposableStream { private readonly IReader _reader; private readonly Stream _stream; diff --git a/src/SharpCompress/Factories/GZipFactory.cs b/src/SharpCompress/Factories/GZipFactory.cs index bda6ef1f..7dfdd313 100644 --- a/src/SharpCompress/Factories/GZipFactory.cs +++ b/src/SharpCompress/Factories/GZipFactory.cs @@ -151,13 +151,16 @@ public class GZipFactory CompressionContext.FromStream(sharpCompressStream).WithReaderOptions(options) ) ); - if (TarArchive.IsTarFile(testStream)) + var isTarArchive = TarArchive.IsTarFile(testStream); + + // The TAR probe can consume arbitrary compressed input before it rejects a stream. + sharpCompressStream.Rewind(); + sharpCompressStream.StopRecording(); + if (isTarArchive) { - sharpCompressStream.StopRecording(); reader = new TarReader(sharpCompressStream, options, CompressionType.GZip); return true; } - sharpCompressStream.StopRecording(); reader = OpenReader(sharpCompressStream, options); return true; } @@ -182,15 +185,27 @@ public class GZipFactory } sharpCompressStream.Rewind(); - var tarReader = await new TarFactory() - .TryOpenReaderAsync(sharpCompressStream, options, cancellationToken) + using var testStream = SharpCompressStream.CreateNonDisposing( + await options + .Providers.CreateDecompressStreamAsync( + CompressionType.GZip, + SharpCompressStream.CreateNonDisposing(sharpCompressStream), + CompressionContext.FromStream(sharpCompressStream).WithReaderOptions(options), + cancellationToken + ) + .ConfigureAwait(false) + ); + var isTarArchive = await TarArchive + .IsTarFileAsync(testStream, cancellationToken) .ConfigureAwait(false); - if (tarReader is not null) + + sharpCompressStream.Rewind(); + sharpCompressStream.StopRecording(); + if (isTarArchive) { - return tarReader; + return new TarReader(sharpCompressStream, options, CompressionType.GZip); } - sharpCompressStream.StopRecording(); return await OpenAsyncReader(sharpCompressStream, options, cancellationToken) .ConfigureAwait(false); } diff --git a/src/SharpCompress/Factories/LzwFactory.cs b/src/SharpCompress/Factories/LzwFactory.cs index bd5569d1..098ec002 100644 --- a/src/SharpCompress/Factories/LzwFactory.cs +++ b/src/SharpCompress/Factories/LzwFactory.cs @@ -65,14 +65,17 @@ public class LzwFactory : Factory, IReaderFactory ) ) { - if (TarArchive.IsTarFile(testStream)) + var isTarArchive = TarArchive.IsTarFile(testStream); + + // The TAR probe can consume arbitrary compressed input before it rejects a stream. + sharpCompressStream.Rewind(); + sharpCompressStream.StopRecording(); + if (isTarArchive) { - sharpCompressStream.StopRecording(); reader = new TarReader(sharpCompressStream, options, CompressionType.Lzw); return true; } } - sharpCompressStream.StopRecording(); reader = OpenReader(sharpCompressStream, options); return true; } @@ -97,15 +100,26 @@ public class LzwFactory : Factory, IReaderFactory } sharpCompressStream.Rewind(); - var tarReader = await new TarFactory() - .TryOpenReaderAsync(sharpCompressStream, options, cancellationToken) + using var testStream = SharpCompressStream.CreateNonDisposing( + await options + .Providers.CreateDecompressStreamAsync( + CompressionType.Lzw, + SharpCompressStream.CreateNonDisposing(sharpCompressStream), + cancellationToken + ) + .ConfigureAwait(false) + ); + var isTarArchive = await TarArchive + .IsTarFileAsync(testStream, cancellationToken) .ConfigureAwait(false); - if (tarReader is not null) + + sharpCompressStream.Rewind(); + sharpCompressStream.StopRecording(); + if (isTarArchive) { - return tarReader; + return new TarReader(sharpCompressStream, options, CompressionType.Lzw); } - sharpCompressStream.StopRecording(); return await OpenAsyncReader(sharpCompressStream, options, cancellationToken) .ConfigureAwait(false); } diff --git a/src/SharpCompress/IO/AsyncDisposableStream.cs b/src/SharpCompress/IO/AsyncDisposableStream.cs new file mode 100644 index 00000000..4c7fafbe --- /dev/null +++ b/src/SharpCompress/IO/AsyncDisposableStream.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; +using System.Threading.Tasks; + +namespace SharpCompress.IO; + +/// +/// A that is guaranteed to be asynchronously disposable on every target framework. +/// +/// +/// +/// On .NET Framework 4.8 and .NET Standard 2.0, has no DisposeAsync. +/// Microsoft.Bcl.AsyncInterfaces supplies the interface on those +/// targets but cannot retrofit it onto the BCL's , and C# will not accept an +/// extension method for the pattern - await using requires a reachable instance +/// DisposeAsync. Deriving from this class instead of therefore makes a type +/// usable with await using uniformly, with no conditional compilation at the call site. +/// +/// +/// The fallback below is the same behaviour as the BCL's own default , +/// so a derived type may call await base.DisposeAsync() unconditionally on any target. +/// +/// +public abstract class AsyncDisposableStream : Stream +#if NO_STREAM_DISPOSEASYNC + , IAsyncDisposable +#endif +{ +#if NO_STREAM_DISPOSEASYNC + public virtual ValueTask DisposeAsync() + { + Dispose(); + return default; + } +#endif +} diff --git a/src/SharpCompress/IO/AsyncDisposeScope.cs b/src/SharpCompress/IO/AsyncDisposeScope.cs new file mode 100644 index 00000000..9d4538e4 --- /dev/null +++ b/src/SharpCompress/IO/AsyncDisposeScope.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; + +namespace SharpCompress.IO; + +/// +/// Makes any resource usable with await using, disposing it asynchronously when the runtime type +/// supports it and synchronously otherwise. +/// +/// +/// Needed for locals whose static type is (or another type that +/// only sometimes has DisposeAsync), where await using cannot bind directly on +/// .NET Framework 4.8 / .NET Standard 2.0. Unlike a compile-time guard, this picks the asynchronous path +/// based on the runtime type, so a stream that really is asynchronously disposable is disposed that way on +/// every target framework. Prefer deriving from where the type is ours. +/// +internal readonly struct AsyncDisposeScope(IDisposable? resource) : IAsyncDisposable +{ + public ValueTask DisposeAsync() + { + if (resource is IAsyncDisposable asyncDisposable) + { + return asyncDisposable.DisposeAsync(); + } + + resource?.Dispose(); + return default; + } + + /// + /// Mirrors ConfiguredAsyncDisposable so await using can specify context capture without + /// boxing this struct through . + /// + public ConfiguredAsyncDisposeScope ConfigureAwait(bool continueOnCapturedContext) => + new(resource, continueOnCapturedContext); +} diff --git a/src/SharpCompress/IO/ConfiguredAsyncDisposeScope.cs b/src/SharpCompress/IO/ConfiguredAsyncDisposeScope.cs new file mode 100644 index 00000000..9ee5e368 --- /dev/null +++ b/src/SharpCompress/IO/ConfiguredAsyncDisposeScope.cs @@ -0,0 +1,22 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +namespace SharpCompress.IO; + +internal readonly struct ConfiguredAsyncDisposeScope( + IDisposable? resource, + bool continueOnCapturedContext +) +{ + public ConfiguredValueTaskAwaitable DisposeAsync() + { + if (resource is IAsyncDisposable asyncDisposable) + { + return asyncDisposable.DisposeAsync().ConfigureAwait(continueOnCapturedContext); + } + + resource?.Dispose(); + return default(ValueTask).ConfigureAwait(continueOnCapturedContext); + } +} diff --git a/src/SharpCompress/Polyfills/StreamExtensions.cs b/src/SharpCompress/Polyfills/StreamExtensions.cs index c6e66b82..d4e80630 100644 --- a/src/SharpCompress/Polyfills/StreamExtensions.cs +++ b/src/SharpCompress/Polyfills/StreamExtensions.cs @@ -25,6 +25,20 @@ public static class StreamExtensions public void Skip() => stream.CopyTo(Stream.Null); + /// + /// Returns a scope that disposes this stream when awaited, asynchronously where the runtime type + /// supports it. Lets await using be written against a -typed local on + /// every target framework. + /// + internal AsyncDisposeScope DisposeAsyncScope() => new(stream); + + /// + /// Disposes this stream, asynchronously where the runtime type supports it. Use where the static + /// type is , which has no DisposeAsync on .NET Framework 4.8 / + /// .NET Standard 2.0. + /// + internal ValueTask DisposeAsyncCompat() => new AsyncDisposeScope(stream).DisposeAsync(); + public async ValueTask SkipAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/SharpCompress/Readers/AbstractReader.Async.cs b/src/SharpCompress/Readers/AbstractReader.Async.cs index 8628c0c2..969c73e6 100644 --- a/src/SharpCompress/Readers/AbstractReader.Async.cs +++ b/src/SharpCompress/Readers/AbstractReader.Async.cs @@ -104,13 +104,8 @@ public abstract partial class AbstractReader } } //don't know the size so we have to try to decompress to skip -#if LEGACY_DOTNET - using var s = await OpenEntryStreamAsync(cancellationToken).ConfigureAwait(false); - await s.SkipEntryAsync(cancellationToken).ConfigureAwait(false); -#else await using var s = await OpenEntryStreamAsync(cancellationToken).ConfigureAwait(false); await s.SkipEntryAsync(cancellationToken).ConfigureAwait(false); -#endif } public async ValueTask WriteEntryToAsync( @@ -139,19 +134,11 @@ public abstract partial class AbstractReader private async ValueTask WriteAsync(Stream writeStream, CancellationToken cancellationToken) { -#if LEGACY_DOTNET - using Stream s = await OpenEntryStreamAsync(cancellationToken).ConfigureAwait(false); + await using var s = await OpenEntryStreamAsync(cancellationToken).ConfigureAwait(false); var sourceStream = WrapWithProgress(s, Entry); await sourceStream .CopyToAsync(writeStream, Options.BufferSize, cancellationToken) .ConfigureAwait(false); -#else - await using Stream s = await OpenEntryStreamAsync(cancellationToken).ConfigureAwait(false); - var sourceStream = WrapWithProgress(s, Entry); - await sourceStream - .CopyToAsync(writeStream, Options.BufferSize, cancellationToken) - .ConfigureAwait(false); -#endif } public async ValueTask OpenEntryStreamAsync( diff --git a/src/SharpCompress/Readers/IAsyncReaderExtensions.cs b/src/SharpCompress/Readers/IAsyncReaderExtensions.cs index 7515b257..5b4e667b 100644 --- a/src/SharpCompress/Readers/IAsyncReaderExtensions.cs +++ b/src/SharpCompress/Readers/IAsyncReaderExtensions.cs @@ -108,15 +108,9 @@ public static class IAsyncReaderExtensions CancellationToken cancellationToken ) { -#if LEGACY_DOTNET - using var entryStream = await reader - .OpenEntryStreamAsync(cancellationToken) - .ConfigureAwait(false); -#else await using var entryStream = await reader .OpenEntryStreamAsync(cancellationToken) .ConfigureAwait(false); -#endif var checkedStream = IEntryExtensions.WrapWithChecksumValidation( reader.Entry, entryStream, diff --git a/src/SharpCompress/SharpCompress.csproj b/src/SharpCompress/SharpCompress.csproj index 991eccb9..c57cb722 100644 --- a/src/SharpCompress/SharpCompress.csproj +++ b/src/SharpCompress/SharpCompress.csproj @@ -31,6 +31,9 @@ $(DefineConstants);LEGACY_DOTNET + + $(DefineConstants);NO_STREAM_DISPOSEASYNC + true true diff --git a/tests/SharpCompress.Test/BZip2/BZip2StreamAsyncTests.cs b/tests/SharpCompress.Test/BZip2/BZip2StreamAsyncTests.cs index d5b2ef78..fc7e649c 100644 --- a/tests/SharpCompress.Test/BZip2/BZip2StreamAsyncTests.cs +++ b/tests/SharpCompress.Test/BZip2/BZip2StreamAsyncTests.cs @@ -151,19 +151,8 @@ public class BZip2StreamAsyncTests Assert.True(compressed.Length > 0); // Decompress and verify -#if LEGACY_DOTNET + // MemoryStream has nothing to dispose asynchronously using (var readStream = new MemoryStream(compressed)) - { - using ( - var bzip2Stream = await BZip2Stream.CreateAsync( - new AsyncOnlyStream(readStream), - SharpCompress.Compressors.CompressionMode.Decompress, - false - ) - ) - { -#else - await using (var readStream = new MemoryStream(compressed)) { await using ( var bzip2Stream = await BZip2Stream.CreateAsync( @@ -173,7 +162,6 @@ public class BZip2StreamAsyncTests ) ) { -#endif var result = new StringBuilder(); var buffer = new byte[256]; int bytesRead; diff --git a/tests/SharpCompress.Test/GZip/GZipCrcExtractionTests.cs b/tests/SharpCompress.Test/GZip/GZipCrcExtractionTests.cs index 2e98c962..acabcb4b 100644 --- a/tests/SharpCompress.Test/GZip/GZipCrcExtractionTests.cs +++ b/tests/SharpCompress.Test/GZip/GZipCrcExtractionTests.cs @@ -68,11 +68,8 @@ public class GZipCrcExtractionTests : TestBase [Fact] public async Task GZipArchive_WriteToFileAsync_Throws_On_Crc_Mismatch() { -#if LEGACY_DOTNET + // MemoryStream has nothing to dispose asynchronously using var stream = new MemoryStream(ReadCorruptedGZipTrailer(corruptCrc: true)); -#else - await using var stream = new MemoryStream(ReadCorruptedGZipTrailer(corruptCrc: true)); -#endif await using var archive = await GZipArchive.OpenAsyncArchive(stream); var entry = await archive.EntriesAsync.SingleAsync(); var destination = Path.Combine(SCRATCH_FILES_PATH, Guid.NewGuid().ToString()); diff --git a/tests/SharpCompress.Test/LargeArchiveTests.cs b/tests/SharpCompress.Test/LargeArchiveTests.cs new file mode 100644 index 00000000..84fb0f7f --- /dev/null +++ b/tests/SharpCompress.Test/LargeArchiveTests.cs @@ -0,0 +1,208 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Crypto; +using SharpCompress.Readers; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test; + +[Collection(LargeArchiveCollection.Name)] +public class LargeArchiveTests : TestBase +{ + private const long LargeFileSize = 64L * 1024 * 1024; + private const uint LargeFileCrc = 0xF9081EB0; + private const int BufferSize = 64 * 1024; + + [Theory] + [InlineData("Large/Large.zip")] + [InlineData("Large/Large.tar")] + [InlineData("Large/Large.gz")] + [InlineData("Large/Large.rar")] + [InlineData("Large/Large.7z")] + public void OpenArchive_ShouldStreamLargeEntry(string fixtureName) + { + using var stream = File.OpenRead(GetMaterializedFixturePath(fixtureName)); + using var archive = ArchiveFactory.OpenArchive(stream); + + VerifyArchive(archive); + } + + [Theory] + [InlineData("Large/Large.zip")] + [InlineData("Large/Large.tar")] + [InlineData("Large/Large.gz")] + [InlineData("Large/Large.rar")] + [InlineData("Large/Large.7z")] + public async Task OpenAsyncArchive_ShouldStreamLargeEntry(string fixtureName) + { + await using var stream = new AsyncOnlyStream( + File.OpenRead(await GetMaterializedFixturePathAsync(fixtureName)) + ); + await using var archive = await ArchiveFactory.OpenAsyncArchive(stream); + + var entry = await GetSingleEntryAsync(archive); + + var entryStream = await entry.OpenEntryStreamAsync(); + await using var entryStreamScope = entryStream.DisposeAsyncScope(); + await VerifyContentAsync(entry.Key, entryStream); + } + + [Theory] + [InlineData("Large/Large.zip")] + [InlineData("Large/Large.tar")] + [InlineData("Large/Large.gz")] + [InlineData("Large/Large.rar")] + [InlineData("Large/Large.tar.gz")] + public void OpenReader_ShouldStreamLargeEntry(string fixtureName) + { + using var stream = File.OpenRead(GetMaterializedFixturePath(fixtureName)); + using var reader = ReaderFactory.OpenReader(stream); + + VerifyReader(reader); + } + + [Theory] + [InlineData("Large/Large.zip")] + [InlineData("Large/Large.tar")] + [InlineData("Large/Large.gz")] + [InlineData("Large/Large.rar")] + [InlineData("Large/Large.tar.gz")] + public async Task OpenAsyncReader_ShouldStreamLargeEntry(string fixtureName) + { + await using var stream = new AsyncOnlyStream( + File.OpenRead(await GetMaterializedFixturePathAsync(fixtureName)) + ); + await using var reader = await ReaderFactory.OpenAsyncReader(stream); + + Assert.True(await reader.MoveToNextEntryAsync()); + Assert.False(reader.Entry.IsDirectory); + + await using var entryStream = await reader.OpenEntryStreamAsync(); + await VerifyContentAsync(reader.Entry.Key, entryStream); + Assert.False(await reader.MoveToNextEntryAsync()); + } + + [Fact] + public async Task OpenAsyncReader_WithGZipExtensionHint_ShouldStreamLargeTarEntry() + { + using var file = File.OpenRead(GetFixturePath("Large/Large.tar.gz")); + await using var stream = new AsyncOnlyStream(new ForwardOnlyStream(file)); + var options = new ReaderOptions { ExtensionHint = "gz" }; + + await using var reader = await ReaderFactory.OpenAsyncReader(stream, options); + + Assert.True(await reader.MoveToNextEntryAsync()); + Assert.False(reader.Entry.IsDirectory); + await using var entryStream = await reader.OpenEntryStreamAsync(); + await VerifyContentAsync(reader.Entry.Key, entryStream); + Assert.False(await reader.MoveToNextEntryAsync()); + } + + private static string GetFixturePath(string fixtureName) => + Path.Combine(TEST_ARCHIVES_PATH, fixtureName); + + private string GetMaterializedFixturePath(string fixtureName) => + fixtureName == "Large/Large.tar" ? MaterializeTarFixture() : GetFixturePath(fixtureName); + + private async Task GetMaterializedFixturePathAsync(string fixtureName) => + fixtureName == "Large/Large.tar" + ? await MaterializeTarFixtureAsync() + : GetFixturePath(fixtureName); + + private string MaterializeTarFixture() + { + var tarPath = Path.Combine(SCRATCH_FILES_PATH, "Large.tar"); + using var compressedStream = File.OpenRead(GetFixturePath("Large/Large.tar.gz")); + using var gzipStream = new GZipStream(compressedStream, CompressionMode.Decompress); + using var tarStream = File.Create(tarPath); + gzipStream.CopyTo(tarStream); + return tarPath; + } + + private async Task MaterializeTarFixtureAsync() + { + var tarPath = Path.Combine(SCRATCH_FILES_PATH, "Large.tar"); + using var compressedStream = File.OpenRead(GetFixturePath("Large/Large.tar.gz")); + using var gzipStream = new GZipStream(compressedStream, CompressionMode.Decompress); + using var tarStream = File.Create(tarPath); + await gzipStream.CopyToAsync(tarStream); + return tarPath; + } + + private static void VerifyArchive(IArchive archive) + { + var entry = Assert.Single(archive.Entries); + + Assert.False(entry.IsDirectory); + using var entryStream = entry.OpenEntryStream(); + VerifyContent(entry.Key, entryStream); + } + + private static async Task GetSingleEntryAsync(IAsyncArchive archive) + { + IArchiveEntry? entry = null; + await foreach (var candidate in archive.EntriesAsync) + { + Assert.Null(entry); + entry = candidate; + } + + return entry ?? throw new InvalidOperationException("The archive contains no entries."); + } + + private static void VerifyReader(IReader reader) + { + Assert.True(reader.MoveToNextEntry()); + Assert.False(reader.Entry.IsDirectory); + using var entryStream = reader.OpenEntryStream(); + VerifyContent(reader.Entry.Key, entryStream); + Assert.False(reader.MoveToNextEntry()); + } + + private static void VerifyContent(string? key, Stream entryStream) + { + Assert.Equal("large.bin", key); + + using var crcStream = new Crc32Stream(Stream.Null); + var buffer = new byte[BufferSize]; + long length = 0; + int bytesRead; + while ((bytesRead = entryStream.Read(buffer, 0, buffer.Length)) > 0) + { + crcStream.Write(buffer, 0, bytesRead); + length += bytesRead; + } + + Assert.Equal(LargeFileSize, length); + Assert.Equal(LargeFileCrc, crcStream.Crc); + } + + private static async Task VerifyContentAsync(string? key, Stream entryStream) + { + Assert.Equal("large.bin", key); + + using var crcStream = new Crc32Stream(Stream.Null); + var buffer = new byte[BufferSize]; + long length = 0; + int bytesRead; + while ((bytesRead = await entryStream.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + await crcStream.WriteAsync(buffer, 0, bytesRead); + length += bytesRead; + } + + Assert.Equal(LargeFileSize, length); + Assert.Equal(LargeFileCrc, crcStream.Crc); + } +} + +[CollectionDefinition(LargeArchiveCollection.Name, DisableParallelization = true)] +public sealed class LargeArchiveCollection +{ + public const string Name = "Large archive fixtures"; +} diff --git a/tests/SharpCompress.Test/Mocks/AsyncOnlyStream.cs b/tests/SharpCompress.Test/Mocks/AsyncOnlyStream.cs index 232f60ee..9dec5e5a 100644 --- a/tests/SharpCompress.Test/Mocks/AsyncOnlyStream.cs +++ b/tests/SharpCompress.Test/Mocks/AsyncOnlyStream.cs @@ -2,10 +2,11 @@ using System; using System.IO; using System.Threading; using System.Threading.Tasks; +using SharpCompress.IO; namespace SharpCompress.Test.Mocks; -public class AsyncOnlyStream(Stream stream, bool disposeStream = true) : Stream +public class AsyncOnlyStream(Stream stream, bool disposeStream = true) : AsyncDisposableStream { private readonly Stream _stream = stream ?? throw new ArgumentNullException(nameof(stream)); diff --git a/tests/SharpCompress.Test/Mocks/FlushOnDisposeStream.cs b/tests/SharpCompress.Test/Mocks/FlushOnDisposeStream.cs index 63f20702..28dff313 100644 --- a/tests/SharpCompress.Test/Mocks/FlushOnDisposeStream.cs +++ b/tests/SharpCompress.Test/Mocks/FlushOnDisposeStream.cs @@ -1,6 +1,7 @@ using System; using System.IO; using System.Threading.Tasks; +using SharpCompress.IO; namespace SharpCompress.Test.Mocks; @@ -8,7 +9,7 @@ namespace SharpCompress.Test.Mocks; // CryptoStream doesn't always trigger the Flush, so this class is used instead // See https://referencesource.microsoft.com/#mscorlib/system/security/cryptography/cryptostream.cs,141 -public class FlushOnDisposeStream(Stream innerStream) : Stream +public class FlushOnDisposeStream(Stream innerStream) : AsyncDisposableStream { public override bool CanRead => innerStream.CanRead; @@ -48,12 +49,10 @@ public class FlushOnDisposeStream(Stream innerStream) : Stream base.Dispose(disposing); } -#if !LEGACY_DOTNET public override async ValueTask DisposeAsync() { await innerStream.FlushAsync(); innerStream.Close(); await base.DisposeAsync(); } -#endif } diff --git a/tests/SharpCompress.Test/Mocks/ForwardOnlyStream.cs b/tests/SharpCompress.Test/Mocks/ForwardOnlyStream.cs index 32961ebc..f6b7ec8e 100644 --- a/tests/SharpCompress.Test/Mocks/ForwardOnlyStream.cs +++ b/tests/SharpCompress.Test/Mocks/ForwardOnlyStream.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Threading; using System.Threading.Tasks; +using SharpCompress.IO; namespace SharpCompress.Test.Mocks; @@ -9,7 +10,7 @@ namespace SharpCompress.Test.Mocks; /// A forward-only stream wrapper that delegates directly to the underlying stream /// without any buffering. Supports reading and writing but not seeking. /// -public class ForwardOnlyStream : Stream +public class ForwardOnlyStream : AsyncDisposableStream { private readonly Stream _stream; private bool _isDisposed; @@ -142,17 +143,15 @@ public class ForwardOnlyStream : Stream } } -#if !LEGACY_DOTNET public override async ValueTask DisposeAsync() { if (!_isDisposed) { - await _stream.DisposeAsync(); + await _stream.DisposeAsyncCompat(); _isDisposed = true; } await base.DisposeAsync(); } -#endif private void ThrowIfDisposed() { diff --git a/tests/SharpCompress.Test/Mocks/TestStream.cs b/tests/SharpCompress.Test/Mocks/TestStream.cs index d3ff48a8..5061afc3 100644 --- a/tests/SharpCompress.Test/Mocks/TestStream.cs +++ b/tests/SharpCompress.Test/Mocks/TestStream.cs @@ -2,10 +2,11 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using SharpCompress.IO; namespace SharpCompress.Test.Mocks; -public class TestStream(Stream stream, bool read, bool write, bool seek) : Stream +public class TestStream(Stream stream, bool read, bool write, bool seek) : AsyncDisposableStream { public TestStream(Stream stream) : this(stream, stream.CanRead, stream.CanWrite, stream.CanSeek) { } @@ -50,14 +51,14 @@ public class TestStream(Stream stream, bool read, bool write, bool seek) : Strea Memory buffer, CancellationToken cancellationToken = default ) => stream.ReadAsync(buffer, cancellationToken); +#endif public override async ValueTask DisposeAsync() { await base.DisposeAsync(); - await stream.DisposeAsync(); + await stream.DisposeAsyncCompat(); IsDisposed = true; } -#endif public override long Seek(long offset, SeekOrigin origin) => stream.Seek(offset, origin); diff --git a/tests/SharpCompress.Test/Mocks/ThrowOnFlushStream.cs b/tests/SharpCompress.Test/Mocks/ThrowOnFlushStream.cs index 2cf1a84c..911762a5 100644 --- a/tests/SharpCompress.Test/Mocks/ThrowOnFlushStream.cs +++ b/tests/SharpCompress.Test/Mocks/ThrowOnFlushStream.cs @@ -2,6 +2,7 @@ using System; using System.IO; using System.Threading; using System.Threading.Tasks; +using SharpCompress.IO; namespace SharpCompress.Test.Mocks; @@ -9,7 +10,7 @@ namespace SharpCompress.Test.Mocks; /// A stream wrapper that throws NotSupportedException on Flush() calls. /// This is used to test that archive iteration handles streams that don't support flushing. /// -public class ThrowOnFlushStream : Stream +public class ThrowOnFlushStream : AsyncDisposableStream { private readonly Stream inner; diff --git a/tests/SharpCompress.Test/Mocks/TruncatedStream.cs b/tests/SharpCompress.Test/Mocks/TruncatedStream.cs index 8699a430..55037f2e 100644 --- a/tests/SharpCompress.Test/Mocks/TruncatedStream.cs +++ b/tests/SharpCompress.Test/Mocks/TruncatedStream.cs @@ -1,5 +1,6 @@ using System; using System.IO; +using SharpCompress.IO; namespace SharpCompress.Test.Mocks; @@ -7,7 +8,7 @@ namespace SharpCompress.Test.Mocks; /// A stream wrapper that truncates the underlying stream after reading a specified number of bytes. /// Used for testing error handling when streams end prematurely. /// -public class TruncatedStream : Stream +public class TruncatedStream : AsyncDisposableStream { private readonly Stream baseStream; private readonly long truncateAfterBytes; diff --git a/tests/SharpCompress.Test/ReaderTests.cs b/tests/SharpCompress.Test/ReaderTests.cs index 4c9024c9..64469219 100644 --- a/tests/SharpCompress.Test/ReaderTests.cs +++ b/tests/SharpCompress.Test/ReaderTests.cs @@ -159,18 +159,12 @@ public abstract class ReaderTests : TestBase { using var file = File.OpenRead(testArchive); -#if !LEGACY_DOTNET - await using var protectedStream = SharpCompressStream.CreateNonDisposing( + // SharpCompressStream is not yet an AsyncDisposableStream, so scope its disposal + var protectedStream = SharpCompressStream.CreateNonDisposing( new ForwardOnlyStream(file, options.BufferSize) ); + await using var protectedStreamScope = protectedStream.DisposeAsyncScope(); await using var testStream = new TestStream(protectedStream); -#else - - using var protectedStream = SharpCompressStream.CreateNonDisposing( - new ForwardOnlyStream(file, options.BufferSize) - ); - using var testStream = new TestStream(protectedStream); -#endif await using ( var reader = await ReaderFactory.OpenAsyncReader( new AsyncOnlyStream(testStream), diff --git a/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs b/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs index 3a1b95cc..dabc0fa2 100644 --- a/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs +++ b/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs @@ -187,10 +187,6 @@ public class TarArchiveAsyncTests : ArchiveTests } } } -#if LEGACY_DOTNET - //add a delay because old .net sucks on DisposeAsync - await Task.Delay(TimeSpan.FromSeconds(1)); -#endif } [Fact] @@ -338,11 +334,8 @@ public class TarArchiveAsyncTests : ArchiveTests { ++numberOfEntries; -#if LEGACY_DOTNET - using var tarEntryStream = await entry.OpenEntryStreamAsync(); -#else - await using var tarEntryStream = await entry.OpenEntryStreamAsync(); -#endif + var tarEntryStream = await entry.OpenEntryStreamAsync(); + await using var tarEntryStreamScope = tarEntryStream.DisposeAsyncScope(); using var testFileStream = new MemoryStream(); await tarEntryStream.CopyToAsync(testFileStream); Assert.Equal(testBytes.Length, testFileStream.Length); diff --git a/tests/SharpCompress.Test/Tar/TarReaderAsyncTests.cs b/tests/SharpCompress.Test/Tar/TarReaderAsyncTests.cs index 90bce78e..75400926 100644 --- a/tests/SharpCompress.Test/Tar/TarReaderAsyncTests.cs +++ b/tests/SharpCompress.Test/Tar/TarReaderAsyncTests.cs @@ -47,22 +47,38 @@ public class TarReaderAsyncTests : ReaderTests await ReadAsync("Tar.tar.Z", CompressionType.Lzw); [Theory] - [InlineData("Tar.tar.gz", "gz", CompressionType.GZip)] - [InlineData("Tar.tar.Z", "z", CompressionType.Lzw)] + [InlineData("Tar.tar.gz", "gz", CompressionType.GZip, false)] + [InlineData("Tar.tar.gz", "gz", CompressionType.GZip, true)] + [InlineData("Tar.tar.Z", "z", CompressionType.Lzw, false)] + [InlineData("Tar.tar.Z", "z", CompressionType.Lzw, true)] public async ValueTask ReaderFactory_ExtensionHint_PreservesCompressedTarDetection_Async( string archiveName, string extensionHint, - CompressionType compressionType + CompressionType compressionType, + bool useForwardOnlyStream ) { - using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archiveName)); + using var file = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, archiveName)); + Stream source = useForwardOnlyStream ? new ForwardOnlyStream(file) : file; + await using var stream = new AsyncOnlyStream(source); var options = ReaderOptions.ForExternalStream.WithExtensionHint(extensionHint); await using var reader = await ReaderFactory.OpenAsyncReader(stream, options); Assert.Equal(ArchiveType.Tar, reader.Type); - Assert.True(await reader.MoveToNextEntryAsync()); - Assert.Equal(compressionType, reader.Entry.CompressionType); + var entryCount = 0; + while (await reader.MoveToNextEntryAsync()) + { + entryCount++; + Assert.Equal(compressionType, reader.Entry.CompressionType); + if (!reader.Entry.IsDirectory) + { + await reader.WriteEntryToDirectoryAsync(SCRATCH_FILES_PATH); + } + } + + Assert.True(entryCount > 0); + VerifyFiles(); } [Fact] @@ -337,13 +353,8 @@ public class TarReaderAsyncTests : ReaderTests Assert.True(await reader.MoveToNextEntryAsync()); Assert.Equal("inner.tar.gz", reader.Entry.Key); -#if !LEGACY_DOTNET await using var entryStream = await reader.OpenEntryStreamAsync(); await using var flushingStream = new FlushOnDisposeStream(entryStream); -#else - using var entryStream = await reader.OpenEntryStreamAsync(); - using var flushingStream = new FlushOnDisposeStream(entryStream); -#endif // Extract inner.tar.gz await using var innerReader = await ReaderFactory.OpenAsyncReader(flushingStream); diff --git a/tests/SharpCompress.Test/Tar/TarWriterNonSeekableTests.cs b/tests/SharpCompress.Test/Tar/TarWriterNonSeekableTests.cs index 454c20a6..218533db 100644 --- a/tests/SharpCompress.Test/Tar/TarWriterNonSeekableTests.cs +++ b/tests/SharpCompress.Test/Tar/TarWriterNonSeekableTests.cs @@ -89,11 +89,8 @@ public class TarWriterNonSeekableTests { var entry = fileEntries.Single(e => e.Key == name); using var extracted = new MemoryStream(); -#if LEGACY_DOTNET - using (var entryStream = await entry.OpenEntryStreamAsync()) -#else - await using (var entryStream = await entry.OpenEntryStreamAsync()) -#endif + var entryStream = await entry.OpenEntryStreamAsync(); + await using (entryStream.DisposeAsyncScope()) { await entryStream.CopyToAsync(extracted); } diff --git a/tests/SharpCompress.Test/Zip/Zip64AsyncTests.cs b/tests/SharpCompress.Test/Zip/Zip64AsyncTests.cs index 5124b6b0..626265cd 100644 --- a/tests/SharpCompress.Test/Zip/Zip64AsyncTests.cs +++ b/tests/SharpCompress.Test/Zip/Zip64AsyncTests.cs @@ -198,11 +198,7 @@ public class Zip64AsyncTests : WriterTests count++; lastKey = rd.Entry.Key; -#if LEGACY_DOTNET - using var entryStream = await rd.OpenEntryStreamAsync(); -#else await using var entryStream = await rd.OpenEntryStreamAsync(); -#endif if (rd.Entry.Key == "small") { using var ms = new MemoryStream(); @@ -337,17 +333,10 @@ public class Zip64AsyncTests : WriterTests ); while (await rd.MoveToNextEntryAsync()) { -#if LEGACY_DOTNET - using (var entryStream = await rd.OpenEntryStreamAsync()) - { - await entryStream.SkipEntryAsync(); - } -#else await using (var entryStream = await rd.OpenEntryStreamAsync()) { await entryStream.SkipEntryAsync(); } -#endif count++; if (prev != null) { diff --git a/tests/SharpCompress.Test/Zip/ZipCrcExtractionTests.cs b/tests/SharpCompress.Test/Zip/ZipCrcExtractionTests.cs index cd0e610c..4a61b150 100644 --- a/tests/SharpCompress.Test/Zip/ZipCrcExtractionTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipCrcExtractionTests.cs @@ -122,11 +122,8 @@ public class ZipCrcExtractionTests : ArchiveTests using var zipStream = CreateZipWithInvalidCrc(useDataDescriptor: false); using var archive = ZipArchive.OpenArchive(zipStream); var entry = archive.Entries.Single(e => !e.IsDirectory); -#if LEGACY_DOTNET + // MemoryStream has nothing to dispose asynchronously using var destination = new MemoryStream(); -#else - await using var destination = new MemoryStream(); -#endif var exception = await Assert.ThrowsAsync(async () => await entry.WriteToAsync(destination, new ExtractionOptions { CheckCrc = true }) @@ -141,11 +138,8 @@ public class ZipCrcExtractionTests : ArchiveTests using var zipStream = CreateZipWithInvalidCrc(useDataDescriptor: false); using var archive = ZipArchive.OpenArchive(zipStream); var entry = archive.Entries.Single(e => !e.IsDirectory); -#if LEGACY_DOTNET + // MemoryStream has nothing to dispose asynchronously using var destination = new MemoryStream(); -#else - await using var destination = new MemoryStream(); -#endif await entry.WriteToAsync(destination, new ExtractionOptions { CheckCrc = false }); diff --git a/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs b/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs index 4c7cd627..78f70b28 100644 --- a/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipReaderAsyncTests.cs @@ -315,11 +315,7 @@ public class ZipReaderAsyncTests : ReaderTests { if (!reader.Entry.IsDirectory) { -#if LEGACY_DOTNET - using var entryStream = await reader.OpenEntryStreamAsync(); -#else await using var entryStream = await reader.OpenEntryStreamAsync(); -#endif // Read some data var buffer = new byte[1024]; await entryStream.ReadAsync(buffer, 0, buffer.Length); @@ -342,11 +338,7 @@ public class ZipReaderAsyncTests : ReaderTests { if (!reader.Entry.IsDirectory) { -#if LEGACY_DOTNET - using var entryStream = await reader.OpenEntryStreamAsync(); -#else await using var entryStream = await reader.OpenEntryStreamAsync(); -#endif // Read some data var buffer = new byte[1024]; await entryStream.ReadAsync(buffer, 0, buffer.Length); diff --git a/tests/SharpCompress.Test/Zip/ZipWriterNonSeekableTests.cs b/tests/SharpCompress.Test/Zip/ZipWriterNonSeekableTests.cs index 4dd10b57..95cdcc56 100644 --- a/tests/SharpCompress.Test/Zip/ZipWriterNonSeekableTests.cs +++ b/tests/SharpCompress.Test/Zip/ZipWriterNonSeekableTests.cs @@ -163,11 +163,8 @@ public class ZipWriterNonSeekableTests { var entry = archive.Entries.Single(e => e.Key == name); using var extracted = new MemoryStream(); -#if LEGACY_DOTNET - using (var entryStream = await entry.OpenEntryStreamAsync()) -#else - await using (var entryStream = await entry.OpenEntryStreamAsync()) -#endif + var entryStream = await entry.OpenEntryStreamAsync(); + await using (entryStream.DisposeAsyncScope()) { await entryStream.CopyToAsync(extracted); } diff --git a/tests/TestArchives/Archives/Large/Large.7z b/tests/TestArchives/Archives/Large/Large.7z new file mode 100644 index 00000000..719ea18d Binary files /dev/null and b/tests/TestArchives/Archives/Large/Large.7z differ diff --git a/tests/TestArchives/Archives/Large/Large.gz b/tests/TestArchives/Archives/Large/Large.gz new file mode 100644 index 00000000..7401121e Binary files /dev/null and b/tests/TestArchives/Archives/Large/Large.gz differ diff --git a/tests/TestArchives/Archives/Large/Large.rar b/tests/TestArchives/Archives/Large/Large.rar new file mode 100644 index 00000000..6f0a65d4 Binary files /dev/null and b/tests/TestArchives/Archives/Large/Large.rar differ diff --git a/tests/TestArchives/Archives/Large/Large.tar.gz b/tests/TestArchives/Archives/Large/Large.tar.gz new file mode 100644 index 00000000..007fef08 Binary files /dev/null and b/tests/TestArchives/Archives/Large/Large.tar.gz differ diff --git a/tests/TestArchives/Archives/Large/Large.zip b/tests/TestArchives/Archives/Large/Large.zip new file mode 100644 index 00000000..5b07336f Binary files /dev/null and b/tests/TestArchives/Archives/Large/Large.zip differ diff --git a/tests/TestArchives/Archives/Large/README.md b/tests/TestArchives/Archives/Large/README.md new file mode 100644 index 00000000..0797db5d --- /dev/null +++ b/tests/TestArchives/Archives/Large/README.md @@ -0,0 +1,37 @@ +# Large Test Archives + +Each fixture contains one `large.bin` entry with a 67,108,864-byte (64 MiB) +deterministic, repeated text pattern with a 2 KiB `0xFF` prefix. The entry CRC-32 is +`f9081eb0`. + +| Fixture | Archive API | Reader API | +| --- | --- | --- | +| `Large.zip` | Yes | Yes | +| Generated `Large.tar` | Yes | Yes | +| `Large.gz` | Yes | Yes | +| `Large.rar` | Yes | Yes | +| `Large.7z` | Yes | No | +| `Large.tar.gz` | No | Yes | + +The compressible payload keeps the compressed fixtures small while requiring a full +64 MiB decompression to validate each API. The tests expand `Large.tar.gz` to a scratch +`Large.tar` before exercising raw TAR support, so the 64 MiB TAR file is not committed. + +## Regenerating + +The fixtures were created with `zip`, `tar`, `gzip`, RAR 7.22, and 7-Zip. Run these +commands from a temporary directory after replacing `` with the repository root: + +```sh +yes "SharpCompress large fixture" | head -c 67108864 > large.bin +printf '\377%.0s' {1..2048} > prefix.bin +dd if=prefix.bin of=large.bin bs=2048 count=1 conv=notrunc +touch -t 202001010000 large.bin +mkdir -p /tests/TestArchives/Archives/Large +zip -X -9 -j /tests/TestArchives/Archives/Large/Large.zip large.bin +COPYFILE_DISABLE=1 tar -cf large.tar large.bin +gzip -9 -c large.bin > /tests/TestArchives/Archives/Large/Large.gz +gzip -n -9 -c large.tar > /tests/TestArchives/Archives/Large/Large.tar.gz +rar a -ma5 -m5 -ep /tests/TestArchives/Archives/Large/Large.rar large.bin +7z a -t7z -mx=9 /tests/TestArchives/Archives/Large/Large.7z large.bin +```