diff --git a/.github/workflows/nuget-release.yml b/.github/workflows/nuget-release.yml index de4a366a0..ccc626cc6 100644 --- a/.github/workflows/nuget-release.yml +++ b/.github/workflows/nuget-release.yml @@ -45,6 +45,12 @@ jobs: # Build and test - name: Build and Test run: dotnet run --project build/build.csproj + + - name: Validate AOT Smoke Test + if: matrix.os == 'ubuntu-latest' + run: | + dotnet publish tests/SharpCompress.AotSmoke/SharpCompress.AotSmoke.csproj --configuration Release --runtime linux-x64 --self-contained true --output artifacts/aot-smoke + ./artifacts/aot-smoke/SharpCompress.AotSmoke # Upload artifacts for verification - name: Upload NuGet Package diff --git a/src/SharpCompress/packages.lock.json b/src/SharpCompress/packages.lock.json index 738df5991..e1e7afa51 100644 --- a/src/SharpCompress/packages.lock.json +++ b/src/SharpCompress/packages.lock.json @@ -286,9 +286,9 @@ "net10.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.6, )", - "resolved": "10.0.6", - "contentHash": "QKuvS0LWX4fjFqeDkyM7Kqt8P3wYTiPD4nwU+9y59n0sCiG714fxDgbbN82vDnzq89AF/PiHl92TP2C4aFDUQA==" + "requested": "[10.0.8, )", + "resolved": "10.0.8", + "contentHash": "dVbSXGIFNR5nZcv2tOLoWI+a9T4jtFd77IYjuND+QVe360qWgAF7H0WtoopYhRw/+SgpGUTyrkrh+65+ClNnfw==" }, "Microsoft.NETFramework.ReferenceAssemblies": { "type": "Direct", @@ -388,9 +388,9 @@ "net8.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[8.0.26, )", - "resolved": "8.0.26", - "contentHash": "o7/yVssM2r9Wyln2s9edBd5ANZXqdSdBI+g7JqXkyJmXrhs2WsJp25K5yPnYrTgdKBCjKB8bg+O2oew4sgzFaA==" + "requested": "[8.0.27, )", + "resolved": "8.0.27", + "contentHash": "rQi9TxifHRnXP7lVRZH05DxD2/XGbJp12q0ozcbrlBlBnyyzssFTH/2vLhtKWUp2CT1qVscTrcYTFiwTyKPKRg==" }, "Microsoft.NETFramework.ReferenceAssemblies": { "type": "Direct", diff --git a/tests/SharpCompress.AotSmoke/Program.cs b/tests/SharpCompress.AotSmoke/Program.cs new file mode 100644 index 000000000..2bdbabe6c --- /dev/null +++ b/tests/SharpCompress.AotSmoke/Program.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using System.Text; +using SharpCompress.Archives; +using SharpCompress.Common; +using SharpCompress.Readers; +using SharpCompress.Writers; + +var original = "SharpCompress AOT smoke test"; +using var archiveStream = new MemoryStream(); + +using ( + var writer = WriterFactory.OpenWriter( + archiveStream, + ArchiveType.Zip, + new WriterOptions(CompressionType.Deflate) { LeaveStreamOpen = true } + ) +) +{ + using var entryStream = new MemoryStream(Encoding.UTF8.GetBytes(original)); + writer.Write("payload.txt", entryStream, DateTime.UtcNow); +} + +archiveStream.Position = 0; +using (var reader = ReaderFactory.OpenReader(archiveStream, ReaderOptions.ForExternalStream)) +{ + if (!reader.MoveToNextEntry() || reader.Entry.IsDirectory) + { + throw new InvalidOperationException("Expected a file entry."); + } + + using var extracted = new MemoryStream(); + reader.WriteEntryTo(extracted); + var actual = Encoding.UTF8.GetString(extracted.ToArray()); + if (!string.Equals(original, actual, StringComparison.Ordinal)) + { + throw new InvalidOperationException("ReaderFactory round-trip content mismatch."); + } +} + +archiveStream.Position = 0; +using (var archive = ArchiveFactory.OpenArchive(archiveStream, ReaderOptions.ForExternalStream)) +{ + var entryCount = 0; + foreach (var entry in archive.Entries) + { + if (!entry.IsDirectory) + { + entryCount++; + } + } + + if (entryCount != 1) + { + throw new InvalidOperationException("ArchiveFactory did not see the expected entry."); + } +} + +Console.WriteLine("SharpCompress AOT smoke test passed."); diff --git a/tests/SharpCompress.AotSmoke/SharpCompress.AotSmoke.csproj b/tests/SharpCompress.AotSmoke/SharpCompress.AotSmoke.csproj new file mode 100644 index 000000000..c6f6314b4 --- /dev/null +++ b/tests/SharpCompress.AotSmoke/SharpCompress.AotSmoke.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + true + true + full + + + + + diff --git a/tests/SharpCompress.AotSmoke/packages.lock.json b/tests/SharpCompress.AotSmoke/packages.lock.json new file mode 100644 index 000000000..74a5d8021 --- /dev/null +++ b/tests/SharpCompress.AotSmoke/packages.lock.json @@ -0,0 +1,84 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.6, )", + "resolved": "10.0.6", + "contentHash": "nBOzxOys8OeyJ+Nsi/uYlI/5TSsvwjaM/p5m4dTL6khCLx9UuP3b2ec3HeuBw/+F7hHCAZG1yFx8VBeoRAX+EQ==" + }, + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[10.0.6, )", + "resolved": "10.0.6", + "contentHash": "QKuvS0LWX4fjFqeDkyM7Kqt8P3wYTiPD4nwU+9y59n0sCiG714fxDgbbN82vDnzq89AF/PiHl92TP2C4aFDUQA==" + }, + "Microsoft.NETFramework.ReferenceAssemblies": { + "type": "Direct", + "requested": "[1.0.3, )", + "resolved": "1.0.3", + "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", + "dependencies": { + "Microsoft.NETFramework.ReferenceAssemblies.net461": "1.0.3" + } + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.102, )", + "resolved": "10.0.102", + "contentHash": "Oxq3RCIJSdtpIU4hLqO7XaDe/Ra3HS9Wi8rJl838SAg6Zu1iQjerA0+xXWBgUFYbgknUGCLOU0T+lzMLkvY9Qg==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.102", + "Microsoft.SourceLink.Common": "10.0.102" + } + }, + "Microsoft.VisualStudio.Threading.Analyzers": { + "type": "Direct", + "requested": "[17.14.15, )", + "resolved": "17.14.15", + "contentHash": "mXQPJsbuUD2ydq4/ffd8h8tSOFCXec+2xJOVNCvXjuMOq/+5EKHq3D2m2MC2+nUaXeFMSt66VS/J4HdKBixgcw==" + }, + "PolySharp": { + "type": "Direct", + "requested": "[1.15.0, )", + "resolved": "1.15.0", + "contentHash": "FbU0El+EEjdpuIX4iDbeS7ki1uzpJPx8vbqOzEtqnl1GZeAGJfq+jCbxeJL2y0EPnUNk8dRnnqR2xnYXg9Tf+g==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.102", + "contentHash": "0i81LYX31U6UiXz4NOLbvc++u+/mVDmOt+PskrM/MygpDxkv9THKQyRUmavBpLK6iBV0abNWnn+CQgSRz//Pwg==" + }, + "Microsoft.NETFramework.ReferenceAssemblies.net461": { + "type": "Transitive", + "resolved": "1.0.3", + "contentHash": "AmOJZwCqnOCNp6PPcf9joyogScWLtwy0M1WkqfEQ0M9nYwyDD7EX9ZjscKS5iYnyvteX7kzSKFCKt9I9dXA6mA==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.102", + "contentHash": "Mk1IMb9q5tahC2NltxYXFkLBtuBvfBoCQ3pIxYQWfzbCE9o1OB9SsHe0hnNGo7lWgTA/ePbFAJLWu6nLL9K17A==" + }, + "sharpcompress": { + "type": "Project" + } + }, + "net10.0/osx-arm64": { + "Microsoft.DotNet.ILCompiler": { + "type": "Direct", + "requested": "[10.0.6, )", + "resolved": "10.0.6", + "contentHash": "nBOzxOys8OeyJ+Nsi/uYlI/5TSsvwjaM/p5m4dTL6khCLx9UuP3b2ec3HeuBw/+F7hHCAZG1yFx8VBeoRAX+EQ==", + "dependencies": { + "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": "10.0.6" + } + }, + "runtime.osx-arm64.Microsoft.DotNet.ILCompiler": { + "type": "Transitive", + "resolved": "10.0.6", + "contentHash": "+yovwOAlIpfIcH+ZWmLYXWTSWYJ93wcQxF/RVk+X4MXgLASeosCJYVLqP20g0cufKjoRqvCmnklR6y9Su3ORtA==" + } + } + } +} \ No newline at end of file diff --git a/tests/SharpCompress.Performance/Benchmarks/TarBenchmarks.cs b/tests/SharpCompress.Performance/Benchmarks/TarBenchmarks.cs index 634f39403..7a7cbadd0 100644 --- a/tests/SharpCompress.Performance/Benchmarks/TarBenchmarks.cs +++ b/tests/SharpCompress.Performance/Benchmarks/TarBenchmarks.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using BenchmarkDotNet.Attributes; +using SharpCompress.Archives; using SharpCompress.Archives.Tar; using SharpCompress.Common; using SharpCompress.Compressors; @@ -67,11 +68,11 @@ public void TarExtractReaderApi() [Benchmark(Description = "Tar: Extract all entries (Archive API) - SystemGzip")] public void SystemTarExtractArchiveApi() { - using var stream = new MemoryStream(_tarBytes); - using var archive = TarArchive.OpenArchive( + using var stream = new MemoryStream(_tarGzBytes); + using var archive = ArchiveFactory.OpenArchive( stream, ReaderOptions.ForExternalStream.WithProviders( - CompressionProviderRegistry.Empty.With(new SystemGZipCompressionProvider()) + CompressionProviderRegistry.Default.With(new SystemGZipCompressionProvider()) ) ); foreach (var entry in archive.Entries.Where(e => !e.IsDirectory)) @@ -84,11 +85,11 @@ public void SystemTarExtractArchiveApi() [Benchmark(Description = "Tar: Extract all entries (Reader API) - SystemGzip")] public void SystemTarExtractReaderApi() { - using var stream = new MemoryStream(_tarBytes); + using var stream = new MemoryStream(_tarGzBytes); using var reader = ReaderFactory.OpenReader( stream, ReaderOptions.ForExternalStream.WithProviders( - CompressionProviderRegistry.Empty.With(new SystemGZipCompressionProvider()) + CompressionProviderRegistry.Default.With(new SystemGZipCompressionProvider()) ) ); while (reader.MoveToNextEntry()) @@ -104,7 +105,7 @@ public void SystemTarExtractReaderApi() public void TarGzipExtract() { using var stream = new MemoryStream(_tarGzBytes); - using var archive = TarArchive.OpenArchive(stream); + using var archive = ArchiveFactory.OpenArchive(stream); foreach (var entry in archive.Entries.Where(e => !e.IsDirectory)) { using var entryStream = entry.OpenEntryStream(); @@ -116,7 +117,9 @@ public void TarGzipExtract() public async Task TarGzipExtractAsync() { using var stream = new MemoryStream(_tarGzBytes); - await using var archive = await TarArchive.OpenAsyncArchive(stream).ConfigureAwait(false); + await using var archive = await ArchiveFactory + .OpenAsyncArchive(stream) + .ConfigureAwait(false); await foreach (var entry in archive.EntriesAsync.Where(e => !e.IsDirectory)) { await using var entryStream = await entry.OpenEntryStreamAsync().ConfigureAwait(false); diff --git a/tests/SharpCompress.Test/AsyncParityAndCancellationTests.cs b/tests/SharpCompress.Test/AsyncParityAndCancellationTests.cs new file mode 100644 index 000000000..49beacfab --- /dev/null +++ b/tests/SharpCompress.Test/AsyncParityAndCancellationTests.cs @@ -0,0 +1,341 @@ +#if NET8_0_OR_GREATER +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Common; +using SharpCompress.Readers; +using SharpCompress.Test.Mocks; +using SharpCompress.Writers; +using Xunit; + +namespace SharpCompress.Test; + +public class AsyncParityAndCancellationTests : TestBase +{ + [Theory] + [InlineData("Zip.deflate.zip")] + [InlineData("Tar.tar")] + [InlineData("Tar.tar.gz")] + [InlineData("Rar.rar")] + [InlineData("7Zip.nonsolid.7z")] + public async Task ArchiveAsyncEntries_ShouldMatchSyncEntries(string archiveName) + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, archiveName); + + var syncEntries = ReadArchiveEntries(archivePath); + var asyncEntries = await ReadArchiveEntriesAsync(archivePath); + + Assert.Equal(syncEntries, asyncEntries); + } + + [Theory] + [InlineData("Zip.deflate.zip")] + [InlineData("Tar.tar")] + [InlineData("Tar.tar.gz")] + [InlineData("Rar.rar")] + public async Task ReaderAsyncEntries_ShouldMatchSyncEntries(string archiveName) + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, archiveName); + + var syncEntries = ReadReaderEntries(archivePath); + var asyncEntries = await ReadReaderEntriesAsync(archivePath); + + Assert.Equal(syncEntries, asyncEntries); + } + + [Fact] + public async Task AsyncReaderExtraction_ShouldRespectCancellationBeforeStart() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.zip"); + await using var stream = File.OpenRead(archivePath); + await using var reader = await ReaderFactory.OpenAsyncReader(stream); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(async () => + await reader.WriteAllToDirectoryAsync(SCRATCH_FILES_PATH, cancellationToken: cts.Token) + ); + } + + [Fact] + public async Task AsyncArchiveExtraction_ShouldRespectCancellationBeforeStart() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.zip"); + await using var archive = await ArchiveFactory.OpenAsyncArchive(archivePath); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Assert.ThrowsAnyAsync(async () => + await archive.WriteToDirectoryAsync(SCRATCH_FILES_PATH, cancellationToken: cts.Token) + ); + } + + [Fact] + public async Task AsyncReaderExtraction_ShouldRespectCancellationDuringRead() + { + var archiveBytes = CreateLargeTarArchive(); + using var cts = new CancellationTokenSource(); + await using var stream = new CancelAfterBytesReadStream( + new MemoryStream(archiveBytes), + cts, + cancelAfterBytes: 2048 + ); + await Assert.ThrowsAnyAsync(async () => + { + await using var reader = await ReaderFactory.OpenAsyncReader( + stream, + cancellationToken: cts.Token + ); + await reader.WriteAllToDirectoryAsync(SCRATCH_FILES_PATH, cancellationToken: cts.Token); + }); + } + + [Fact] + public async Task OpenAsyncReader_CallerProvidedStream_ShouldRemainOpenByDefault() + { + var archiveBytes = await File.ReadAllBytesAsync( + Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.zip") + ); + var stream = new TestStream(new MemoryStream(archiveBytes)); + + try + { + await using (var reader = await ReaderFactory.OpenAsyncReader(stream)) + { + Assert.True(await reader.MoveToNextEntryAsync()); + } + + Assert.False(stream.IsDisposed); + } + finally + { + stream.Dispose(); + } + } + + [Fact] + public async Task OpenAsyncArchive_CallerProvidedStream_ShouldRemainOpenByDefault() + { + var archiveBytes = await File.ReadAllBytesAsync( + Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.zip") + ); + var stream = new TestStream(new MemoryStream(archiveBytes)); + + try + { + await using (var archive = await ArchiveFactory.OpenAsyncArchive(stream)) + { + await foreach (var _ in archive.EntriesAsync) + { + break; + } + } + + Assert.False(stream.IsDisposed); + } + finally + { + await stream.DisposeAsync(); + } + } + + private static List ReadArchiveEntries(string archivePath) + { + using var archive = ArchiveFactory.OpenArchive(archivePath); + return archive + .Entries.Where(entry => !entry.IsDirectory) + .Select(entry => + { + using var stream = entry.OpenEntryStream(); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return new EntrySnapshot( + entry.Key ?? string.Empty, + entry.Size, + entry.CompressionType, + Convert.ToBase64String(memory.ToArray()) + ); + }) + .OrderBy(entry => entry.Key, StringComparer.Ordinal) + .ToList(); + } + + private static async Task> ReadArchiveEntriesAsync(string archivePath) + { + await using var archive = await ArchiveFactory.OpenAsyncArchive(archivePath); + var entries = new List(); + await foreach (var entry in archive.EntriesAsync) + { + if (entry.IsDirectory) + { + continue; + } + + await using var stream = await entry.OpenEntryStreamAsync(); + using var memory = new MemoryStream(); + await stream.CopyToAsync(memory); + entries.Add( + new EntrySnapshot( + entry.Key ?? string.Empty, + entry.Size, + entry.CompressionType, + Convert.ToBase64String(memory.ToArray()) + ) + ); + } + + return entries.OrderBy(entry => entry.Key, StringComparer.Ordinal).ToList(); + } + + private static List ReadReaderEntries(string archivePath) + { + using var stream = File.OpenRead(archivePath); + using var reader = ReaderFactory.OpenReader(stream); + var entries = new List(); + while (reader.MoveToNextEntry()) + { + if (reader.Entry.IsDirectory) + { + continue; + } + + using var memory = new MemoryStream(); + reader.WriteEntryTo(memory); + entries.Add( + new EntrySnapshot( + reader.Entry.Key ?? string.Empty, + reader.Entry.Size, + reader.Entry.CompressionType, + Convert.ToBase64String(memory.ToArray()) + ) + ); + } + + return entries.OrderBy(entry => entry.Key, StringComparer.Ordinal).ToList(); + } + + private static async Task> ReadReaderEntriesAsync(string archivePath) + { + await using var stream = File.OpenRead(archivePath); + await using var reader = await ReaderFactory.OpenAsyncReader(stream); + var entries = new List(); + while (await reader.MoveToNextEntryAsync()) + { + if (reader.Entry.IsDirectory) + { + continue; + } + + using var memory = new MemoryStream(); + await reader.WriteEntryToAsync(memory); + entries.Add( + new EntrySnapshot( + reader.Entry.Key ?? string.Empty, + reader.Entry.Size, + reader.Entry.CompressionType, + Convert.ToBase64String(memory.ToArray()) + ) + ); + } + + return entries.OrderBy(entry => entry.Key, StringComparer.Ordinal).ToList(); + } + + private static byte[] CreateLargeTarArchive() + { + using var stream = new MemoryStream(); + using ( + var writer = WriterFactory.OpenWriter( + stream, + ArchiveType.Tar, + new WriterOptions(CompressionType.None) + ) + ) + { + writer.Write("large.bin", new MemoryStream(new byte[64 * 1024])); + } + return stream.ToArray(); + } + + private sealed record EntrySnapshot( + string Key, + long Size, + CompressionType CompressionType, + string Content + ); + + private sealed class CancelAfterBytesReadStream( + Stream stream, + CancellationTokenSource cancellationTokenSource, + long cancelAfterBytes + ) : Stream + { + private long _bytesRead; + + public override bool CanRead => stream.CanRead; + public override bool CanSeek => stream.CanSeek; + public override bool CanWrite => false; + public override long Length => stream.Length; + public override long Position + { + get => stream.Position; + set => stream.Position = value; + } + + public override void Flush() => stream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => + throw new NotSupportedException("Use async reads for this test stream."); + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) + { + cancellationToken.ThrowIfCancellationRequested(); + var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); + _bytesRead = read; + if (_bytesRead > cancelAfterBytes) + { + cancellationTokenSource.Cancel(); + cancellationToken.ThrowIfCancellationRequested(); + } + + return read; + } + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) => ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + stream.Dispose(); + } + base.Dispose(disposing); + } + + public override async ValueTask DisposeAsync() + { + await stream.DisposeAsync().ConfigureAwait(false); + await base.DisposeAsync().ConfigureAwait(false); + } + + public override long Seek(long offset, SeekOrigin origin) => stream.Seek(offset, origin); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + } +} +#endif