From 9d60394f8f888cbccff0f1a2e7bfd3cfefd8609d Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Sat, 6 Jun 2026 15:33:03 +0100 Subject: [PATCH] Add another convience method and tests --- .../Archives/ArchiveFactory.Async.cs | 40 +++++++++ .../ArchiveFactoryAsyncExtractionTests.cs | 74 +++++++++++++++++ .../GZipArchiveAsyncFactoryExtractionTests.cs | 40 +++++++++ .../RarArchiveAsyncFactoryExtractionTests.cs | 35 ++++++++ ...enZipArchiveAsyncFactoryExtractionTests.cs | 35 ++++++++ .../ZipArchiveAsyncParallelExtractionTests.cs | 83 +++++++++++++++++++ 6 files changed, 307 insertions(+) create mode 100644 tests/SharpCompress.Test/ArchiveFactoryAsyncExtractionTests.cs create mode 100644 tests/SharpCompress.Test/GZip/GZipArchiveAsyncFactoryExtractionTests.cs create mode 100644 tests/SharpCompress.Test/Rar/RarArchiveAsyncFactoryExtractionTests.cs create mode 100644 tests/SharpCompress.Test/SevenZip/SevenZipArchiveAsyncFactoryExtractionTests.cs create mode 100644 tests/SharpCompress.Test/Zip/ZipArchiveAsyncParallelExtractionTests.cs diff --git a/src/SharpCompress/Archives/ArchiveFactory.Async.cs b/src/SharpCompress/Archives/ArchiveFactory.Async.cs index 6c45cb1f..c9118a47 100644 --- a/src/SharpCompress/Archives/ArchiveFactory.Async.cs +++ b/src/SharpCompress/Archives/ArchiveFactory.Async.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -10,6 +11,45 @@ namespace SharpCompress.Archives; public static partial class ArchiveFactory { + public static async ValueTask ExtractToDirectoryAsync( + string sourceArchive, + string destinationDirectory, + ExtractionOptions? options = null, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + sourceArchive.NotNullOrEmpty(nameof(sourceArchive)); + await ExtractToDirectoryAsync( + new FileInfo(sourceArchive), + destinationDirectory, + options, + progress, + cancellationToken + ) + .ConfigureAwait(false); + } + + public static async ValueTask ExtractToDirectoryAsync( + FileInfo sourceArchive, + string destinationDirectory, + ExtractionOptions? options = null, + IProgress? progress = null, + CancellationToken cancellationToken = default + ) + { + sourceArchive.NotNull(nameof(sourceArchive)); + await using var archive = await OpenAsyncArchive( + sourceArchive, + ReaderOptions.ForFilePath, + cancellationToken + ) + .ConfigureAwait(false); + await archive + .WriteToDirectoryAsync(destinationDirectory, options, progress, cancellationToken) + .ConfigureAwait(false); + } + public static async ValueTask OpenAsyncArchive( Stream stream, ReaderOptions? readerOptions = null, diff --git a/tests/SharpCompress.Test/ArchiveFactoryAsyncExtractionTests.cs b/tests/SharpCompress.Test/ArchiveFactoryAsyncExtractionTests.cs new file mode 100644 index 00000000..9f937fd7 --- /dev/null +++ b/tests/SharpCompress.Test/ArchiveFactoryAsyncExtractionTests.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Common; +using Xunit; + +namespace SharpCompress.Test; + +public class ArchiveFactoryAsyncExtractionTests : ArchiveTests +{ + public ArchiveFactoryAsyncExtractionTests() => UseExtensionInsteadOfNameToVerify = true; + + [Fact] + public async ValueTask ExtractToDirectoryAsync_Zip_DefaultAuto_Extracts() + { + await ArchiveFactory.ExtractToDirectoryAsync( + Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.zip"), + SCRATCH_FILES_PATH + ); + + VerifyFiles(); + } + + [Fact] + public async ValueTask ExtractToDirectoryAsync_Zip_FileInfo_RequireParallel_Extracts() + { + await ArchiveFactory.ExtractToDirectoryAsync( + new FileInfo(Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.zip")), + SCRATCH_FILES_PATH, + new ExtractionOptions { Parallelism = ExtractionParallelism.RequireParallel } + ); + + VerifyFiles(); + } + + [Fact] + public async ValueTask ExtractToDirectoryAsync_Zip_SingleThreaded_Extracts() + { + await ArchiveFactory.ExtractToDirectoryAsync( + Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.zip"), + SCRATCH_FILES_PATH, + new ExtractionOptions { Parallelism = ExtractionParallelism.SingleThreaded } + ); + + VerifyFiles(); + } + + [Fact] + public async ValueTask ExtractToDirectoryAsync_ForwardsProgress() + { + var progressReports = new List(); + var progress = new SynchronousProgress(progressReports.Add); + + await ArchiveFactory.ExtractToDirectoryAsync( + Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.zip"), + SCRATCH_FILES_PATH, + progress: progress + ); + + VerifyFiles(); + Assert.NotEmpty(progressReports); + } + + private sealed class SynchronousProgress : IProgress + { + private readonly Action handler; + + internal SynchronousProgress(Action handler) => this.handler = handler; + + public void Report(T value) => handler(value); + } +} diff --git a/tests/SharpCompress.Test/GZip/GZipArchiveAsyncFactoryExtractionTests.cs b/tests/SharpCompress.Test/GZip/GZipArchiveAsyncFactoryExtractionTests.cs new file mode 100644 index 00000000..93bc7410 --- /dev/null +++ b/tests/SharpCompress.Test/GZip/GZipArchiveAsyncFactoryExtractionTests.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Archives.GZip; +using SharpCompress.Common; +using SharpCompress.Writers.GZip; +using Xunit; + +namespace SharpCompress.Test.GZip; + +public class GZipArchiveAsyncFactoryExtractionTests : TestBase +{ + [Fact] + public async ValueTask ExtractToDirectoryAsync_RequireParallel_Fails() + { + var archivePath = GetScratch2Path("single.gz"); + var entryBytes = Encoding.UTF8.GetBytes("single file content"); + + await using (var archive = (GZipArchive)await GZipArchive.CreateAsyncArchive()) + { + await archive.AddEntryAsync( + "single.txt", + new MemoryStream(entryBytes), + closeStream: true, + size: entryBytes.Length + ); + await archive.SaveToAsync(archivePath, new GZipWriterOptions()); + } + + await Assert.ThrowsAsync(async () => + await ArchiveFactory.ExtractToDirectoryAsync( + archivePath, + SCRATCH_FILES_PATH, + new ExtractionOptions { Parallelism = ExtractionParallelism.RequireParallel } + ) + ); + } +} diff --git a/tests/SharpCompress.Test/Rar/RarArchiveAsyncFactoryExtractionTests.cs b/tests/SharpCompress.Test/Rar/RarArchiveAsyncFactoryExtractionTests.cs new file mode 100644 index 00000000..b4b32de6 --- /dev/null +++ b/tests/SharpCompress.Test/Rar/RarArchiveAsyncFactoryExtractionTests.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Common; +using Xunit; + +namespace SharpCompress.Test.Rar; + +public class RarArchiveAsyncFactoryExtractionTests : ArchiveTests +{ + [Fact] + public async ValueTask ExtractToDirectoryAsync_NonSolid_RequireParallel_Extracts() + { + await ArchiveFactory.ExtractToDirectoryAsync( + Path.Combine(TEST_ARCHIVES_PATH, "Rar.rar"), + SCRATCH_FILES_PATH, + new ExtractionOptions { Parallelism = ExtractionParallelism.RequireParallel } + ); + + VerifyFiles(); + } + + [Fact] + public async ValueTask ExtractToDirectoryAsync_Solid_RequireParallel_Fails() + { + await Assert.ThrowsAsync(async () => + await ArchiveFactory.ExtractToDirectoryAsync( + Path.Combine(TEST_ARCHIVES_PATH, "Rar.solid.rar"), + SCRATCH_FILES_PATH, + new ExtractionOptions { Parallelism = ExtractionParallelism.RequireParallel } + ) + ); + } +} diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipArchiveAsyncFactoryExtractionTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipArchiveAsyncFactoryExtractionTests.cs new file mode 100644 index 00000000..10ae6296 --- /dev/null +++ b/tests/SharpCompress.Test/SevenZip/SevenZipArchiveAsyncFactoryExtractionTests.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Common; +using Xunit; + +namespace SharpCompress.Test.SevenZip; + +public class SevenZipArchiveAsyncFactoryExtractionTests : ArchiveTests +{ + [Fact] + public async ValueTask ExtractToDirectoryAsync_NonSolid_RequireParallel_Extracts() + { + await ArchiveFactory.ExtractToDirectoryAsync( + Path.Combine(TEST_ARCHIVES_PATH, "7Zip.nonsolid.7z"), + SCRATCH_FILES_PATH, + new ExtractionOptions { Parallelism = ExtractionParallelism.RequireParallel } + ); + + VerifyFiles(); + } + + [Fact] + public async ValueTask ExtractToDirectoryAsync_SolidSingleBlock_RequireParallel_Fails() + { + await Assert.ThrowsAsync(async () => + await ArchiveFactory.ExtractToDirectoryAsync( + Path.Combine(TEST_ARCHIVES_PATH, "7Zip.solid.1block.7z"), + SCRATCH_FILES_PATH, + new ExtractionOptions { Parallelism = ExtractionParallelism.RequireParallel } + ) + ); + } +} diff --git a/tests/SharpCompress.Test/Zip/ZipArchiveAsyncParallelExtractionTests.cs b/tests/SharpCompress.Test/Zip/ZipArchiveAsyncParallelExtractionTests.cs new file mode 100644 index 00000000..cca42f78 --- /dev/null +++ b/tests/SharpCompress.Test/Zip/ZipArchiveAsyncParallelExtractionTests.cs @@ -0,0 +1,83 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Common; +using SharpCompress.Writers; +using SharpCompress.Writers.Zip; +using Xunit; + +namespace SharpCompress.Test.Zip; + +public class ZipArchiveAsyncParallelExtractionTests : TestBase +{ + [Fact] + public async ValueTask RequireParallel_DetectsDuplicateOutputPathsBeforeWriting() + { + var archivePath = GetScratch2Path("duplicate-output.zip"); + CreateZip(archivePath, ("duplicate.txt", "first"), ("duplicate.txt", "second")); + + var exception = await Assert.ThrowsAsync(async () => + await ArchiveFactory.ExtractToDirectoryAsync( + archivePath, + SCRATCH_FILES_PATH, + new ExtractionOptions { Parallelism = ExtractionParallelism.RequireParallel } + ) + ); + + Assert.Contains("multiple entries", exception.Message); + Assert.False(File.Exists(GetScratchPath("duplicate.txt"))); + } + + [Fact] + public async ValueTask RequireParallel_HonorsCancellation() + { + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + await Assert.ThrowsAnyAsync(async () => + await ArchiveFactory.ExtractToDirectoryAsync( + Path.Combine(TEST_ARCHIVES_PATH, "Zip.deflate.zip"), + SCRATCH_FILES_PATH, + new ExtractionOptions { Parallelism = ExtractionParallelism.RequireParallel }, + cancellationToken: cancellationTokenSource.Token + ) + ); + } + + [Fact] + public async ValueTask RequireParallel_EntryFailureIncludesEntryName() + { + var archivePath = GetScratch2Path("overwrite-failure.zip"); + CreateZip(archivePath, ("blocked.txt", "archive")); + File.WriteAllText(GetScratchPath("blocked.txt"), "existing"); + + var exception = await Assert.ThrowsAnyAsync(async () => + await ArchiveFactory.ExtractToDirectoryAsync( + archivePath, + SCRATCH_FILES_PATH, + new ExtractionOptions + { + Overwrite = false, + Parallelism = ExtractionParallelism.RequireParallel, + } + ) + ); + + Assert.Contains("blocked.txt", exception.Message); + } + + private static void CreateZip(string archivePath, params (string Key, string Content)[] entries) + { + using var stream = File.Create(archivePath); + using var writer = new ZipWriter(stream, new ZipWriterOptions(CompressionType.Deflate)); + + foreach (var entry in entries) + { + using var entryStream = new MemoryStream(Encoding.UTF8.GetBytes(entry.Content)); + writer.Write(entry.Key, entryStream); + } + } +}