From 9c47eaf447355df2c82d2bbbf200aad384997548 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 17 Feb 2026 11:54:32 +0000 Subject: [PATCH] SevenZipReader --- docs/API.md | 2 + docs/INTERFACES.md | 2 +- .../SevenZip/SevenZipArchive.Async.cs | 1 + .../Archives/SevenZip/SevenZipArchive.cs | 107 +-------- .../Factories/SevenZipFactory.cs | 20 +- .../Readers/ReaderFactory.Async.cs | 2 +- src/SharpCompress/Readers/ReaderFactory.cs | 2 +- .../SevenZip/ISevenZipReader.cs | 2 +- .../Readers/SevenZip/SevenZipReader.cs | 221 ++++++++++++++++++ .../SevenZip/SevenZipArchiveAsyncTests.cs | 22 +- .../SevenZip/SevenZipArchiveTests.cs | 23 +- 11 files changed, 286 insertions(+), 118 deletions(-) rename src/SharpCompress/{Archives => Readers}/SevenZip/ISevenZipReader.cs (88%) create mode 100644 src/SharpCompress/Readers/SevenZip/SevenZipReader.cs diff --git a/docs/API.md b/docs/API.md index 5696961d..5ee6c0c3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2,6 +2,8 @@ Quick reference for commonly used SharpCompress APIs. +Refer to [INTERFACES.md](INTERFACES.md) for more details. + ## Factory Methods ### Opening Archives diff --git a/docs/INTERFACES.md b/docs/INTERFACES.md index 8320ee69..3cbcf8c2 100644 --- a/docs/INTERFACES.md +++ b/docs/INTERFACES.md @@ -537,7 +537,7 @@ await writer.WriteAsync("file.txt", contentStream, DateTime.Now); | **Zip** | ✅ | ✅ | ✅ | ✅ | | **Tar** | ✅ | ✅ | ✅ | ✅ | | **GZip** | ✅ | ✅ | ✅ | ✅ | -| **7Zip** | ✅ | ❌ (sequential only) | ❌ | ❌ | +| **7Zip** | ✅ | ❌ (sequential only) | ✅ (sequential only) | ❌ | | **Rar** | ✅ | ✅ | ✅ (read-only) | ❌ | | **Ace** | ❌ | ❌ | ✅ (read-only) | ❌ | | **Arc** | ❌ | ❌ | ✅ (read-only) | ❌ | diff --git a/src/SharpCompress/Archives/SevenZip/SevenZipArchive.Async.cs b/src/SharpCompress/Archives/SevenZip/SevenZipArchive.Async.cs index 1f44fa2e..316eadf2 100644 --- a/src/SharpCompress/Archives/SevenZip/SevenZipArchive.Async.cs +++ b/src/SharpCompress/Archives/SevenZip/SevenZipArchive.Async.cs @@ -7,6 +7,7 @@ using SharpCompress.Common; using SharpCompress.Common.SevenZip; using SharpCompress.IO; using SharpCompress.Readers; +using SharpCompress.Readers.SevenZip; namespace SharpCompress.Archives.SevenZip; diff --git a/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs b/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs index 76934b8b..a8ed6239 100644 --- a/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs +++ b/src/SharpCompress/Archives/SevenZip/SevenZipArchive.cs @@ -9,6 +9,7 @@ using SharpCompress.Common.SevenZip; using SharpCompress.Compressors.LZMA.Utilities; using SharpCompress.IO; using SharpCompress.Readers; +using SharpCompress.Readers.SevenZip; namespace SharpCompress.Archives.SevenZip; @@ -18,6 +19,7 @@ public partial class SevenZipArchive ISevenZipAsyncArchive { private ArchiveDatabase? _database; + internal ArchiveDatabase? Database => _database; /// /// Constructor with a SourceStream able to handle FileInfo and Streams. @@ -106,111 +108,6 @@ public partial class SevenZipArchive public override long TotalSize => _database?._packSizes.Aggregate(0L, (total, packSize) => total + packSize) ?? 0; - internal sealed class SevenZipReader - : AbstractReader, - ISevenZipReader, - ISevenZipAsyncReader - { - private readonly SevenZipArchive _archive; - private SevenZipEntry? _currentEntry; - private Stream? _currentFolderStream; - private CFolder? _currentFolder; - - /// - /// Enables internal diagnostics for tests. - /// When disabled (default), diagnostics properties return null to avoid exposing internal state. - /// - internal bool DiagnosticsEnabled { get; set; } - - /// - /// Current folder instance used to decide whether the solid folder stream should be reused. - /// Only available when is true. - /// - internal object? DiagnosticsCurrentFolder => DiagnosticsEnabled ? _currentFolder : null; - - /// - /// Current shared folder stream instance. - /// Only available when is true. - /// - internal Stream? DiagnosticsCurrentFolderStream => - DiagnosticsEnabled ? _currentFolderStream : null; - - internal SevenZipReader(ReaderOptions readerOptions, SevenZipArchive archive) - : base(readerOptions, ArchiveType.SevenZip, false) => this._archive = archive; - - public override SevenZipVolume Volume => _archive.Volumes.Single(); - - protected override IEnumerable GetEntries(Stream stream) - { - var entries = _archive.Entries.ToList(); - stream.Position = 0; - foreach (var dir in entries.Where(x => x.IsDirectory)) - { - _currentEntry = dir; - yield return dir; - } - // For solid archives (entries in the same folder share a compressed stream), - // we must iterate entries sequentially and maintain the folder stream state - // across entries in the same folder to avoid recreating the decompression - // stream for each file, which breaks contiguous streaming. - foreach (var entry in entries.Where(x => !x.IsDirectory)) - { - _currentEntry = entry; - yield return entry; - } - } - - protected override EntryStream GetEntryStream() - { - var entry = _currentEntry.NotNull("currentEntry is not null"); - if (entry.IsDirectory) - { - return CreateEntryStream(Stream.Null); - } - - var folder = entry.FilePart.Folder; - - // If folder is null (empty stream entry), return empty stream - if (folder is null) - { - return CreateEntryStream(Stream.Null); - } - - // Check if we're starting a new folder - dispose old folder stream if needed - if (folder != _currentFolder) - { - _currentFolderStream?.Dispose(); - _currentFolderStream = null; - _currentFolder = folder; - } - - // Create the folder stream once per folder - if (_currentFolderStream is null) - { - _currentFolderStream = _archive._database!.GetFolderStream( - _archive.Volumes.Single().Stream, - folder!, - _archive._database.PasswordProvider - ); - } - - return CreateEntryStream( - new ReadOnlySubStream(_currentFolderStream, entry.Size, leaveOpen: true) - ); - } - - protected override ValueTask GetEntryStreamAsync( - CancellationToken cancellationToken = default - ) => new(GetEntryStream()); - - public override void Dispose() - { - _currentFolderStream?.Dispose(); - _currentFolderStream = null; - base.Dispose(); - } - } - /// /// WORKAROUND: Forces async operations to use synchronous equivalents. /// This is necessary because the LZMA decoder has bugs in its async implementation diff --git a/src/SharpCompress/Factories/SevenZipFactory.cs b/src/SharpCompress/Factories/SevenZipFactory.cs index 93826468..b491cbe7 100644 --- a/src/SharpCompress/Factories/SevenZipFactory.cs +++ b/src/SharpCompress/Factories/SevenZipFactory.cs @@ -7,13 +7,14 @@ using SharpCompress.Archives.SevenZip; using SharpCompress.Common; using SharpCompress.IO; using SharpCompress.Readers; +using SharpCompress.Readers.SevenZip; namespace SharpCompress.Factories; /// /// Represents the foundation factory of 7Zip archive. /// -public class SevenZipFactory : Factory, IArchiveFactory, IMultiArchiveFactory +public class SevenZipFactory : Factory, IArchiveFactory, IMultiArchiveFactory, IReaderFactory { #region IFactory @@ -110,11 +111,18 @@ public class SevenZipFactory : Factory, IArchiveFactory, IMultiArchiveFactory SharpCompressStream sharpCompressStream, ReaderOptions options, out IReader? reader - ) - { - reader = null; - return false; - } + ) => base.TryOpenReader(sharpCompressStream, options, out reader); + + /// + public IReader OpenReader(Stream stream, ReaderOptions? options) => + SevenZipReader.OpenReader(stream, options); + + /// + public ValueTask OpenAsyncReader( + Stream stream, + ReaderOptions? options, + CancellationToken cancellationToken = default + ) => SevenZipReader.OpenAsyncReader(stream, options, cancellationToken); #endregion } diff --git a/src/SharpCompress/Readers/ReaderFactory.Async.cs b/src/SharpCompress/Readers/ReaderFactory.Async.cs index 1bed74d5..d1553012 100644 --- a/src/SharpCompress/Readers/ReaderFactory.Async.cs +++ b/src/SharpCompress/Readers/ReaderFactory.Async.cs @@ -98,7 +98,7 @@ public static partial class ReaderFactory } throw new InvalidFormatException( - "Cannot determine compressed stream type. Supported Reader Formats: Arc, Arj, Zip, GZip, BZip2, Tar, Rar, LZip, XZ, ZStandard" + "Cannot determine compressed stream type. Supported Reader Formats: Arc, Arj, Zip, GZip, BZip2, Tar, Rar, SevenZip, LZip, XZ, ZStandard" ); } } diff --git a/src/SharpCompress/Readers/ReaderFactory.cs b/src/SharpCompress/Readers/ReaderFactory.cs index 6a729b55..615493e4 100644 --- a/src/SharpCompress/Readers/ReaderFactory.cs +++ b/src/SharpCompress/Readers/ReaderFactory.cs @@ -75,7 +75,7 @@ public static partial class ReaderFactory } throw new InvalidFormatException( - "Cannot determine compressed stream type. Supported Reader Formats: Ace, Arc, Arj, Zip, GZip, BZip2, Tar, Rar, LZip, Lzw, XZ, ZStandard" + "Cannot determine compressed stream type. Supported Reader Formats: Ace, Arc, Arj, Zip, GZip, BZip2, Tar, Rar, SevenZip, LZip, Lzw, XZ, ZStandard" ); } } diff --git a/src/SharpCompress/Archives/SevenZip/ISevenZipReader.cs b/src/SharpCompress/Readers/SevenZip/ISevenZipReader.cs similarity index 88% rename from src/SharpCompress/Archives/SevenZip/ISevenZipReader.cs rename to src/SharpCompress/Readers/SevenZip/ISevenZipReader.cs index a1cd961d..a20571f4 100644 --- a/src/SharpCompress/Archives/SevenZip/ISevenZipReader.cs +++ b/src/SharpCompress/Readers/SevenZip/ISevenZipReader.cs @@ -1,6 +1,6 @@ using SharpCompress.Readers; -namespace SharpCompress.Archives.SevenZip; +namespace SharpCompress.Readers.SevenZip; /// /// Reader for 7Zip archives - supports sequential extraction only. diff --git a/src/SharpCompress/Readers/SevenZip/SevenZipReader.cs b/src/SharpCompress/Readers/SevenZip/SevenZipReader.cs new file mode 100644 index 00000000..60c01aa2 --- /dev/null +++ b/src/SharpCompress/Readers/SevenZip/SevenZipReader.cs @@ -0,0 +1,221 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives.SevenZip; +using SharpCompress.Common; +using SharpCompress.Common.SevenZip; +using SharpCompress.IO; + +namespace SharpCompress.Readers.SevenZip; + +/// +/// Public 7Zip reader entry point for sequential extraction. +/// +public sealed class SevenZipReader + : AbstractReader, + ISevenZipReader, + ISevenZipAsyncReader +{ + private readonly SevenZipArchive _archive; + private readonly bool _disposeArchive; + private SevenZipEntry? _currentEntry; + private Stream? _currentFolderStream; + private CFolder? _currentFolder; + + /// + /// Enables internal diagnostics for tests. + /// When disabled (default), diagnostics properties return null to avoid exposing internal state. + /// + internal bool DiagnosticsEnabled { get; set; } + + /// + /// Current folder instance used to decide whether the solid folder stream should be reused. + /// Only available when is true. + /// + internal object? DiagnosticsCurrentFolder => DiagnosticsEnabled ? _currentFolder : null; + + /// + /// Current shared folder stream instance. + /// Only available when is true. + /// + internal Stream? DiagnosticsCurrentFolderStream => + DiagnosticsEnabled ? _currentFolderStream : null; + internal SevenZipReader( + ReaderOptions readerOptions, + SevenZipArchive archive, + bool disposeArchive = false + ) + : base(readerOptions, ArchiveType.SevenZip, disposeVolume: false) + { + _archive = archive; + _disposeArchive = disposeArchive; + } + + /// + /// Opens a 7Zip reader from a file path. + /// + public static ISevenZipReader OpenReader(string filePath, ReaderOptions? readerOptions = null) + { + filePath.NotNullOrEmpty(nameof(filePath)); + return OpenReader(new FileInfo(filePath), readerOptions); + } + + /// + /// Opens a 7Zip reader from a file. + /// + public static ISevenZipReader OpenReader(FileInfo fileInfo, ReaderOptions? readerOptions = null) + { + fileInfo.NotNull(nameof(fileInfo)); + var options = readerOptions ?? ReaderOptions.ForOwnedFile; + return OpenReader(fileInfo.OpenRead(), options); + } + + /// + /// Opens a 7Zip reader from a stream. + /// + public static ISevenZipReader OpenReader(Stream stream, ReaderOptions? readerOptions = null) + { + stream.NotNull(nameof(stream)); + var options = readerOptions ?? ReaderOptions.ForExternalStream; + return new SevenZipReader( + options, + (SevenZipArchive)SevenZipArchive.OpenArchive(stream, options), + disposeArchive: true + ); + } + + /// + /// Opens a 7Zip reader from a file path asynchronously. + /// + public static ValueTask OpenAsyncReader( + string filePath, + ReaderOptions? readerOptions = null, + CancellationToken cancellationToken = default + ) + { + cancellationToken.ThrowIfCancellationRequested(); + filePath.NotNullOrEmpty(nameof(filePath)); + return OpenAsyncReader(new FileInfo(filePath), readerOptions, cancellationToken); + } + + /// + /// Opens a 7Zip reader from a file asynchronously. + /// + public static ValueTask OpenAsyncReader( + FileInfo fileInfo, + ReaderOptions? readerOptions = null, + CancellationToken cancellationToken = default + ) + { + cancellationToken.ThrowIfCancellationRequested(); + fileInfo.NotNull(nameof(fileInfo)); + return OpenAsyncReader( + fileInfo.OpenRead(), + readerOptions ?? ReaderOptions.ForOwnedFile, + cancellationToken + ); + } + + /// + /// Opens a 7Zip reader from a stream asynchronously. + /// + public static ValueTask OpenAsyncReader( + Stream stream, + ReaderOptions? readerOptions = null, + CancellationToken cancellationToken = default + ) + { + cancellationToken.ThrowIfCancellationRequested(); + return new((IAsyncReader)OpenReader(stream, readerOptions)); + } + + public override SevenZipVolume Volume => _archive.Volumes.Single(); + + protected override IEnumerable GetEntries(Stream stream) + { + var entries = _archive.Entries.ToList(); + stream.Position = 0; + foreach (var dir in entries.Where(x => x.IsDirectory)) + { + _currentEntry = dir; + yield return dir; + } + // For solid archives (entries in the same folder share a compressed stream), + // we must iterate entries sequentially and maintain the folder stream state + // across entries in the same folder to avoid recreating the decompression + // stream for each file, which breaks contiguous streaming. + foreach (var entry in entries.Where(x => !x.IsDirectory)) + { + _currentEntry = entry; + yield return entry; + } + } + + protected override EntryStream GetEntryStream() + { + var entry = _currentEntry.NotNull("currentEntry is not null"); + if (entry.IsDirectory) + { + return CreateEntryStream(Stream.Null); + } + + var folder = entry.FilePart.Folder; + + // If folder is null (empty stream entry), return empty stream + if (folder is null) + { + return CreateEntryStream(Stream.Null); + } + + // Check if we're starting a new folder - dispose old folder stream if needed + if (folder != _currentFolder) + { + _currentFolderStream?.Dispose(); + _currentFolderStream = null; + _currentFolder = folder; + } + + // Create the folder stream once per folder + if (_currentFolderStream is null) + { + var database = _archive.Database.NotNull("database is not loaded"); + _currentFolderStream = database.GetFolderStream( + _archive.Volumes.Single().Stream, + folder, + database.PasswordProvider + ); + } + + return CreateEntryStream( + new ReadOnlySubStream(_currentFolderStream, entry.Size, leaveOpen: true) + ); + } + + protected override ValueTask GetEntryStreamAsync( + CancellationToken cancellationToken = default + ) => new(GetEntryStream()); + + public override void Dispose() + { + _currentFolderStream?.Dispose(); + _currentFolderStream = null; + base.Dispose(); + if (_disposeArchive) + { + _archive.Dispose(); + } + } + + public override async ValueTask DisposeAsync() + { + _currentFolderStream?.Dispose(); + _currentFolderStream = null; + await base.DisposeAsync().ConfigureAwait(false); + if (_disposeArchive) + { + await _archive.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipArchiveAsyncTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipArchiveAsyncTests.cs index 03ea2e94..ecd198a5 100644 --- a/tests/SharpCompress.Test/SevenZip/SevenZipArchiveAsyncTests.cs +++ b/tests/SharpCompress.Test/SevenZip/SevenZipArchiveAsyncTests.cs @@ -6,6 +6,8 @@ using SharpCompress.Archives; using SharpCompress.Archives.SevenZip; using SharpCompress.Readers; using Xunit; +using PublicISevenZipAsyncReader = SharpCompress.Readers.SevenZip.ISevenZipAsyncReader; +using PublicSevenZipReader = SharpCompress.Readers.SevenZip.SevenZipReader; namespace SharpCompress.Test.SevenZip; @@ -39,6 +41,24 @@ public class SevenZipArchiveAsyncTests : ArchiveTests await ExtractAllEntriesSequentiallyAsync(testArchive); } + [Fact] + public async Task SevenZipReader_OpenAsyncReader_ReturnsSevenZipReader() + { + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, "7Zip.LZMA.7z")); + await using var reader = await PublicSevenZipReader.OpenAsyncReader(stream); + + Assert.IsAssignableFrom(reader); + } + + [Fact] + public async Task ReaderFactory_OpenAsyncReader_SevenZip_ReturnsSevenZipReader() + { + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, "7Zip.LZMA.7z")); + await using var reader = await ReaderFactory.OpenAsyncReader(stream); + + Assert.IsAssignableFrom(reader); + } + [Fact] public async Task SevenZipArchive_PPMd_AsyncStreamExtraction() { @@ -78,7 +98,7 @@ public class SevenZipArchiveAsyncTests : ArchiveTests await using var reader = await archive.ExtractAllEntriesAsync(); - var sevenZipReader = Assert.IsType(reader); + var sevenZipReader = Assert.IsType(reader); sevenZipReader.DiagnosticsEnabled = true; Stream? currentFolderStreamInstance = null; diff --git a/tests/SharpCompress.Test/SevenZip/SevenZipArchiveTests.cs b/tests/SharpCompress.Test/SevenZip/SevenZipArchiveTests.cs index 7a594860..09828fcf 100644 --- a/tests/SharpCompress.Test/SevenZip/SevenZipArchiveTests.cs +++ b/tests/SharpCompress.Test/SevenZip/SevenZipArchiveTests.cs @@ -1,4 +1,3 @@ -using System; using System.IO; using System.Linq; using SharpCompress.Archives; @@ -7,6 +6,7 @@ using SharpCompress.Common; using SharpCompress.Common.SevenZip; using SharpCompress.Factories; using SharpCompress.Readers; +using SharpCompress.Readers.SevenZip; using Xunit; namespace SharpCompress.Test.SevenZip; @@ -63,6 +63,24 @@ public class SevenZipArchiveTests : ArchiveTests public void SevenZipArchive_LZMA2_EXE_PathRead() => ArchiveFileRead("7Zip.LZMA2.exe", new() { LookForHeader = true }, new SevenZipFactory()); + [Fact] + public void SevenZipReader_OpenReader_StreamRead() + { + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, "7Zip.LZMA.7z")); + using var reader = SevenZipReader.OpenReader(stream); + + Assert.IsAssignableFrom(reader); + } + + [Fact] + public void ReaderFactory_OpenReader_SevenZip_ReturnsSevenZipReader() + { + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, "7Zip.LZMA.7z")); + using var reader = ReaderFactory.OpenReader(stream); + + Assert.IsAssignableFrom(reader); + } + [Fact] public void SevenZipArchive_LZMA2AES_StreamRead() => ArchiveStreamRead("7Zip.LZMA2.Aes.7z", new ReaderOptions { Password = "testpassword" }); @@ -289,7 +307,8 @@ public class SevenZipArchiveTests : ArchiveTests using var reader = archive.ExtractAllEntries(); - var sevenZipReader = Assert.IsType(reader); + Assert.IsType(reader); + SevenZipReader sevenZipReader = (SevenZipReader)reader; sevenZipReader.DiagnosticsEnabled = true; Stream? currentFolderStreamInstance = null;