diff --git a/src/SharpCompress/Archives/AbstractArchive.cs b/src/SharpCompress/Archives/AbstractArchive.cs index 1e4b353b9..482fdf6ef 100644 --- a/src/SharpCompress/Archives/AbstractArchive.cs +++ b/src/SharpCompress/Archives/AbstractArchive.cs @@ -139,6 +139,19 @@ public IReader ExtractAllEntries() /// public virtual bool IsEncrypted => false; + /// + /// Returns whether multi-threaded extraction is supported for this archive. + /// Multi-threading is supported when: + /// 1. The archive is opened from a FileInfo or file path (not a stream) + /// 2. Multi-threading is explicitly enabled in ReaderOptions + /// 3. The archive is not SOLID (SOLID archives should use sequential extraction) + /// + public virtual bool SupportsMultiThreadedExtraction => + _sourceStream is not null + && _sourceStream.IsFileMode + && ReaderOptions.EnableMultiThreadedExtraction + && !IsSolid; + /// /// The archive can find all the parts of the archive needed to fully extract the archive. This forces the parsing of the entire archive. /// diff --git a/src/SharpCompress/Archives/IArchive.cs b/src/SharpCompress/Archives/IArchive.cs index ba0f74a5c..38fbd64dc 100644 --- a/src/SharpCompress/Archives/IArchive.cs +++ b/src/SharpCompress/Archives/IArchive.cs @@ -49,4 +49,12 @@ public interface IArchive : IDisposable /// Returns whether the archive is encrypted. /// bool IsEncrypted { get; } + + /// + /// Returns whether multi-threaded extraction is supported for this archive. + /// Multi-threading is supported when the archive is opened from a FileInfo or file path + /// (not a stream) and the format supports random access (e.g., Zip, Tar, Rar). + /// SOLID archives (some Rar, all 7Zip) should use sequential extraction for best performance. + /// + bool SupportsMultiThreadedExtraction { get; } } diff --git a/src/SharpCompress/Archives/Rar/SeekableFilePart.cs b/src/SharpCompress/Archives/Rar/SeekableFilePart.cs index 97822d90e..927ba8823 100644 --- a/src/SharpCompress/Archives/Rar/SeekableFilePart.cs +++ b/src/SharpCompress/Archives/Rar/SeekableFilePart.cs @@ -1,6 +1,7 @@ using System.IO; using SharpCompress.Common.Rar; using SharpCompress.Common.Rar.Headers; +using SharpCompress.IO; namespace SharpCompress.Archives.Rar; @@ -24,6 +25,76 @@ internal SeekableFilePart( internal override Stream GetCompressedStream() { + Stream streamToUse; + + // If the stream is a SourceStream in file mode with multi-threading enabled, + // create an independent stream to support concurrent extraction + if ( + _stream is SourceStream sourceStream + && sourceStream.IsFileMode + && sourceStream.ReaderOptions.EnableMultiThreadedExtraction + ) + { + var independentStream = sourceStream.CreateIndependentStream(0); + if (independentStream is not null) + { + streamToUse = independentStream; + streamToUse.Position = FileHeader.DataStartPosition; + + if (FileHeader.R4Salt != null) + { + var cryptKey = new CryptKey3(_password!); + return new RarCryptoWrapper(streamToUse, FileHeader.R4Salt, cryptKey); + } + + if (FileHeader.Rar5CryptoInfo != null) + { + var cryptKey = new CryptKey5(_password!, FileHeader.Rar5CryptoInfo); + return new RarCryptoWrapper( + streamToUse, + FileHeader.Rar5CryptoInfo.Salt, + cryptKey + ); + } + + return streamToUse; + } + } + + // Check if the stream wraps a FileStream + Stream? underlyingStream = _stream; + if (_stream is IStreamStack streamStack) + { + underlyingStream = streamStack.BaseStream(); + } + + if (underlyingStream is FileStream fileStream) + { + // Create a new independent stream from the file + streamToUse = new FileStream( + fileStream.Name, + FileMode.Open, + FileAccess.Read, + FileShare.Read + ); + streamToUse.Position = FileHeader.DataStartPosition; + + if (FileHeader.R4Salt != null) + { + var cryptKey = new CryptKey3(_password!); + return new RarCryptoWrapper(streamToUse, FileHeader.R4Salt, cryptKey); + } + + if (FileHeader.Rar5CryptoInfo != null) + { + var cryptKey = new CryptKey5(_password!, FileHeader.Rar5CryptoInfo); + return new RarCryptoWrapper(streamToUse, FileHeader.Rar5CryptoInfo.Salt, cryptKey); + } + + return streamToUse; + } + + // Fall back to existing behavior for stream-based sources _stream.Position = FileHeader.DataStartPosition; if (FileHeader.R4Salt != null) diff --git a/src/SharpCompress/Common/Tar/TarFilePart.cs b/src/SharpCompress/Common/Tar/TarFilePart.cs index 15b76d141..8aa166343 100644 --- a/src/SharpCompress/Common/Tar/TarFilePart.cs +++ b/src/SharpCompress/Common/Tar/TarFilePart.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using SharpCompress.Common.Tar.Headers; +using SharpCompress.IO; namespace SharpCompress.Common.Tar; @@ -22,10 +23,47 @@ internal TarFilePart(TarHeader header, Stream? seekableStream) internal override Stream GetCompressedStream() { - if (_seekableStream != null) + if (_seekableStream is not null) { + // If the seekable stream is a SourceStream in file mode with multi-threading enabled, + // create an independent stream to support concurrent extraction + if ( + _seekableStream is SourceStream sourceStream + && sourceStream.IsFileMode + && sourceStream.ReaderOptions.EnableMultiThreadedExtraction + ) + { + var independentStream = sourceStream.CreateIndependentStream(0); + if (independentStream is not null) + { + independentStream.Position = Header.DataStartPosition ?? 0; + return new TarReadOnlySubStream(independentStream, Header.Size); + } + } + + // Check if the seekable stream wraps a FileStream + Stream? underlyingStream = _seekableStream; + if (_seekableStream is IStreamStack streamStack) + { + underlyingStream = streamStack.BaseStream(); + } + + if (underlyingStream is FileStream fileStream) + { + // Create a new independent stream from the file + var independentStream = new FileStream( + fileStream.Name, + FileMode.Open, + FileAccess.Read, + FileShare.Read + ); + independentStream.Position = Header.DataStartPosition ?? 0; + return new TarReadOnlySubStream(independentStream, Header.Size); + } + + // Fall back to existing behavior for stream-based sources _seekableStream.Position = Header.DataStartPosition ?? 0; - return new TarReadOnlySubStream(_seekableStream, Header.Size, false); + return new TarReadOnlySubStream(_seekableStream, Header.Size); } return Header.PackedStream.NotNull(); } @@ -36,14 +74,8 @@ internal override Stream GetCompressedStream() { if (_seekableStream != null) { - var useSyncOverAsync = false; -#if LEGACY_DOTNET - useSyncOverAsync = true; -#endif _seekableStream.Position = Header.DataStartPosition ?? 0; - return new ValueTask( - new TarReadOnlySubStream(_seekableStream, Header.Size, useSyncOverAsync) - ); + return new ValueTask(new TarReadOnlySubStream(_seekableStream, Header.Size)); } return new ValueTask(Header.PackedStream.NotNull()); } diff --git a/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs b/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs index 772a56651..c011abd25 100644 --- a/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs @@ -44,15 +44,7 @@ IArchiveEncoding archiveEncoding break; case StreamingMode.Streaming: { - var useSyncOverAsync = false; -#if LEGACY_DOTNET - useSyncOverAsync = true; -#endif - header.PackedStream = new TarReadOnlySubStream( - stream, - header.Size, - useSyncOverAsync - ); + header.PackedStream = new TarReadOnlySubStream(stream, header.Size); } break; default: diff --git a/src/SharpCompress/Common/Tar/TarHeaderFactory.cs b/src/SharpCompress/Common/Tar/TarHeaderFactory.cs index df71351e9..c95efaef5 100644 --- a/src/SharpCompress/Common/Tar/TarHeaderFactory.cs +++ b/src/SharpCompress/Common/Tar/TarHeaderFactory.cs @@ -38,11 +38,7 @@ IArchiveEncoding archiveEncoding break; case StreamingMode.Streaming: { - header.PackedStream = new TarReadOnlySubStream( - stream, - header.Size, - false - ); + header.PackedStream = new TarReadOnlySubStream(stream, header.Size); } break; default: diff --git a/src/SharpCompress/Common/Tar/TarReadOnlySubStream.cs b/src/SharpCompress/Common/Tar/TarReadOnlySubStream.cs index 987751a34..79a4c4a4e 100644 --- a/src/SharpCompress/Common/Tar/TarReadOnlySubStream.cs +++ b/src/SharpCompress/Common/Tar/TarReadOnlySubStream.cs @@ -10,7 +10,7 @@ internal class TarReadOnlySubStream : Stream private bool _isDisposed; private long _amountRead; - public TarReadOnlySubStream(Stream stream, long bytesToRead, bool useSyncOverAsyncDispose) + public TarReadOnlySubStream(Stream stream, long bytesToRead) { _stream = stream; BytesLeftToRead = bytesToRead; diff --git a/src/SharpCompress/Common/Zip/SeekableZipFilePart.Async.cs b/src/SharpCompress/Common/Zip/SeekableZipFilePart.Async.cs index 8fff84365..2c17e5134 100644 --- a/src/SharpCompress/Common/Zip/SeekableZipFilePart.Async.cs +++ b/src/SharpCompress/Common/Zip/SeekableZipFilePart.Async.cs @@ -1,26 +1,102 @@ +using System; using System.IO; using System.Threading; using System.Threading.Tasks; using SharpCompress.Common.Zip.Headers; +using SharpCompress.IO; namespace SharpCompress.Common.Zip; -internal partial class SeekableZipFilePart +internal partial class SeekableZipFilePart : IDisposable { + private readonly SemaphoreSlim _asyncHeaderSemaphore = new(1, 1); + internal override async ValueTask GetCompressedStreamAsync( CancellationToken cancellationToken = default ) { if (!_isLocalHeaderLoaded) { - await LoadLocalHeaderAsync(cancellationToken).ConfigureAwait(false); - _isLocalHeaderLoaded = true; + await _asyncHeaderSemaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!_isLocalHeaderLoaded) + { + await LoadLocalHeaderAsync(cancellationToken).ConfigureAwait(false); + _isLocalHeaderLoaded = true; + } + } + finally + { + _asyncHeaderSemaphore.Release(); + } } return await base.GetCompressedStreamAsync(cancellationToken).ConfigureAwait(false); } - private async ValueTask LoadLocalHeaderAsync(CancellationToken cancellationToken = default) => - Header = await _headerFactory - .GetLocalHeaderAsync(BaseStream, (DirectoryEntryHeader)Header) - .ConfigureAwait(false); + private async ValueTask LoadLocalHeaderAsync(CancellationToken cancellationToken = default) + { + // Use an independent stream for loading the header if multi-threading is enabled + Stream streamToUse = BaseStream; + bool disposeStream = false; + + if ( + BaseStream is SourceStream sourceStream + && sourceStream.IsFileMode + && sourceStream.ReaderOptions.EnableMultiThreadedExtraction + ) + { + var independentStream = sourceStream.CreateIndependentStream(0); + if (independentStream is not null) + { + streamToUse = independentStream; + disposeStream = true; + } + } + else + { + // Check if BaseStream wraps a FileStream + Stream? underlyingStream = BaseStream; + if (BaseStream is IStreamStack streamStack) + { + underlyingStream = streamStack.BaseStream(); + } + + if (underlyingStream is FileStream fileStream) + { + streamToUse = new FileStream( + fileStream.Name, + FileMode.Open, + FileAccess.Read, + FileShare.Read + ); + disposeStream = true; + } + } + + try + { + Header = await _headerFactory + .GetLocalHeaderAsync(streamToUse, (DirectoryEntryHeader)Header) + .ConfigureAwait(false); + } + finally + { + if (disposeStream) + { + if (streamToUse is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else + { +#pragma warning disable VSTHRD103 + streamToUse.Dispose(); +#pragma warning restore VSTHRD103 + } + } + } + } + + public void Dispose() => _asyncHeaderSemaphore.Dispose(); } diff --git a/src/SharpCompress/Common/Zip/SeekableZipFilePart.cs b/src/SharpCompress/Common/Zip/SeekableZipFilePart.cs index f9c59dfdc..d783d2f22 100644 --- a/src/SharpCompress/Common/Zip/SeekableZipFilePart.cs +++ b/src/SharpCompress/Common/Zip/SeekableZipFilePart.cs @@ -1,14 +1,15 @@ using System.IO; using SharpCompress.Common.Zip.Headers; -using SharpCompress.Compressors; +using SharpCompress.IO; using SharpCompress.Providers; namespace SharpCompress.Common.Zip; internal partial class SeekableZipFilePart : ZipFilePart { - private bool _isLocalHeaderLoaded; + private volatile bool _isLocalHeaderLoaded; private readonly SeekableZipHeaderFactory _headerFactory; + private readonly object _headerLock = new(); internal SeekableZipFilePart( SeekableZipHeaderFactory headerFactory, @@ -22,19 +23,112 @@ internal override Stream GetCompressedStream() { if (!_isLocalHeaderLoaded) { - LoadLocalHeader(); - _isLocalHeaderLoaded = true; + lock (_headerLock) + { + if (!_isLocalHeaderLoaded) + { + LoadLocalHeader(); + _isLocalHeaderLoaded = true; + } + } } return base.GetCompressedStream(); } - private void LoadLocalHeader() => - Header = _headerFactory.GetLocalHeader(BaseStream, (DirectoryEntryHeader)Header); + private void LoadLocalHeader() + { + // Use an independent stream for loading the header if multi-threading is enabled + Stream streamToUse = BaseStream; + bool disposeStream = false; + + if ( + BaseStream is SourceStream sourceStream + && sourceStream.IsFileMode + && sourceStream.ReaderOptions.EnableMultiThreadedExtraction + ) + { + var independentStream = sourceStream.CreateIndependentStream(0); + if (independentStream is not null) + { + streamToUse = independentStream; + disposeStream = true; + } + } + else + { + // Check if BaseStream wraps a FileStream + Stream? underlyingStream = BaseStream; + if (BaseStream is IStreamStack streamStack) + { + underlyingStream = streamStack.BaseStream(); + } + + if (underlyingStream is FileStream fileStream) + { + streamToUse = new FileStream( + fileStream.Name, + FileMode.Open, + FileAccess.Read, + FileShare.Read + ); + disposeStream = true; + } + } + + try + { + Header = _headerFactory.GetLocalHeader(streamToUse, (DirectoryEntryHeader)Header); + } + finally + { + if (disposeStream) + { + streamToUse.Dispose(); + } + } + } protected override Stream CreateBaseStream() { - BaseStream.Position = Header.DataStartPosition.NotNull(); + // If BaseStream is a SourceStream in file mode with multi-threading enabled, + // create an independent stream to support concurrent extraction + if ( + BaseStream is SourceStream sourceStream + && sourceStream.IsFileMode + && sourceStream.ReaderOptions.EnableMultiThreadedExtraction + ) + { + // Create a new independent stream for this entry + var independentStream = sourceStream.CreateIndependentStream(0); + if (independentStream is not null) + { + independentStream.Position = Header.DataStartPosition.NotNull(); + return independentStream; + } + } + // Check if BaseStream wraps a FileStream (for multi-volume archives) + Stream? underlyingStream = BaseStream; + if (BaseStream is IStreamStack streamStack) + { + underlyingStream = streamStack.BaseStream(); + } + + if (underlyingStream is FileStream fileStream) + { + // Create a new independent stream from the file + var independentStream = new FileStream( + fileStream.Name, + FileMode.Open, + FileAccess.Read, + FileShare.Read + ); + independentStream.Position = Header.DataStartPosition.NotNull(); + return independentStream; + } + + // Fall back to existing behavior for stream-based sources + BaseStream.Position = Header.DataStartPosition.NotNull(); return BaseStream; } } diff --git a/src/SharpCompress/IO/SourceStream.cs b/src/SharpCompress/IO/SourceStream.cs index 4a8377139..fee77d5ad 100644 --- a/src/SharpCompress/IO/SourceStream.cs +++ b/src/SharpCompress/IO/SourceStream.cs @@ -77,6 +77,30 @@ public void LoadAllParts() private Stream Current => _streams[_stream]; + /// + /// Creates an independent stream for the specified volume index. + /// This allows multiple threads to read from different positions concurrently. + /// Only works when IsFileMode is true. + /// + /// The volume index to create a stream for + /// A new independent FileStream, or null if not in file mode or volume doesn't exist + public Stream? CreateIndependentStream(int volumeIndex) + { + if (!IsFileMode) + { + return null; + } + + // Ensure the volume is loaded + if (!LoadStream(volumeIndex)) + { + return null; + } + + // Create a new independent stream from the FileInfo + return _files[volumeIndex].OpenRead(); + } + public bool LoadStream(int index) //ensure all parts to id are loaded { while (_streams.Count <= index) diff --git a/src/SharpCompress/Readers/ReaderOptions.cs b/src/SharpCompress/Readers/ReaderOptions.cs index aced7169e..0aefb5274 100644 --- a/src/SharpCompress/Readers/ReaderOptions.cs +++ b/src/SharpCompress/Readers/ReaderOptions.cs @@ -185,4 +185,12 @@ public static ReaderOptions ForSelfExtractingArchive(string? password = null) => // new ReaderOptions().WithPassword("secret").WithLookForHeader(true) // or // new ReaderOptions { Password = "secret", LookForHeader = true } + + /// + /// Enable multi-threaded extraction support when the archive is opened from a FileInfo or file path. + /// When enabled, multiple threads can extract different entries concurrently by creating + /// independent file streams. This is only effective for archives opened from files, not streams. + /// Default is false for backward compatibility. + /// + public bool EnableMultiThreadedExtraction { get; set; } } diff --git a/src/SharpCompress/packages.lock.json b/src/SharpCompress/packages.lock.json index e1e7afa51..738df5991 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.8, )", - "resolved": "10.0.8", - "contentHash": "dVbSXGIFNR5nZcv2tOLoWI+a9T4jtFd77IYjuND+QVe360qWgAF7H0WtoopYhRw/+SgpGUTyrkrh+65+ClNnfw==" + "requested": "[10.0.6, )", + "resolved": "10.0.6", + "contentHash": "QKuvS0LWX4fjFqeDkyM7Kqt8P3wYTiPD4nwU+9y59n0sCiG714fxDgbbN82vDnzq89AF/PiHl92TP2C4aFDUQA==" }, "Microsoft.NETFramework.ReferenceAssemblies": { "type": "Direct", @@ -388,9 +388,9 @@ "net8.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[8.0.27, )", - "resolved": "8.0.27", - "contentHash": "rQi9TxifHRnXP7lVRZH05DxD2/XGbJp12q0ozcbrlBlBnyyzssFTH/2vLhtKWUp2CT1qVscTrcYTFiwTyKPKRg==" + "requested": "[8.0.26, )", + "resolved": "8.0.26", + "contentHash": "o7/yVssM2r9Wyln2s9edBd5ANZXqdSdBI+g7JqXkyJmXrhs2WsJp25K5yPnYrTgdKBCjKB8bg+O2oew4sgzFaA==" }, "Microsoft.NETFramework.ReferenceAssemblies": { "type": "Direct", diff --git a/tests/SharpCompress.Test/Tar/TarMultiThreadTests.cs b/tests/SharpCompress.Test/Tar/TarMultiThreadTests.cs new file mode 100644 index 000000000..f7cbcc30f --- /dev/null +++ b/tests/SharpCompress.Test/Tar/TarMultiThreadTests.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using SharpCompress.Archives.Tar; +using SharpCompress.Common; +using Xunit; + +namespace SharpCompress.Test.Tar; + +public class TarMultiThreadTests : TestBase +{ + [Fact] + public void Tar_Archive_Concurrent_Extraction_From_FileInfo() + { + // Test concurrent extraction of multiple entries from a Tar archive opened from FileInfo + var testArchive = Path.Combine(TEST_ARCHIVES_PATH, "Tar.tar"); + var fileInfo = new FileInfo(testArchive); + + var options = new SharpCompress.Readers.ReaderOptions + { + EnableMultiThreadedExtraction = true, + }; + + using var archive = TarArchive.OpenArchive(fileInfo, options); + + // Verify multi-threading is supported + Assert.True(archive.SupportsMultiThreadedExtraction); + + var entries = archive.Entries.Where(e => !e.IsDirectory).Take(5).ToList(); + + // Extract multiple entries concurrently + var tasks = new List(); + var outputFiles = new List(); + + foreach (var entry in entries) + { + var outputFile = Path.Combine(SCRATCH_FILES_PATH, entry.Key!); + outputFiles.Add(outputFile); + + tasks.Add( + Task.Run(() => + { + var dir = Path.GetDirectoryName(outputFile); + if (dir != null) + { + Directory.CreateDirectory(dir); + } + using var entryStream = entry.OpenEntryStream(); + using var fileStream = File.Create(outputFile); + entryStream.CopyTo(fileStream); + }) + ); + } + + Task.WaitAll(tasks.ToArray()); + + // Verify all files were extracted + Assert.Equal(entries.Count, outputFiles.Count); + foreach (var outputFile in outputFiles) + { + Assert.True(File.Exists(outputFile), $"File {outputFile} should exist"); + } + } + + [Fact] + public async Task Tar_Archive_Concurrent_Extraction_From_FileInfo_Async() + { + // Test concurrent async extraction of multiple entries from a Tar archive opened from FileInfo + var testArchive = Path.Combine(TEST_ARCHIVES_PATH, "Tar.tar"); + var fileInfo = new FileInfo(testArchive); + + var options = new SharpCompress.Readers.ReaderOptions + { + EnableMultiThreadedExtraction = true, + }; + + using var archive = TarArchive.OpenArchive(fileInfo, options); + var entries = archive.Entries.Where(e => !e.IsDirectory).Take(5).ToList(); + + // Extract multiple entries concurrently + var tasks = new List(); + var outputFiles = new List(); + + foreach (var entry in entries) + { + var outputFile = Path.Combine(SCRATCH_FILES_PATH, entry.Key!); + outputFiles.Add(outputFile); + + tasks.Add( + Task.Run(async () => + { + var dir = Path.GetDirectoryName(outputFile); + if (dir != null) + { + Directory.CreateDirectory(dir); + } + using var entryStream = await entry.OpenEntryStreamAsync(); + using var fileStream = File.Create(outputFile); + await entryStream.CopyToAsync(fileStream); + }) + ); + } + + await Task.WhenAll(tasks); + + // Verify all files were extracted + Assert.Equal(entries.Count, outputFiles.Count); + foreach (var outputFile in outputFiles) + { + Assert.True(File.Exists(outputFile), $"File {outputFile} should exist"); + } + } +} diff --git a/tests/SharpCompress.Test/Zip/ZipMultiThreadTests.cs b/tests/SharpCompress.Test/Zip/ZipMultiThreadTests.cs new file mode 100644 index 000000000..4100f5fa7 --- /dev/null +++ b/tests/SharpCompress.Test/Zip/ZipMultiThreadTests.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using Xunit; + +namespace SharpCompress.Test.Zip; + +public class ZipMultiThreadTests : TestBase +{ + [Fact] + public void Zip_Archive_Without_MultiThreading_Enabled() + { + // Test that extraction still works when multi-threading is NOT enabled + var testArchive = Path.Combine(TEST_ARCHIVES_PATH, "Zip.none.zip"); + var fileInfo = new FileInfo(testArchive); + + // Default options - multi-threading disabled + using var archive = ZipArchive.OpenArchive(fileInfo); + + // Verify multi-threading is NOT supported + Assert.False(archive.SupportsMultiThreadedExtraction); + + var entry = archive.Entries.First(e => !e.IsDirectory); + var outputFile = Path.Combine(SCRATCH_FILES_PATH, entry.Key!); + + var dir = Path.GetDirectoryName(outputFile); + if (dir != null) + { + Directory.CreateDirectory(dir); + } + + using var entryStream = entry.OpenEntryStream(); + using var fileStream = File.Create(outputFile); + entryStream.CopyTo(fileStream); + + Assert.True(File.Exists(outputFile)); + } + + [Fact] + public void Zip_Archive_Concurrent_Extraction_From_FileInfo() + { + // Test concurrent extraction of multiple entries from a Zip archive opened from FileInfo + var testArchive = Path.Combine(TEST_ARCHIVES_PATH, "Zip.none.zip"); + var fileInfo = new FileInfo(testArchive); + + var options = new SharpCompress.Readers.ReaderOptions + { + EnableMultiThreadedExtraction = true, + }; + + using var archive = ZipArchive.OpenArchive(fileInfo, options); + + // Verify multi-threading is supported + Assert.True(archive.SupportsMultiThreadedExtraction); + + var entries = archive.Entries.Where(e => !e.IsDirectory).Take(5).ToList(); + + // Extract multiple entries concurrently + var tasks = new List(); + var outputFiles = new List(); + + foreach (var entry in entries) + { + var outputFile = Path.Combine(SCRATCH_FILES_PATH, entry.Key!); + outputFiles.Add(outputFile); + + tasks.Add( + Task.Run(() => + { + var dir = Path.GetDirectoryName(outputFile); + if (dir != null) + { + Directory.CreateDirectory(dir); + } + using var entryStream = entry.OpenEntryStream(); + using var fileStream = File.Create(outputFile); + entryStream.CopyTo(fileStream); + }) + ); + } + + Task.WaitAll(tasks.ToArray()); + + // Verify all files were extracted + Assert.Equal(entries.Count, outputFiles.Count); + foreach (var outputFile in outputFiles) + { + Assert.True(File.Exists(outputFile), $"File {outputFile} should exist"); + } + } + + [Fact] + public async Task Zip_Archive_Concurrent_Extraction_From_FileInfo_Async() + { + // Test concurrent async extraction of multiple entries from a Zip archive opened from FileInfo + var testArchive = Path.Combine(TEST_ARCHIVES_PATH, "Zip.none.zip"); + var fileInfo = new FileInfo(testArchive); + + var options = new SharpCompress.Readers.ReaderOptions + { + EnableMultiThreadedExtraction = true, + }; + + using var archive = ZipArchive.OpenArchive(fileInfo, options); + var entries = archive.Entries.Where(e => !e.IsDirectory).Take(5).ToList(); + + // Extract multiple entries concurrently + var tasks = new List(); + var outputFiles = new List(); + + foreach (var entry in entries) + { + var outputFile = Path.Combine(SCRATCH_FILES_PATH, entry.Key!); + outputFiles.Add(outputFile); + + tasks.Add( + Task.Run(async () => + { + var dir = Path.GetDirectoryName(outputFile); + if (dir != null) + { + Directory.CreateDirectory(dir); + } + using var entryStream = await entry.OpenEntryStreamAsync(); + using var fileStream = File.Create(outputFile); + await entryStream.CopyToAsync(fileStream); + }) + ); + } + + await Task.WhenAll(tasks); + + // Verify all files were extracted + Assert.Equal(entries.Count, outputFiles.Count); + foreach (var outputFile in outputFiles) + { + Assert.True(File.Exists(outputFile), $"File {outputFile} should exist"); + } + } + + [Fact] + public void Zip_Archive_Concurrent_Extraction_From_Path() + { + // Test concurrent extraction when opening from path (should use FileInfo internally) + var testArchive = Path.Combine(TEST_ARCHIVES_PATH, "Zip.none.zip"); + + var options = new SharpCompress.Readers.ReaderOptions + { + EnableMultiThreadedExtraction = true, + }; + + using var archive = ZipArchive.OpenArchive(testArchive, options); + var entries = archive.Entries.Where(e => !e.IsDirectory).Take(5).ToList(); + + // Extract multiple entries concurrently + var tasks = new List(); + var outputFiles = new List(); + + foreach (var entry in entries) + { + var outputFile = Path.Combine(SCRATCH_FILES_PATH, entry.Key!); + outputFiles.Add(outputFile); + + tasks.Add( + Task.Run(() => + { + var dir = Path.GetDirectoryName(outputFile); + if (dir != null) + { + Directory.CreateDirectory(dir); + } + using var entryStream = entry.OpenEntryStream(); + using var fileStream = File.Create(outputFile); + entryStream.CopyTo(fileStream); + }) + ); + } + + Task.WaitAll(tasks.ToArray()); + + // Verify all files were extracted + Assert.Equal(entries.Count, outputFiles.Count); + foreach (var outputFile in outputFiles) + { + Assert.True(File.Exists(outputFile), $"File {outputFile} should exist"); + } + } +}