From 87f5616d1f94c4f1d57fad211794124e36e7f437 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 4 Aug 2026 11:45:13 +0100 Subject: [PATCH] Detect v Inspect is added. Detect is used only basic info but does not buffer as much as it can. --- docs/API.md | 46 +- docs/FORMATS.md | 2 +- docs/USAGE.md | 26 +- .../Archives/ArchiveAccessMode.cs | 25 + .../Archives/ArchiveDetection.cs | 43 ++ .../Archives/ArchiveEncryptionScope.cs | 25 + .../Archives/ArchiveFactory.Detection.cs | 370 ++++++---- .../ArchiveFactory.Information.Async.cs | 537 ++++++++++++++ .../Archives/ArchiveFactory.Information.cs | 682 ++++++++++++++++++ .../Archives/ArchiveInformation.cs | 179 +++-- .../Archives/ArchiveInformationLimitations.cs | 30 + .../Archives/ArchiveInformationStatus.cs | 17 + .../Archives/ZipArchiveInformation.cs | 15 + src/SharpCompress/Common/Rar/RarVolume.cs | 2 + src/SharpCompress/IO/ArchiveOffsetStream.cs | 87 +++ .../SharpCompress.Test/ArchiveFactoryTests.cs | 330 +++++++-- 16 files changed, 2125 insertions(+), 291 deletions(-) create mode 100644 src/SharpCompress/Archives/ArchiveAccessMode.cs create mode 100644 src/SharpCompress/Archives/ArchiveDetection.cs create mode 100644 src/SharpCompress/Archives/ArchiveEncryptionScope.cs create mode 100644 src/SharpCompress/Archives/ArchiveFactory.Information.Async.cs create mode 100644 src/SharpCompress/Archives/ArchiveFactory.Information.cs create mode 100644 src/SharpCompress/Archives/ArchiveInformationLimitations.cs create mode 100644 src/SharpCompress/Archives/ArchiveInformationStatus.cs create mode 100644 src/SharpCompress/Archives/ZipArchiveInformation.cs create mode 100644 src/SharpCompress/IO/ArchiveOffsetStream.cs diff --git a/docs/API.md b/docs/API.md index cda03364..1536c9c6 100644 --- a/docs/API.md +++ b/docs/API.md @@ -50,20 +50,26 @@ if (ArchiveFactory.IsArchive("archive.zip", out var archiveType)) Console.WriteLine($"Detected {archiveType}"); } -// Detect capabilities before choosing Archive API vs Reader API -var info = ArchiveFactory.GetArchiveInformation("archive.arc"); -if (info is not null) +// Detect the format and choose an API without enumerating entries. +var detection = ArchiveFactory.DetectArchive("archive.tar.xz"); +if (detection is not null) { - Console.WriteLine($"Type: {info.Type}"); - Console.WriteLine($"Supports random access: {info.SupportsRandomAccess}"); - Console.WriteLine($"ZIP data descriptor entries: {info.ZipDataDescriptorEntryCount}"); - Console.WriteLine($"Solid stream count: {info.SolidStreamCount}"); + Console.WriteLine($"Container: {detection.ContainerType}"); + Console.WriteLine($"Outer compression: {detection.OuterCompressionType}"); + Console.WriteLine($"Supported APIs: {detection.SupportedApis}"); } -var asyncInfo = await ArchiveFactory.GetArchiveInformationAsync( - "archive.zip", - cancellationToken -); +// InspectArchive parses complete archive metadata. It can be expensive for +// sequential formats such as TAR and compressed TAR. +var information = ArchiveFactory.InspectArchive("archive.zip"); +if (information is not null) +{ + Console.WriteLine($"Entries: {information.EntryCount}"); + Console.WriteLine($"Solid streams: {information.SolidStreamCount}"); + Console.WriteLine($"ZIP data descriptor entries: {information.Zip?.DataDescriptorEntryCount}"); +} + +var asyncInformation = await ArchiveFactory.InspectArchiveAsync("archive.zip", cancellationToken); // Multi-volume archives var parts = ArchiveFactory.GetFileParts("archive.part1.rar") @@ -75,7 +81,23 @@ using (var archive = ArchiveFactory.OpenArchive(parts)) } ``` -`ArchiveInformation.SupportsRandomAccess` is `true` when the detected format supports `IArchive` random access. It is `false` for reader-only formats such as Ace, Arc, Arj, and standalone LZW, where `ReaderFactory.OpenReader` should be used instead. Compressed tar wrappers such as `.tar.gz` and `.tar.xz` are also reader-only; `ArchiveFactory.GetArchiveInformation` returns `null` for them and `ArchiveFactory.OpenArchive` does not open them as the outer compression wrapper. Use `ReaderFactory.OpenReader` or `TarReader.OpenReader` for those files. Format-specific details are available through `ArchiveInformation.ZipDataDescriptorEntryCount` (ZIP) and `ArchiveInformation.SolidStreamCount` (solid-capable formats such as 7z and RAR). +`DetectArchive` identifies the logical container, its outer compression wrapper, and whether the Archive and Reader APIs are available without enumerating entries. Compressed TAR wrappers such as `.tar.gz` and `.tar.xz` are identified as `ArchiveType.Tar` with an outer compression type and `ArchiveAccessMode.Reader`. + +`InspectArchive` enumerates metadata and returns `ArchiveInformation`. It reports `Partial` status for missing volumes or encrypted headers without a password; malformed archives and incorrect passwords throw. ZIP-specific metadata is exposed through `ArchiveInformation.Zip`. + +The stream overloads of `DetectArchive` and `InspectArchive` preserve the supplied stream's position and leave it open, including when `ReaderOptions.LeaveStreamOpen` is `false`. + +#### Migrating from `GetArchiveInformation` + +| Removed API | Replacement | +| --- | --- | +| `GetArchiveInformation(...)` used only to identify a format | `DetectArchive(...)` | +| `GetArchiveInformation(...)` used to enumerate archive details | `InspectArchive(...)` | +| `GetArchiveInformationAsync(...)` | `DetectArchiveAsync(...)` or `InspectArchiveAsync(...)` | +| `Type` | `Detection.ContainerType` | +| `SupportsRandomAccess` | `Detection.SupportedApis.HasFlag(ArchiveAccessMode.Archive)` | +| `ZipDataDescriptorEntryCount` | `Zip.DataDescriptorEntryCount` | +| `SolidStreamCount` | `SolidStreamCount` | ### Creating Archives diff --git a/docs/FORMATS.md b/docs/FORMATS.md index 22a7080a..a9309c06 100644 --- a/docs/FORMATS.md +++ b/docs/FORMATS.md @@ -31,7 +31,7 @@ 4. The 7Zip format doesn't allow for reading as a forward-only stream, so 7Zip read support is only through the Archive API. Writing is supported through SevenZipWriter for non-solid archives with LZMA/LZMA2 and requires a seekable output stream. See [7Zip Format Notes](#7zip-format-notes) for details on async extraction behavior. 5. LZip has no support for extra data like the file name or timestamp. There is a default filename used when looking at the entry Key on the archive. -`ArchiveFactory.GetArchiveInformation(...).SupportsRandomAccess` is `true` when the detected format has an Archive API in this table. It is `false` for reader-only formats such as Ace, Arc, Arj, and standalone LZW. Compressed tar wrappers are supported by `ReaderFactory`/`TarReader`, not by `ArchiveFactory`/`TarArchive`; ArchiveFactory detection blocks them instead of opening the outer compression wrapper as a standalone archive. +`ArchiveFactory.DetectArchive(...)` reports which APIs in this table are available through `ArchiveDetection.SupportedApis`. Reader-only formats include Ace, Arc, Arj, and standalone LZW. Compressed TAR wrappers are detected as a TAR container with an outer compression type and support the Reader API, not the Archive API. Use `ArchiveFactory.InspectArchive(...)` when complete archive metadata is required. ### Zip Format Notes diff --git a/docs/USAGE.md b/docs/USAGE.md index 9666d43f..838cd16f 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -227,16 +227,16 @@ using (var archive = ArchiveFactory.OpenArchive("archive.zip")) } ``` -### Use ArchiveInformation to choose the right API +### Detect the format and choose the right API ```C# var archivePath = "archive.arc"; -var info = ArchiveFactory.GetArchiveInformation(archivePath); -if (info is null) +var detection = ArchiveFactory.DetectArchive(archivePath); +if (detection is null) { Console.WriteLine("Not a supported archive"); } -else if (info.SupportsRandomAccess) +else if (detection.SupportedApis.HasFlag(ArchiveAccessMode.Archive)) { using var archive = ArchiveFactory.OpenArchive(archivePath); archive.WriteToDirectory(@"D:\output"); @@ -248,7 +248,23 @@ else } ``` -`SupportsRandomAccess` is `false` for reader-only formats such as Ace, Arc, Arj, and standalone LZW. Use the Reader API for those formats. +`DetectArchive` does not enumerate entries. It reports both Archive and Reader API availability; reader-only formats include Ace, Arc, Arj, standalone LZW, and compressed TAR wrappers. + +### Inspect archive metadata + +```C# +var information = ArchiveFactory.InspectArchive(archivePath); +if (information is not null) +{ + Console.WriteLine($"Entries: {information.EntryCount}"); + Console.WriteLine($"Compressed bytes: {information.CompressedPayloadSize}"); + Console.WriteLine($"Solid streams: {information.SolidStreamCount}"); +} +``` + +`InspectArchive` parses archive metadata, which can require a complete sequential scan for TAR and compressed TAR files. It returns partial information for encrypted headers without a password or missing archive parts; inspect `Status` and `Limitations` before using nullable metadata values. + +The stream overload preserves the caller's current position and leaves the supplied stream open. ### Open multi-volume archives diff --git a/src/SharpCompress/Archives/ArchiveAccessMode.cs b/src/SharpCompress/Archives/ArchiveAccessMode.cs new file mode 100644 index 00000000..a86afdb2 --- /dev/null +++ b/src/SharpCompress/Archives/ArchiveAccessMode.cs @@ -0,0 +1,25 @@ +using System; + +namespace SharpCompress.Archives; + +/// +/// Specifies the APIs available for an archive format. +/// +[Flags] +public enum ArchiveAccessMode +{ + /// + /// No archive access API is available. + /// + None = 0, + + /// + /// The seekable API is available. + /// + Archive = 1, + + /// + /// The forward-only API is available. + /// + Reader = 2, +} diff --git a/src/SharpCompress/Archives/ArchiveDetection.cs b/src/SharpCompress/Archives/ArchiveDetection.cs new file mode 100644 index 00000000..fe961af8 --- /dev/null +++ b/src/SharpCompress/Archives/ArchiveDetection.cs @@ -0,0 +1,43 @@ +using SharpCompress.Common; + +namespace SharpCompress.Archives; + +/// +/// Identifies an archive format without enumerating its entries. +/// +public sealed class ArchiveDetection +{ + internal ArchiveDetection( + ArchiveType? containerType, + string formatName, + CompressionType? outerCompressionType, + ArchiveAccessMode supportedApis + ) + { + ContainerType = containerType; + FormatName = formatName; + OuterCompressionType = outerCompressionType; + SupportedApis = supportedApis; + } + + /// + /// Gets the logical archive container type, when it is a built-in SharpCompress type. + /// + public ArchiveType? ContainerType { get; } + + /// + /// Gets the detected format name. + /// + public string FormatName { get; } + + /// + /// Gets the compression wrapper around , if present. + /// For example, a tar.xz archive has a TAR container and XZ outer compression. + /// + public CompressionType? OuterCompressionType { get; } + + /// + /// Gets the APIs supported by the detected format. + /// + public ArchiveAccessMode SupportedApis { get; } +} diff --git a/src/SharpCompress/Archives/ArchiveEncryptionScope.cs b/src/SharpCompress/Archives/ArchiveEncryptionScope.cs new file mode 100644 index 00000000..5299e9bf --- /dev/null +++ b/src/SharpCompress/Archives/ArchiveEncryptionScope.cs @@ -0,0 +1,25 @@ +using System; + +namespace SharpCompress.Archives; + +/// +/// Identifies which archive data is encrypted. +/// +[Flags] +public enum ArchiveEncryptionScope +{ + /// + /// No encryption is present. + /// + None = 0, + + /// + /// One or more entry payloads are encrypted. + /// + EntryData = 1, + + /// + /// Archive headers are encrypted. + /// + Headers = 2, +} diff --git a/src/SharpCompress/Archives/ArchiveFactory.Detection.cs b/src/SharpCompress/Archives/ArchiveFactory.Detection.cs index 5674a462..caedc7fd 100644 --- a/src/SharpCompress/Archives/ArchiveFactory.Detection.cs +++ b/src/SharpCompress/Archives/ArchiveFactory.Detection.cs @@ -2,13 +2,8 @@ using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; -using SharpCompress.Archives.Rar; -using SharpCompress.Archives.SevenZip; using SharpCompress.Archives.Tar; -using SharpCompress.Archives.Zip; using SharpCompress.Common; -using SharpCompress.Common.Zip; -using SharpCompress.Common.Zip.Headers; using SharpCompress.Factories; using SharpCompress.IO; using SharpCompress.Providers; @@ -19,26 +14,24 @@ namespace SharpCompress.Archives; public static partial class ArchiveFactory { /// - /// Returns information about the archive at the given file path asynchronously, - /// or if the file is not a recognized archive. + /// Identifies the archive at the given file path without enumerating its entries. /// /// Path to the archive file. /// Cancellation token. - public static async ValueTask GetArchiveInformationAsync( + public static async ValueTask DetectArchiveAsync( string filePath, CancellationToken cancellationToken = default ) => - await GetArchiveInformationAsync(filePath, ReaderOptions.ForFilePath, cancellationToken) + await DetectArchiveAsync(filePath, ReaderOptions.ForFilePath, cancellationToken) .ConfigureAwait(false); /// - /// Returns information about the archive at the given file path asynchronously, - /// or if the file is not a recognized archive. + /// Identifies the archive at the given file path without enumerating its entries. /// /// Path to the archive file. /// Options controlling archive detection. /// Cancellation token. - public static async ValueTask GetArchiveInformationAsync( + public static async ValueTask DetectArchiveAsync( string filePath, ReaderOptions? readerOptions, CancellationToken cancellationToken = default @@ -46,7 +39,7 @@ public static partial class ArchiveFactory { filePath.NotNullOrEmpty(nameof(filePath)); using Stream stream = File.OpenRead(filePath); - return await GetArchiveInformationAsync( + return await DetectArchiveAsync( stream, readerOptions ?? ReaderOptions.ForFilePath, cancellationToken @@ -55,26 +48,24 @@ public static partial class ArchiveFactory } /// - /// Returns information about the archive in the given stream asynchronously, - /// or if the stream is not a recognized archive. + /// Identifies the archive in the given stream without enumerating its entries. /// /// A readable and seekable stream positioned at the start of the archive. /// Cancellation token. - public static async ValueTask GetArchiveInformationAsync( + public static async ValueTask DetectArchiveAsync( Stream stream, CancellationToken cancellationToken = default ) => - await GetArchiveInformationAsync(stream, ReaderOptions.ForExternalStream, cancellationToken) + await DetectArchiveAsync(stream, ReaderOptions.ForExternalStream, cancellationToken) .ConfigureAwait(false); /// - /// Returns information about the archive in the given stream asynchronously, - /// or if the stream is not a recognized archive. + /// Identifies the archive in the given stream without enumerating its entries. /// /// A readable and seekable stream positioned at the start of the archive. /// Options controlling archive detection. /// Cancellation token. - public static async ValueTask GetArchiveInformationAsync( + public static async ValueTask DetectArchiveAsync( Stream stream, ReaderOptions? readerOptions, CancellationToken cancellationToken = default @@ -83,19 +74,12 @@ public static partial class ArchiveFactory stream.RequireReadable(); stream.RequireSeekable(); - var factory = await TryFindFactoryAsync( + return await TryDetectArchiveAsync( stream, readerOptions ?? ReaderOptions.ForExternalStream, cancellationToken ) .ConfigureAwait(false); - return factory is null - ? null - : BuildArchiveInformation( - stream, - readerOptions ?? ReaderOptions.ForExternalStream, - factory - ); } internal static ValueTask FindFactoryAsync( @@ -187,87 +171,80 @@ public static partial class ArchiveFactory { var startPosition = stream.Position; - foreach (var factory in Factory.Factories) + try { - stream.Seek(startPosition, SeekOrigin.Begin); - var isArchive = await factory - .IsArchiveAsync(stream, readerOptions, cancellationToken) - .ConfigureAwait(false); - - if (isArchive) + foreach (var factory in Factory.Factories) { stream.Seek(startPosition, SeekOrigin.Begin); - if ( - await IsCompressedTarAsync(stream, factory, readerOptions, cancellationToken) - .ConfigureAwait(false) - ) + var isArchive = await factory + .IsArchiveAsync(stream, readerOptions, cancellationToken) + .ConfigureAwait(false); + + if (isArchive) { - continue; + stream.Seek(startPosition, SeekOrigin.Begin); + if ( + await IsCompressedTarAsync( + stream, + factory, + readerOptions, + cancellationToken + ) + .ConfigureAwait(false) + ) + { + continue; + } + + return factory; } - - stream.Seek(startPosition, SeekOrigin.Begin); - return factory; } - } - stream.Seek(startPosition, SeekOrigin.Begin); - return null; + return null; + } + finally + { + stream.Seek(startPosition, SeekOrigin.Begin); + } } /// - /// Returns information about the archive at the given file path, - /// or if the file is not a recognized archive. + /// Identifies the archive at the given file path without enumerating its entries. /// /// Path to the archive file. - public static ArchiveInformation? GetArchiveInformation(string filePath) => - GetArchiveInformation(filePath, ReaderOptions.ForFilePath); + public static ArchiveDetection? DetectArchive(string filePath) => + DetectArchive(filePath, ReaderOptions.ForFilePath); /// - /// Returns information about the archive at the given file path, - /// or if the file is not a recognized archive. + /// Identifies the archive at the given file path without enumerating its entries. /// /// Path to the archive file. /// Options controlling archive detection. - public static ArchiveInformation? GetArchiveInformation( - string filePath, - ReaderOptions? readerOptions - ) + public static ArchiveDetection? DetectArchive(string filePath, ReaderOptions? readerOptions) { filePath.NotNullOrEmpty(nameof(filePath)); using Stream stream = File.OpenRead(filePath); - return GetArchiveInformation(stream, readerOptions ?? ReaderOptions.ForFilePath); + return DetectArchive(stream, readerOptions ?? ReaderOptions.ForFilePath); } /// - /// Returns information about the archive in the given stream, - /// or if the stream is not a recognized archive. + /// Identifies the archive in the given stream without enumerating its entries. /// /// A readable and seekable stream positioned at the start of the archive. - public static ArchiveInformation? GetArchiveInformation(Stream stream) => - GetArchiveInformation(stream, ReaderOptions.ForExternalStream); + public static ArchiveDetection? DetectArchive(Stream stream) => + DetectArchive(stream, ReaderOptions.ForExternalStream); /// - /// Returns information about the archive in the given stream, - /// or if the stream is not a recognized archive. + /// Identifies the archive in the given stream without enumerating its entries. /// /// A readable and seekable stream positioned at the start of the archive. /// Options controlling archive detection. - public static ArchiveInformation? GetArchiveInformation( - Stream stream, - ReaderOptions? readerOptions - ) + public static ArchiveDetection? DetectArchive(Stream stream, ReaderOptions? readerOptions) { stream.RequireReadable(); stream.RequireSeekable(); - var factory = TryFindFactory(stream, readerOptions ?? ReaderOptions.ForExternalStream); - return factory is null - ? null - : BuildArchiveInformation( - stream, - readerOptions ?? ReaderOptions.ForExternalStream, - factory - ); + return TryDetectArchive(stream, readerOptions ?? ReaderOptions.ForExternalStream); } /// @@ -278,7 +255,7 @@ public static partial class ArchiveFactory /// /// This is the shared, seekable-stream detection core used by /// , , - /// and . + /// and . /// /// uses a separate code path /// based on rewindable buffering, which supports @@ -292,26 +269,31 @@ public static partial class ArchiveFactory { var startPosition = stream.Position; - foreach (var factory in Factory.Factories) + try { - stream.Seek(startPosition, SeekOrigin.Begin); - var isArchive = factory.IsArchive(stream, readerOptions); - - if (isArchive) + foreach (var factory in Factory.Factories) { stream.Seek(startPosition, SeekOrigin.Begin); - if (IsCompressedTar(stream, factory, readerOptions)) + var isArchive = factory.IsArchive(stream, readerOptions); + + if (isArchive) { - continue; + stream.Seek(startPosition, SeekOrigin.Begin); + if (IsCompressedTar(stream, factory, readerOptions)) + { + continue; + } + + return factory; } - - stream.Seek(startPosition, SeekOrigin.Begin); - return factory; } - } - stream.Seek(startPosition, SeekOrigin.Begin); - return null; + return null; + } + finally + { + stream.Seek(startPosition, SeekOrigin.Begin); + } } private static bool IsCompressedTar( @@ -425,114 +407,184 @@ public static partial class ArchiveFactory _ => null, }; - private static ArchiveInformation BuildArchiveInformation( - Stream stream, - ReaderOptions readerOptions, - IFactory factory - ) + private static ArchiveDetection? TryDetectArchive(Stream stream, ReaderOptions readerOptions) { - var info = new ArchiveInformation(factory.KnownArchiveType, factory is IArchiveFactory); var startPosition = stream.Position; try { - var probeReaderOptions = readerOptions with { LeaveStreamOpen = true }; - switch (factory) + foreach (var factory in Factory.Factories) { - case ZipFactory: - TryPopulateZipDetails(info, stream, probeReaderOptions); - break; - case SevenZipFactory: - TryPopulateSevenZipDetails(info, stream, probeReaderOptions); - break; - case RarFactory: - TryPopulateRarDetails(info, stream, probeReaderOptions); - break; + stream.Seek(startPosition, SeekOrigin.Begin); + if (!factory.IsArchive(stream, readerOptions)) + { + continue; + } + + if (GetCompressedTarType(factory) is { } compressionType) + { + stream.Seek(startPosition, SeekOrigin.Begin); + if (IsCompressedTar(stream, readerOptions, compressionType)) + { + return CreateCompressedTarDetection(compressionType); + } + } + + return CreateDetection(factory); } + + return + TryDetectCompressedTar(stream, readerOptions, startPosition) + is { } compressedTarType + ? CreateCompressedTarDetection(compressedTarType) + : null; } finally { stream.Seek(startPosition, SeekOrigin.Begin); } - - return info; } - private static void TryPopulateZipDetails( - ArchiveInformation info, + private static async ValueTask TryDetectArchiveAsync( Stream stream, - ReaderOptions readerOptions + ReaderOptions readerOptions, + CancellationToken cancellationToken ) { + var startPosition = stream.Position; + try { - info.ZipDataDescriptorEntryCount = GetZipDataDescriptorEntryCount( - stream, - readerOptions - ); + foreach (var factory in Factory.Factories) + { + stream.Seek(startPosition, SeekOrigin.Begin); + if ( + !await factory + .IsArchiveAsync(stream, readerOptions, cancellationToken) + .ConfigureAwait(false) + ) + { + continue; + } + + if (GetCompressedTarType(factory) is { } compressionType) + { + stream.Seek(startPosition, SeekOrigin.Begin); + if ( + await IsCompressedTarAsync( + stream, + readerOptions, + compressionType, + cancellationToken + ) + .ConfigureAwait(false) + ) + { + return CreateCompressedTarDetection(compressionType); + } + } + + return CreateDetection(factory); + } + + var compressedTarType = await TryDetectCompressedTarAsync( + stream, + readerOptions, + startPosition, + cancellationToken + ) + .ConfigureAwait(false); + return compressedTarType is { } value ? CreateCompressedTarDetection(value) : null; } - catch + finally { - // Keep archive detection resilient even when format-specific probing fails. + stream.Seek(startPosition, SeekOrigin.Begin); } } - private static void TryPopulateSevenZipDetails( - ArchiveInformation info, + private static CompressionType? TryDetectCompressedTar( Stream stream, - ReaderOptions readerOptions + ReaderOptions readerOptions, + long startPosition ) { - try + foreach (var wrapper in TarWrapper.Wrappers) { - info.SolidStreamCount = GetSevenZipSolidStreamCount(stream, readerOptions); - } - catch - { - // Keep archive detection resilient even when format-specific probing fails. + if (wrapper.CompressionType == CompressionType.None) + { + continue; + } + + stream.Seek(startPosition, SeekOrigin.Begin); + if (!wrapper.IsMatch(stream)) + { + continue; + } + + stream.Seek(startPosition, SeekOrigin.Begin); + if (IsCompressedTar(stream, readerOptions, wrapper.CompressionType)) + { + return wrapper.CompressionType; + } } + + return null; } - private static void TryPopulateRarDetails( - ArchiveInformation info, + private static async ValueTask TryDetectCompressedTarAsync( Stream stream, - ReaderOptions readerOptions + ReaderOptions readerOptions, + long startPosition, + CancellationToken cancellationToken ) { - try + foreach (var wrapper in TarWrapper.Wrappers) { - info.SolidStreamCount = GetRarSolidStreamCount(stream, readerOptions); + cancellationToken.ThrowIfCancellationRequested(); + if (wrapper.CompressionType == CompressionType.None) + { + continue; + } + + stream.Seek(startPosition, SeekOrigin.Begin); + if (!await wrapper.IsMatchAsync(stream, cancellationToken).ConfigureAwait(false)) + { + continue; + } + + stream.Seek(startPosition, SeekOrigin.Begin); + if ( + await IsCompressedTarAsync( + stream, + readerOptions, + wrapper.CompressionType, + cancellationToken + ) + .ConfigureAwait(false) + ) + { + return wrapper.CompressionType; + } } - catch + + return null; + } + + private static ArchiveDetection CreateDetection(IFactory factory) + { + var supportedApis = ArchiveAccessMode.None; + if (factory is IArchiveFactory) { - // Keep archive detection resilient even when format-specific probing fails. + supportedApis |= ArchiveAccessMode.Archive; } + if (factory is IReaderFactory) + { + supportedApis |= ArchiveAccessMode.Reader; + } + + return new ArchiveDetection(factory.KnownArchiveType, factory.Name, null, supportedApis); } - private static int GetZipDataDescriptorEntryCount(Stream stream, ReaderOptions readerOptions) - { - using var archive = ZipArchive.OpenArchive(stream, readerOptions); - return archive - .Entries.OfType() - .SelectMany(entry => entry.Parts.OfType()) - .Count(part => - FlagUtility.HasFlag(part.Header.Flags, HeaderFlags.UsePostDataDescriptor) - ); - } - - private static int GetSevenZipSolidStreamCount(Stream stream, ReaderOptions readerOptions) - { - using var archive = SevenZipArchive.OpenArchive(stream, readerOptions); - return archive - .Entries.OfType() - .Where(entry => !entry.IsDirectory && entry.FilePart.Folder is not null) - .GroupBy(entry => entry.FilePart.Folder) - .Count(group => group.Skip(1).Any()); - } - - private static int GetRarSolidStreamCount(Stream stream, ReaderOptions readerOptions) - { - using var archive = RarArchive.OpenArchive(stream, readerOptions); - return archive.IsSolid ? 1 : 0; - } + private static ArchiveDetection CreateCompressedTarDetection(CompressionType compressionType) => + new(ArchiveType.Tar, "Tar", compressionType, ArchiveAccessMode.Reader); } diff --git a/src/SharpCompress/Archives/ArchiveFactory.Information.Async.cs b/src/SharpCompress/Archives/ArchiveFactory.Information.Async.cs new file mode 100644 index 00000000..9c8076ef --- /dev/null +++ b/src/SharpCompress/Archives/ArchiveFactory.Information.Async.cs @@ -0,0 +1,537 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives.Rar; +using SharpCompress.Archives.SevenZip; +using SharpCompress.Common; +using SharpCompress.Common.Ace.Headers; +using SharpCompress.Common.Rar; +using SharpCompress.Common.Zip; +using SharpCompress.IO; +using SharpCompress.Readers; + +namespace SharpCompress.Archives; + +public static partial class ArchiveFactory +{ + /// + /// Asynchronously collects metadata for the archive at the given file path. + /// + /// Path to the archive file. + /// Cancellation token. + /// Archive metadata, or when the file is not a supported archive. + public static async ValueTask InspectArchiveAsync( + string filePath, + CancellationToken cancellationToken = default + ) => + await InspectArchiveAsync(filePath, ReaderOptions.ForFilePath, cancellationToken) + .ConfigureAwait(false); + + /// + /// Asynchronously collects metadata for the archive at the given file path. + /// + /// Path to the archive file. + /// Options controlling archive inspection. + /// Cancellation token. + /// Archive metadata, or when the file is not a supported archive. + public static async ValueTask InspectArchiveAsync( + string filePath, + ReaderOptions? readerOptions, + CancellationToken cancellationToken = default + ) + { + filePath.NotNullOrEmpty(nameof(filePath)); + var options = readerOptions ?? ReaderOptions.ForFilePath; + var detection = await DetectArchiveAsync(filePath, options, cancellationToken) + .ConfigureAwait(false); + if (detection is null) + { + return null; + } + if ((detection.SupportedApis & ArchiveAccessMode.Archive) != 0) + { + var fileInfos = GetArchiveFileParts(new FileInfo(filePath), options); + if (fileInfos.Length > 1) + { + return await InspectArchiveAsync(fileInfos, options, cancellationToken) + .ConfigureAwait(false); + } + } + + using Stream stream = File.OpenRead(filePath); + return await InspectArchiveAsync(stream, options, cancellationToken).ConfigureAwait(false); + } + + /// + /// Asynchronously collects metadata for the archive in the given stream. + /// + /// A readable and seekable stream positioned at the start of the archive. + /// Cancellation token. + /// Archive metadata, or when the stream is not a supported archive. + public static async ValueTask InspectArchiveAsync( + Stream stream, + CancellationToken cancellationToken = default + ) => + await InspectArchiveAsync(stream, ReaderOptions.ForExternalStream, cancellationToken) + .ConfigureAwait(false); + + /// + /// Asynchronously collects metadata for the archive in the given stream. + /// + /// A readable and seekable stream positioned at the start of the archive. + /// Options controlling archive inspection. + /// Cancellation token. + /// Archive metadata, or when the stream is not a supported archive. + /// The supplied stream remains open and is restored to its original position. + public static async ValueTask InspectArchiveAsync( + Stream stream, + ReaderOptions? readerOptions, + CancellationToken cancellationToken = default + ) + { + stream.RequireReadable(); + stream.RequireSeekable(); + cancellationToken.ThrowIfCancellationRequested(); + + var options = readerOptions ?? ReaderOptions.ForExternalStream; + var startPosition = stream.Position; + var physicalSize = GetPhysicalSize(stream, startPosition); + + try + { + using var archiveStream = new ArchiveOffsetStream(stream); + var inspectionOptions = options with { LeaveStreamOpen = true }; + var detection = await TryDetectArchiveAsync( + archiveStream, + inspectionOptions, + cancellationToken + ) + .ConfigureAwait(false); + if (detection is null) + { + return null; + } + + if ((detection.SupportedApis & ArchiveAccessMode.Archive) != 0) + { + await using var archive = await OpenAsyncArchive( + archiveStream, + inspectionOptions, + cancellationToken + ) + .ConfigureAwait(false); + return await InspectOpenedArchiveAsync( + archive, + detection, + physicalSize, + 1, + cancellationToken + ) + .ConfigureAwait(false); + } + + var aceHeader = await ReadAceHeaderAsync( + archiveStream, + detection, + inspectionOptions, + cancellationToken + ) + .ConfigureAwait(false); + await using var reader = await ReaderFactory + .OpenAsyncReader(archiveStream, inspectionOptions, cancellationToken) + .ConfigureAwait(false); + return await InspectOpenedReaderAsync( + reader, + detection, + physicalSize, + 1, + aceHeader, + cancellationToken + ) + .ConfigureAwait(false); + } + catch (CryptographicException) when (string.IsNullOrEmpty(options.Password)) + { + var detection = await RedetectArchiveAsync( + stream, + startPosition, + options, + cancellationToken + ) + .ConfigureAwait(false); + return detection is null + ? null + : CreatePartialInformation( + detection, + physicalSize, + 1, + ArchiveInformationLimitations.EncryptedHeaders + ); + } + catch (MultipartStreamRequiredException) + { + var detection = await RedetectArchiveAsync( + stream, + startPosition, + options, + cancellationToken + ) + .ConfigureAwait(false); + return detection is null + ? null + : CreatePartialInformation( + detection, + physicalSize, + 1, + ArchiveInformationLimitations.MissingVolumes + ); + } + finally + { + stream.Seek(startPosition, SeekOrigin.Begin); + } + } + + /// + /// Asynchronously collects metadata for an archive opened from multiple files. + /// + /// Archive source files in archive order. + /// Options controlling archive inspection. + /// Cancellation token. + /// Archive metadata, or when the files are not a supported archive. + public static async ValueTask InspectArchiveAsync( + IReadOnlyList fileInfos, + ReaderOptions? readerOptions = null, + CancellationToken cancellationToken = default + ) + { + fileInfos.NotNull(nameof(fileInfos)); + if (fileInfos.Count == 0) + { + throw new ArchiveOperationException("No files to inspect"); + } + if (fileInfos.Count == 1) + { + return await InspectArchiveAsync( + fileInfos[0].FullName, + readerOptions, + cancellationToken + ) + .ConfigureAwait(false); + } + + var options = readerOptions ?? ReaderOptions.ForFilePath; + var detection = await DetectArchiveAsync(fileInfos[0].FullName, options, cancellationToken) + .ConfigureAwait(false); + if (detection is null) + { + return null; + } + if ((detection.SupportedApis & ArchiveAccessMode.Archive) == 0) + { + throw new NotSupportedException( + "Inspecting multiple source files is supported only for formats with an Archive API." + ); + } + + var physicalSize = GetPhysicalSize(fileInfos); + try + { + await using var archive = await OpenAsyncArchive(fileInfos, options, cancellationToken) + .ConfigureAwait(false); + return await InspectOpenedArchiveAsync( + archive, + detection, + physicalSize, + fileInfos.Count, + cancellationToken + ) + .ConfigureAwait(false); + } + catch (CryptographicException) when (string.IsNullOrEmpty(options.Password)) + { + return CreatePartialInformation( + detection, + physicalSize, + fileInfos.Count, + ArchiveInformationLimitations.EncryptedHeaders + ); + } + } + + /// + /// Asynchronously collects metadata for an archive opened from multiple streams. + /// + /// Archive source streams in archive order. + /// Options controlling archive inspection. + /// Cancellation token. + /// Archive metadata, or when the streams are not a supported archive. + public static async ValueTask InspectArchiveAsync( + IReadOnlyList streams, + ReaderOptions? readerOptions = null, + CancellationToken cancellationToken = default + ) + { + streams.NotNull(nameof(streams)); + if (streams.Count == 0) + { + throw new ArchiveOperationException("No streams to inspect"); + } + if (streams.Count == 1) + { + return await InspectArchiveAsync(streams[0], readerOptions, cancellationToken) + .ConfigureAwait(false); + } + + var options = readerOptions ?? ReaderOptions.ForExternalStream; + var startPositions = streams.Select(stream => stream.Position).ToArray(); + var physicalSize = GetPhysicalSize(streams, startPositions); + + try + { + using var firstArchiveStream = new ArchiveOffsetStream(streams[0]); + var inspectionOptions = options with { LeaveStreamOpen = true }; + var detection = await TryDetectArchiveAsync( + firstArchiveStream, + inspectionOptions, + cancellationToken + ) + .ConfigureAwait(false); + if (detection is null) + { + return null; + } + if ((detection.SupportedApis & ArchiveAccessMode.Archive) == 0) + { + throw new NotSupportedException( + "Inspecting multiple source streams is supported only for formats with an Archive API." + ); + } + + var archiveStreams = new List { firstArchiveStream }; + archiveStreams.AddRange( + streams.Skip(1).Select(stream => new ArchiveOffsetStream(stream)) + ); + await using var archive = await OpenAsyncArchive( + archiveStreams, + inspectionOptions, + cancellationToken + ) + .ConfigureAwait(false); + return await InspectOpenedArchiveAsync( + archive, + detection, + physicalSize, + streams.Count, + cancellationToken + ) + .ConfigureAwait(false); + } + catch (CryptographicException) when (string.IsNullOrEmpty(options.Password)) + { + var detection = await RedetectArchiveAsync( + streams[0], + startPositions[0], + options, + cancellationToken + ) + .ConfigureAwait(false); + return detection is null + ? null + : CreatePartialInformation( + detection, + physicalSize, + streams.Count, + ArchiveInformationLimitations.EncryptedHeaders + ); + } + finally + { + for (var i = 0; i < streams.Count; i++) + { + streams[i].Seek(startPositions[i], SeekOrigin.Begin); + } + } + } + + private static async ValueTask InspectOpenedArchiveAsync( + IAsyncArchive archive, + ArchiveDetection detection, + long? physicalSize, + int physicalPartCount, + CancellationToken cancellationToken + ) + { + var entries = new List(); + await foreach ( + var entry in archive + .EntriesAsync.WithCancellation(cancellationToken) + .ConfigureAwait(false) + ) + { + cancellationToken.ThrowIfCancellationRequested(); + entries.Add(entry); + } + + var volumes = new List(); + await foreach ( + var volume in archive + .VolumesAsync.WithCancellation(cancellationToken) + .ConfigureAwait(false) + ) + { + cancellationToken.ThrowIfCancellationRequested(); + volumes.Add(volume); + } + + var entryArray = entries.ToArray(); + var volumeArray = volumes.ToArray(); + var zip = GetZipInformation(entryArray); + var (isSolid, solidStreamCount) = await GetSolidInformationAsync(archive, entryArray) + .ConfigureAwait(false); + var isComplete = await archive.IsCompleteAsync().ConfigureAwait(false); + var limitations = isComplete + ? ArchiveInformationLimitations.None + : ArchiveInformationLimitations.MissingVolumes; + if (archive.Type == ArchiveType.GZip) + { + limitations |= ArchiveInformationLimitations.UnavailableMetadata; + } + var hasEncryptedHeaders = volumeArray + .OfType() + .Any(volume => volume.IsHeaderEncrypted); + var hasEncryptedEntries = entryArray.Any(entry => entry.IsEncrypted); + + return new ArchiveInformation( + detection, + GetStatus(limitations), + limitations, + GetFormatVersion(volumeArray), + entryArray.LongLength, + zip?.DataDescriptorEntryCount ?? 0, + physicalSize, + isComplete ? GetCompressedPayloadSize(archive, entryArray) : null, + isComplete && archive.Type != ArchiveType.GZip + ? entryArray.Aggregate(0L, (total, entry) => total + entry.Size) + : null, + isSolid, + solidStreamCount, + GetEncryptionScope(hasEncryptedHeaders, hasEncryptedEntries), + hasEncryptedHeaders || hasEncryptedEntries, + GetIsMultiVolume(archive.Type, volumeArray), + physicalPartCount, + volumeArray.Length, + isComplete, + GetArchiveComment(volumeArray), + zip + ); + } + + private static async ValueTask InspectOpenedReaderAsync( + IAsyncReader reader, + ArchiveDetection detection, + long? physicalSize, + int physicalPartCount, + AceMainHeader? aceHeader, + CancellationToken cancellationToken + ) + { + var inspection = new ReaderInspection(detection); + while (await reader.MoveToNextEntryAsync(cancellationToken).ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + inspection.Add(reader.Entry); + } + + var isSolid = aceHeader?.IsSolid ?? false; + var isMultiVolume = aceHeader?.IsMultiVolume ?? false; + var formatVersion = aceHeader is null ? null : $"ACE {aceHeader.AceVersion / 10.0:0.0}"; + return inspection.CreateInformation( + physicalSize, + physicalPartCount, + isSolid, + isSolid && inspection.EntryCount > 1 ? 1 : 0, + isMultiVolume, + formatVersion + ); + } + + private static async ValueTask ReadAceHeaderAsync( + Stream stream, + ArchiveDetection detection, + ReaderOptions options, + CancellationToken cancellationToken + ) + { + if (detection.ContainerType != ArchiveType.Ace) + { + return null; + } + + try + { + stream.Position = 0; + return await new AceMainHeader(options.ArchiveEncoding) + .ReadAsync(stream, cancellationToken) + .ConfigureAwait(false) as AceMainHeader; + } + finally + { + stream.Position = 0; + } + } + + private static async ValueTask<(bool IsSolid, long SolidStreamCount)> GetSolidInformationAsync( + IAsyncArchive archive, + IReadOnlyCollection entries + ) + { + if (archive.Type == ArchiveType.SevenZip) + { + var solidStreamCount = entries + .OfType() + .Where(entry => !entry.IsDirectory && entry.FilePart.Folder is not null) + .GroupBy(entry => entry.FilePart.Folder) + .LongCount(group => group.Skip(1).Any()); + return (solidStreamCount > 0, solidStreamCount); + } + + if (archive.Type == ArchiveType.Rar) + { + return ( + await archive.IsSolidAsync().ConfigureAwait(false), + CountRarSolidStreams(entries) + ); + } + + return (false, 0); + } + + private static long? GetCompressedPayloadSize( + IAsyncArchive archive, + IReadOnlyCollection entries + ) => + archive.Type switch + { + ArchiveType.GZip => null, + ArchiveType.SevenZip when archive is SevenZipArchive sevenZipArchive => + sevenZipArchive.TotalSize, + _ => entries.Aggregate(0L, (total, entry) => total + entry.CompressedSize), + }; + + private static async ValueTask RedetectArchiveAsync( + Stream stream, + long startPosition, + ReaderOptions options, + CancellationToken cancellationToken + ) + { + stream.Seek(startPosition, SeekOrigin.Begin); + using var archiveStream = new ArchiveOffsetStream(stream); + return await TryDetectArchiveAsync(archiveStream, options, cancellationToken) + .ConfigureAwait(false); + } +} diff --git a/src/SharpCompress/Archives/ArchiveFactory.Information.cs b/src/SharpCompress/Archives/ArchiveFactory.Information.cs new file mode 100644 index 00000000..1179a41a --- /dev/null +++ b/src/SharpCompress/Archives/ArchiveFactory.Information.cs @@ -0,0 +1,682 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives.Rar; +using SharpCompress.Archives.SevenZip; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Common.Rar; +using SharpCompress.Common.Zip; +using SharpCompress.Common.Zip.Headers; +using SharpCompress.IO; +using SharpCompress.Readers; +using AceMainHeader = SharpCompress.Common.Ace.Headers.AceMainHeader; + +namespace SharpCompress.Archives; + +public static partial class ArchiveFactory +{ + /// + /// Collects metadata for the archive at the given file path. + /// + /// Path to the archive file. + /// Archive metadata, or when the file is not a supported archive. + public static ArchiveInformation? InspectArchive(string filePath) => + InspectArchive(filePath, ReaderOptions.ForFilePath); + + /// + /// Collects metadata for the archive at the given file path. + /// + /// Path to the archive file. + /// Options controlling archive inspection. + /// Archive metadata, or when the file is not a supported archive. + public static ArchiveInformation? InspectArchive(string filePath, ReaderOptions? readerOptions) + { + filePath.NotNullOrEmpty(nameof(filePath)); + var options = readerOptions ?? ReaderOptions.ForFilePath; + var detection = DetectArchive(filePath, options); + if (detection is null) + { + return null; + } + if ((detection.SupportedApis & ArchiveAccessMode.Archive) != 0) + { + var fileInfos = GetArchiveFileParts(new FileInfo(filePath), options); + if (fileInfos.Length > 1) + { + return InspectArchive(fileInfos, options); + } + } + + using Stream stream = File.OpenRead(filePath); + return InspectArchive(stream, options); + } + + /// + /// Collects metadata for the archive in the given stream. + /// + /// A readable and seekable stream positioned at the start of the archive. + /// Archive metadata, or when the stream is not a supported archive. + public static ArchiveInformation? InspectArchive(Stream stream) => + InspectArchive(stream, ReaderOptions.ForExternalStream); + + /// + /// Collects metadata for the archive in the given stream. + /// + /// A readable and seekable stream positioned at the start of the archive. + /// Options controlling archive inspection. + /// Archive metadata, or when the stream is not a supported archive. + /// The supplied stream remains open and is restored to its original position. + public static ArchiveInformation? InspectArchive(Stream stream, ReaderOptions? readerOptions) + { + stream.RequireReadable(); + stream.RequireSeekable(); + + var options = readerOptions ?? ReaderOptions.ForExternalStream; + var startPosition = stream.Position; + var physicalSize = GetPhysicalSize(stream, startPosition); + + try + { + using var archiveStream = new ArchiveOffsetStream(stream); + var inspectionOptions = options with { LeaveStreamOpen = true }; + var detection = TryDetectArchive(archiveStream, inspectionOptions); + if (detection is null) + { + return null; + } + + if ((detection.SupportedApis & ArchiveAccessMode.Archive) != 0) + { + using var archive = OpenArchive(archiveStream, inspectionOptions); + return InspectOpenedArchive(archive, detection, physicalSize, 1); + } + + var aceHeader = ReadAceHeader(archiveStream, detection, inspectionOptions); + using var reader = ReaderFactory.OpenReader(archiveStream, inspectionOptions); + return InspectOpenedReader(reader, detection, physicalSize, 1, aceHeader); + } + catch (CryptographicException) when (string.IsNullOrEmpty(options.Password)) + { + var detection = RedetectArchive(stream, startPosition, options); + return detection is null + ? null + : CreatePartialInformation( + detection, + physicalSize, + 1, + ArchiveInformationLimitations.EncryptedHeaders + ); + } + catch (MultipartStreamRequiredException) + { + var detection = RedetectArchive(stream, startPosition, options); + return detection is null + ? null + : CreatePartialInformation( + detection, + physicalSize, + 1, + ArchiveInformationLimitations.MissingVolumes + ); + } + finally + { + stream.Seek(startPosition, SeekOrigin.Begin); + } + } + + /// + /// Collects metadata for an archive opened from multiple files. + /// + /// Archive source files in archive order. + /// Options controlling archive inspection. + /// Archive metadata, or when the files are not a supported archive. + public static ArchiveInformation? InspectArchive( + IReadOnlyList fileInfos, + ReaderOptions? readerOptions = null + ) + { + fileInfos.NotNull(nameof(fileInfos)); + if (fileInfos.Count == 0) + { + throw new ArchiveOperationException("No files to inspect"); + } + if (fileInfos.Count == 1) + { + return InspectArchive(fileInfos[0].FullName, readerOptions); + } + + var options = readerOptions ?? ReaderOptions.ForFilePath; + var detection = DetectArchive(fileInfos[0].FullName, options); + if (detection is null) + { + return null; + } + if ((detection.SupportedApis & ArchiveAccessMode.Archive) == 0) + { + throw new NotSupportedException( + "Inspecting multiple source files is supported only for formats with an Archive API." + ); + } + + var physicalSize = GetPhysicalSize(fileInfos); + try + { + using var archive = OpenArchive(fileInfos, options); + return InspectOpenedArchive(archive, detection, physicalSize, fileInfos.Count); + } + catch (CryptographicException) when (string.IsNullOrEmpty(options.Password)) + { + return CreatePartialInformation( + detection, + physicalSize, + fileInfos.Count, + ArchiveInformationLimitations.EncryptedHeaders + ); + } + } + + /// + /// Collects metadata for an archive opened from multiple streams. + /// + /// Archive source streams in archive order. + /// Options controlling archive inspection. + /// Archive metadata, or when the streams are not a supported archive. + public static ArchiveInformation? InspectArchive( + IReadOnlyList streams, + ReaderOptions? readerOptions = null + ) + { + streams.NotNull(nameof(streams)); + if (streams.Count == 0) + { + throw new ArchiveOperationException("No streams to inspect"); + } + if (streams.Count == 1) + { + return InspectArchive(streams[0], readerOptions); + } + + var options = readerOptions ?? ReaderOptions.ForExternalStream; + var startPositions = streams.Select(stream => stream.Position).ToArray(); + var physicalSize = GetPhysicalSize(streams, startPositions); + + try + { + using var firstArchiveStream = new ArchiveOffsetStream(streams[0]); + var inspectionOptions = options with { LeaveStreamOpen = true }; + var detection = TryDetectArchive(firstArchiveStream, inspectionOptions); + if (detection is null) + { + return null; + } + if ((detection.SupportedApis & ArchiveAccessMode.Archive) == 0) + { + throw new NotSupportedException( + "Inspecting multiple source streams is supported only for formats with an Archive API." + ); + } + + var archiveStreams = new List { firstArchiveStream }; + archiveStreams.AddRange( + streams.Skip(1).Select(stream => new ArchiveOffsetStream(stream)) + ); + using var archive = OpenArchive(archiveStreams, inspectionOptions); + return InspectOpenedArchive(archive, detection, physicalSize, streams.Count); + } + catch (CryptographicException) when (string.IsNullOrEmpty(options.Password)) + { + var detection = RedetectArchive(streams[0], startPositions[0], options); + return detection is null + ? null + : CreatePartialInformation( + detection, + physicalSize, + streams.Count, + ArchiveInformationLimitations.EncryptedHeaders + ); + } + finally + { + for (var i = 0; i < streams.Count; i++) + { + streams[i].Seek(startPositions[i], SeekOrigin.Begin); + } + } + } + + private static ArchiveInformation InspectOpenedArchive( + IArchive archive, + ArchiveDetection detection, + long? physicalSize, + int physicalPartCount + ) + { + var entries = archive.Entries.ToArray(); + var volumes = archive.Volumes.ToArray(); + var zip = GetZipInformation(entries); + var (isSolid, solidStreamCount) = GetSolidInformation(archive, entries); + var isMultiVolume = GetIsMultiVolume(archive.Type, volumes); + var comment = GetArchiveComment(volumes); + var hasEncryptedHeaders = volumes + .OfType() + .Any(volume => volume.IsHeaderEncrypted); + var hasEncryptedEntries = entries.Any(entry => entry.IsEncrypted); + var isComplete = archive.IsComplete; + var limitations = isComplete + ? ArchiveInformationLimitations.None + : ArchiveInformationLimitations.MissingVolumes; + if (archive.Type == ArchiveType.GZip) + { + limitations |= ArchiveInformationLimitations.UnavailableMetadata; + } + + return new ArchiveInformation( + detection, + GetStatus(limitations), + limitations, + GetFormatVersion(volumes), + entries.LongLength, + zip?.DataDescriptorEntryCount ?? 0, + physicalSize, + isComplete ? GetCompressedPayloadSize(archive, entries) : null, + isComplete && archive.Type != ArchiveType.GZip + ? entries.Aggregate(0L, (total, entry) => total + entry.Size) + : null, + isSolid, + solidStreamCount, + GetEncryptionScope(hasEncryptedHeaders, hasEncryptedEntries), + hasEncryptedHeaders || hasEncryptedEntries, + isMultiVolume, + physicalPartCount, + volumes.Length, + isComplete, + comment, + zip + ); + } + + private static ArchiveInformation InspectOpenedReader( + IReader reader, + ArchiveDetection detection, + long? physicalSize, + int physicalPartCount, + AceMainHeader? aceHeader + ) + { + var inspection = new ReaderInspection(detection); + while (reader.MoveToNextEntry()) + { + inspection.Add(reader.Entry); + } + + var isSolid = aceHeader?.IsSolid ?? false; + var isMultiVolume = aceHeader?.IsMultiVolume ?? false; + var formatVersion = aceHeader is null ? null : $"ACE {aceHeader.AceVersion / 10.0:0.0}"; + return inspection.CreateInformation( + physicalSize, + physicalPartCount, + isSolid, + isSolid && inspection.EntryCount > 1 ? 1 : 0, + isMultiVolume, + formatVersion + ); + } + + private static AceMainHeader? ReadAceHeader( + Stream stream, + ArchiveDetection detection, + ReaderOptions options + ) + { + if (detection.ContainerType != ArchiveType.Ace) + { + return null; + } + + try + { + stream.Position = 0; + return new AceMainHeader(options.ArchiveEncoding).Read(stream) as AceMainHeader; + } + finally + { + stream.Position = 0; + } + } + + private static ArchiveInformation CreatePartialInformation( + ArchiveDetection detection, + long? physicalSize, + int physicalPartCount, + ArchiveInformationLimitations limitations + ) => + new( + detection, + ArchiveInformationStatus.Partial, + limitations, + null, + null, + null, + physicalSize, + null, + null, + null, + null, + limitations.HasFlag(ArchiveInformationLimitations.EncryptedHeaders) + ? ArchiveEncryptionScope.Headers + : null, + limitations.HasFlag(ArchiveInformationLimitations.EncryptedHeaders) ? true : null, + null, + physicalPartCount, + null, + null, + null, + null + ); + + private static ArchiveInformationStatus GetStatus(ArchiveInformationLimitations limitations) => + limitations == ArchiveInformationLimitations.None + ? ArchiveInformationStatus.Complete + : ArchiveInformationStatus.Partial; + + private static ZipArchiveInformation? GetZipInformation(IEnumerable entries) + { + var dataDescriptorEntryCount = entries + .OfType() + .LongCount(entry => + entry + .Parts.OfType() + .Any(part => + FlagUtility.HasFlag( + part.Header.Flags, + SharpCompress.Common.Zip.Headers.HeaderFlags.UsePostDataDescriptor + ) + ) + ); + return dataDescriptorEntryCount == 0 && !entries.OfType().Any() + ? null + : new ZipArchiveInformation(dataDescriptorEntryCount); + } + + private static (bool IsSolid, long SolidStreamCount) GetSolidInformation( + IArchive archive, + IReadOnlyCollection entries + ) + { + if (archive.Type == ArchiveType.SevenZip) + { + var solidStreamCount = entries + .OfType() + .Where(entry => !entry.IsDirectory && entry.FilePart.Folder is not null) + .GroupBy(entry => entry.FilePart.Folder) + .LongCount(group => group.Skip(1).Any()); + return (solidStreamCount > 0, solidStreamCount); + } + + if (archive.Type == ArchiveType.Rar) + { + var solidStreamCount = CountRarSolidStreams(entries); + return (archive.IsSolid, solidStreamCount); + } + + return (false, 0); + } + + private static long CountRarSolidStreams(IEnumerable entries) + { + var wasSolid = false; + long count = 0; + foreach (var entry in entries.Where(entry => !entry.IsDirectory)) + { + if (entry.IsSolid && !wasSolid) + { + count++; + } + wasSolid = entry.IsSolid; + } + return count; + } + + private static bool GetIsMultiVolume(ArchiveType type, IVolume[] volumes) => + type == ArchiveType.Rar + ? volumes.OfType().Any(volume => volume.IsMultiVolume) + : volumes.Length > 1; + + private static string? GetArchiveComment(IEnumerable volumes) => + volumes.OfType().LastOrDefault()?.Comment + ?? volumes + .OfType() + .Select(volume => volume.Comment) + .FirstOrDefault(comment => comment is not null); + + private static string? GetFormatVersion(IEnumerable volumes) + { + var rarVolume = volumes.OfType().FirstOrDefault(); + return rarVolume is null ? null : $"RAR {rarVolume.MinVersion}-{rarVolume.MaxVersion}"; + } + + private static ArchiveEncryptionScope GetEncryptionScope( + bool hasEncryptedHeaders, + bool hasEncryptedEntries + ) + { + var encryption = ArchiveEncryptionScope.None; + if (hasEncryptedHeaders) + { + encryption |= ArchiveEncryptionScope.Headers; + } + if (hasEncryptedEntries) + { + encryption |= ArchiveEncryptionScope.EntryData; + } + return encryption; + } + + private static long? GetCompressedPayloadSize( + IArchive archive, + IReadOnlyCollection entries + ) => + archive.Type switch + { + ArchiveType.GZip => null, + ArchiveType.SevenZip when archive is SevenZipArchive sevenZipArchive => + sevenZipArchive.TotalSize, + _ => entries.Aggregate(0L, (total, entry) => total + entry.CompressedSize), + }; + + private static long? GetPhysicalSize(Stream stream, long startPosition) + { + try + { + return stream.Length - startPosition; + } + catch (NotSupportedException) + { + return null; + } + } + + private static ArchiveDetection? RedetectArchive( + Stream stream, + long startPosition, + ReaderOptions options + ) + { + stream.Seek(startPosition, SeekOrigin.Begin); + using var archiveStream = new ArchiveOffsetStream(stream); + return TryDetectArchive(archiveStream, options); + } + + private static FileInfo[] GetArchiveFileParts(FileInfo firstPart, ReaderOptions options) + { + using Stream stream = firstPart.OpenRead(); + var factory = TryFindFactory(stream, options); + if (factory is null) + { + return [firstPart]; + } + + var parts = new List { firstPart }; + for (var index = 1; factory.GetFilePart(index, firstPart) is { } part; index++) + { + parts.Add(part); + } + return parts.ToArray(); + } + + private static long? GetPhysicalSize(IEnumerable fileInfos) + { + try + { + return fileInfos.Aggregate(0L, (total, fileInfo) => total + fileInfo.Length); + } + catch (IOException) + { + return null; + } + } + + private static long? GetPhysicalSize(IReadOnlyList streams, long[] startPositions) + { + try + { + long total = 0; + for (var i = 0; i < streams.Count; i++) + { + total += streams[i].Length - startPositions[i]; + } + return total; + } + catch (NotSupportedException) + { + return null; + } + } + + private sealed class ReaderInspection + { + private readonly ArchiveDetection detection; + private long? compressedPayloadSize = 0; + private long? uncompressedPayloadSize = 0; + private long dataDescriptorEntryCount; + private long entriesWithUnknownSizeCount; + private bool isEncrypted; + private ArchiveInformationLimitations limitations; + + public ReaderInspection(ArchiveDetection detection) + { + this.detection = detection; + if ( + detection.ContainerType == ArchiveType.Lzw + || ( + detection.ContainerType == ArchiveType.Tar + && detection.OuterCompressionType is not null + ) + ) + { + compressedPayloadSize = null; + limitations |= ArchiveInformationLimitations.UnavailableMetadata; + } + if (detection.ContainerType == ArchiveType.Lzw) + { + uncompressedPayloadSize = null; + } + } + + public long EntryCount { get; private set; } + + public void Add(IEntry entry) + { + EntryCount++; + isEncrypted |= entry.IsEncrypted; + + var usesDataDescriptor = UsesZipDataDescriptor(entry); + if (usesDataDescriptor) + { + dataDescriptorEntryCount++; + } + + if ( + detection.ContainerType == ArchiveType.Lzw + || usesDataDescriptor + || !TryGetSize(entry, out var size) + ) + { + entriesWithUnknownSizeCount++; + uncompressedPayloadSize = null; + limitations |= ArchiveInformationLimitations.UnavailableMetadata; + } + else if (uncompressedPayloadSize is { } totalSize) + { + uncompressedPayloadSize = totalSize + size; + } + + if (compressedPayloadSize is { } totalCompressedSize) + { + compressedPayloadSize = totalCompressedSize + entry.CompressedSize; + } + } + + public ArchiveInformation CreateInformation( + long? physicalSize, + int physicalPartCount, + bool isSolid, + long solidStreamCount, + bool isMultiVolume, + string? formatVersion + ) => + new( + detection, + GetStatus(limitations), + limitations, + formatVersion, + EntryCount, + entriesWithUnknownSizeCount, + physicalSize, + compressedPayloadSize, + uncompressedPayloadSize, + isSolid, + solidStreamCount, + isEncrypted ? ArchiveEncryptionScope.EntryData : ArchiveEncryptionScope.None, + isEncrypted, + isMultiVolume, + physicalPartCount, + 1, + true, + null, + detection.ContainerType == ArchiveType.Zip + ? new ZipArchiveInformation(dataDescriptorEntryCount) + : null + ); + + private static bool TryGetSize(IEntry entry, out long size) + { + try + { + size = entry.Size; + return size >= 0; + } + catch (NotImplementedException) + { + size = 0; + return false; + } + } + + private static bool UsesZipDataDescriptor(IEntry entry) => + entry is ZipEntry zipEntry + && zipEntry + .Parts.OfType() + .Any(part => + FlagUtility.HasFlag( + part.Header.Flags, + SharpCompress.Common.Zip.Headers.HeaderFlags.UsePostDataDescriptor + ) + ); + } +} diff --git a/src/SharpCompress/Archives/ArchiveInformation.cs b/src/SharpCompress/Archives/ArchiveInformation.cs index 81d4fc6d..68468771 100644 --- a/src/SharpCompress/Archives/ArchiveInformation.cs +++ b/src/SharpCompress/Archives/ArchiveInformation.cs @@ -1,50 +1,145 @@ -using SharpCompress.Common; - namespace SharpCompress.Archives; /// -/// Contains information about a detected archive, including its type and supported capabilities. +/// Contains metadata collected by fully inspecting an archive. /// -/// -/// Use or -/// -/// to obtain an instance of this record. -/// -public record ArchiveInformation +public sealed class ArchiveInformation { - /// - /// The type of archive detected, or when the format is not a registered well-known type. - /// - public ArchiveType? Type { get; set; } - - /// - /// when this archive format supports random access via the API, - /// meaning the full file listing can be retrieved without decompressing the entire archive. - /// when only the API is available, - /// which reads entries sequentially and can only report per-entry progress. - /// - public bool SupportsRandomAccess { get; set; } - - /// - /// For ZIP archives, the number of entries that use post-data descriptor trailers. - /// This value is for non-ZIP formats. - /// - public int? ZipDataDescriptorEntryCount { get; set; } - - /// - /// For solid-capable archive formats, the number of solid compressed streams present. - /// This value is for formats that do not support solid entries. - /// - public int? SolidStreamCount { get; set; } - - /// - /// Creates a new archive information instance. - /// - /// The detected archive type. - /// Whether the detected format supports random access. - public ArchiveInformation(ArchiveType? type, bool supportsRandomAccess) + internal ArchiveInformation( + ArchiveDetection detection, + ArchiveInformationStatus status, + ArchiveInformationLimitations limitations, + string? formatVersion, + long? entryCount, + long? entriesWithUnknownSizeCount, + long? physicalSize, + long? compressedPayloadSize, + long? uncompressedPayloadSize, + bool? isSolid, + long? solidStreamCount, + ArchiveEncryptionScope? encryption, + bool? isEncrypted, + bool? isMultiVolume, + int? physicalPartCount, + int? logicalVolumeCount, + bool? isComplete, + string? comment, + ZipArchiveInformation? zip + ) { - Type = type; - SupportsRandomAccess = supportsRandomAccess; + Detection = detection; + Status = status; + Limitations = limitations; + FormatVersion = formatVersion; + EntryCount = entryCount; + EntriesWithUnknownSizeCount = entriesWithUnknownSizeCount; + PhysicalSize = physicalSize; + CompressedPayloadSize = compressedPayloadSize; + UncompressedPayloadSize = uncompressedPayloadSize; + IsSolid = isSolid; + SolidStreamCount = solidStreamCount; + Encryption = encryption; + IsEncrypted = isEncrypted; + IsMultiVolume = isMultiVolume; + PhysicalPartCount = physicalPartCount; + LogicalVolumeCount = logicalVolumeCount; + IsComplete = isComplete; + Comment = comment; + Zip = zip; } + + /// + /// Gets the format and API capabilities identified before inspection. + /// + public ArchiveDetection Detection { get; } + + /// + /// Gets whether metadata collection completed. + /// + public ArchiveInformationStatus Status { get; } + + /// + /// Gets conditions that prevented complete metadata collection. + /// + public ArchiveInformationLimitations Limitations { get; } + + /// + /// Gets the format version when exposed by the archive format. + /// + public string? FormatVersion { get; } + + /// + /// Gets the number of entries, excluding format control records. + /// + public long? EntryCount { get; } + + /// + /// Gets the number of entries for which a forward-only reader cannot know the uncompressed size before reading entry data. + /// + public long? EntriesWithUnknownSizeCount { get; } + + /// + /// Gets the number of bytes in the supplied source parts, when known. + /// + public long? PhysicalSize { get; } + + /// + /// Gets the aggregate stored payload size, when the format exposes it reliably. + /// + public long? CompressedPayloadSize { get; } + + /// + /// Gets the aggregate uncompressed entry size, when the format exposes it reliably. + /// + public long? UncompressedPayloadSize { get; } + + /// + /// Gets whether entries share compression state. + /// + public bool? IsSolid { get; } + + /// + /// Gets the number of independent shared compression streams, when known. + /// + public long? SolidStreamCount { get; } + + /// + /// Gets the scopes protected by encryption, when known. + /// + public ArchiveEncryptionScope? Encryption { get; } + + /// + /// Gets whether archive headers or entry data are encrypted, when known. + /// + public bool? IsEncrypted { get; } + + /// + /// Gets whether the archive is part of a multi-volume set, when known. + /// + public bool? IsMultiVolume { get; } + + /// + /// Gets the number of source parts supplied for inspection. + /// + public int? PhysicalPartCount { get; } + + /// + /// Gets the number of logical volumes exposed by the archive, when known. + /// + public int? LogicalVolumeCount { get; } + + /// + /// Gets whether all discovered entries are complete, when known. + /// + public bool? IsComplete { get; } + + /// + /// Gets the archive comment, when supported by the format. + /// + public string? Comment { get; } + + /// + /// Gets ZIP-specific metadata when the detected container is ZIP. + /// + public ZipArchiveInformation? Zip { get; } } diff --git a/src/SharpCompress/Archives/ArchiveInformationLimitations.cs b/src/SharpCompress/Archives/ArchiveInformationLimitations.cs new file mode 100644 index 00000000..26f2cd3a --- /dev/null +++ b/src/SharpCompress/Archives/ArchiveInformationLimitations.cs @@ -0,0 +1,30 @@ +using System; + +namespace SharpCompress.Archives; + +/// +/// Identifies conditions that prevented complete archive metadata inspection. +/// +[Flags] +public enum ArchiveInformationLimitations +{ + /// + /// No known inspection limitations apply. + /// + None = 0, + + /// + /// Archive headers are encrypted and no password was supplied. + /// + EncryptedHeaders = 1, + + /// + /// One or more archive volumes are unavailable. + /// + MissingVolumes = 2, + + /// + /// The format does not expose a requested metadata value. + /// + UnavailableMetadata = 4, +} diff --git a/src/SharpCompress/Archives/ArchiveInformationStatus.cs b/src/SharpCompress/Archives/ArchiveInformationStatus.cs new file mode 100644 index 00000000..dabee58b --- /dev/null +++ b/src/SharpCompress/Archives/ArchiveInformationStatus.cs @@ -0,0 +1,17 @@ +namespace SharpCompress.Archives; + +/// +/// Describes whether archive metadata could be collected completely. +/// +public enum ArchiveInformationStatus +{ + /// + /// All metadata supported by the format was collected. + /// + Complete, + + /// + /// Some metadata could not be collected. See . + /// + Partial, +} diff --git a/src/SharpCompress/Archives/ZipArchiveInformation.cs b/src/SharpCompress/Archives/ZipArchiveInformation.cs new file mode 100644 index 00000000..c7160903 --- /dev/null +++ b/src/SharpCompress/Archives/ZipArchiveInformation.cs @@ -0,0 +1,15 @@ +namespace SharpCompress.Archives; + +/// +/// Contains ZIP-specific metadata. +/// +public sealed class ZipArchiveInformation +{ + internal ZipArchiveInformation(long dataDescriptorEntryCount) => + DataDescriptorEntryCount = dataDescriptorEntryCount; + + /// + /// Gets the number of entries whose local header defers its CRC and sizes to a data descriptor. + /// + public long DataDescriptorEntryCount { get; } +} diff --git a/src/SharpCompress/Common/Rar/RarVolume.cs b/src/SharpCompress/Common/Rar/RarVolume.cs index 1b7de043..f3913d2c 100644 --- a/src/SharpCompress/Common/Rar/RarVolume.cs +++ b/src/SharpCompress/Common/Rar/RarVolume.cs @@ -25,6 +25,8 @@ public abstract class RarVolume : Volume private ArchiveHeader? ArchiveHeader { get; set; } + internal bool IsHeaderEncrypted => _headerFactory.IsEncrypted; + private StreamingMode Mode => _headerFactory.StreamingMode; internal abstract IEnumerable ReadFileParts(); diff --git a/src/SharpCompress/IO/ArchiveOffsetStream.cs b/src/SharpCompress/IO/ArchiveOffsetStream.cs new file mode 100644 index 00000000..9c7bc03e --- /dev/null +++ b/src/SharpCompress/IO/ArchiveOffsetStream.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace SharpCompress.IO; + +/// +/// Exposes the remainder of a seekable stream as an archive that begins at position zero. +/// +internal sealed class ArchiveOffsetStream : Stream +{ + private readonly Stream stream; + private readonly long origin; + + public ArchiveOffsetStream(Stream stream) + { + this.stream = stream; + origin = stream.Position; + } + + public override bool CanRead => stream.CanRead; + public override bool CanSeek => stream.CanSeek; + public override bool CanWrite => false; + public override long Length => stream.Length - origin; + + public override long Position + { + get => stream.Position - origin; + set => stream.Position = origin + value; + } + + public override void Flush() { } + + public override int Read(byte[] buffer, int offset, int count) => + stream.Read(buffer, offset, count); + +#if !LEGACY_DOTNET + public override int Read(Span buffer) => stream.Read(buffer); +#endif + + public override int ReadByte() => stream.ReadByte(); + + public override Task ReadAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) => stream.ReadAsync(buffer, offset, count, cancellationToken); + +#if !NET48 && !NETSTANDARD2_0 + public override ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default + ) => stream.ReadAsync(buffer, cancellationToken); +#endif + + public override long Seek(long offset, SeekOrigin seekOrigin) + { + var position = seekOrigin switch + { + SeekOrigin.Begin => offset, + SeekOrigin.Current => Position + offset, + SeekOrigin.End => Length + offset, + _ => throw new ArgumentOutOfRangeException(nameof(seekOrigin)), + }; + + if (position < 0) + { + throw new IOException("Attempted to seek before the archive origin."); + } + + stream.Seek(origin + position, SeekOrigin.Begin); + return position; + } + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + // The caller owns the source stream. + base.Dispose(disposing); + } +} diff --git a/tests/SharpCompress.Test/ArchiveFactoryTests.cs b/tests/SharpCompress.Test/ArchiveFactoryTests.cs index 2c8aa42c..5ea9d3c0 100644 --- a/tests/SharpCompress.Test/ArchiveFactoryTests.cs +++ b/tests/SharpCompress.Test/ArchiveFactoryTests.cs @@ -247,38 +247,36 @@ public class ArchiveFactoryTests : TestBase [InlineData("7Zip.nonsolid.7z", ArchiveType.SevenZip, true)] [InlineData("Ace.store.ace", ArchiveType.Ace, false)] [InlineData("Arc.uncompressed.arc", ArchiveType.Arc, false)] - public void GetArchiveInformation_ReturnsExpectedInfo( + public void DetectArchive_ReturnsExpectedInfo( string archiveName, ArchiveType expectedType, bool expectedRandomAccess ) { - var info = ArchiveFactory.GetArchiveInformation( - Path.Combine(TEST_ARCHIVES_PATH, archiveName) - ); + var info = ArchiveFactory.DetectArchive(Path.Combine(TEST_ARCHIVES_PATH, archiveName)); Assert.NotNull(info); - Assert.Equal(expectedType, info.Type); - Assert.Equal(expectedRandomAccess, info.SupportsRandomAccess); + Assert.Equal(expectedType, info.ContainerType); + Assert.Equal(expectedRandomAccess, info.SupportedApis.HasFlag(ArchiveAccessMode.Archive)); } [Theory] [InlineData("7Zip.LZMA2.exe", ArchiveType.SevenZip, true)] [InlineData("Rar.jpeg.jpg", ArchiveType.Rar, true)] - public void GetArchiveInformation_WithReaderOptions_ReturnsExpectedInfo( + public void DetectArchive_WithReaderOptions_ReturnsExpectedInfo( string archiveName, ArchiveType expectedType, bool expectedRandomAccess ) { - var info = ArchiveFactory.GetArchiveInformation( + var info = ArchiveFactory.DetectArchive( GetTestArchivePath(archiveName), ReaderOptions.ForFilePath.WithLookForHeader(true) ); Assert.NotNull(info); - Assert.Equal(expectedType, info.Type); - Assert.Equal(expectedRandomAccess, info.SupportsRandomAccess); + Assert.Equal(expectedType, info.ContainerType); + Assert.Equal(expectedRandomAccess, info.SupportedApis.HasFlag(ArchiveAccessMode.Archive)); } [Theory] @@ -288,38 +286,38 @@ public class ArchiveFactoryTests : TestBase [InlineData("7Zip.nonsolid.7z", ArchiveType.SevenZip, true)] [InlineData("Ace.store.ace", ArchiveType.Ace, false)] [InlineData("Arc.uncompressed.arc", ArchiveType.Arc, false)] - public async ValueTask GetArchiveInformationAsync_ReturnsExpectedInfo( + public async ValueTask DetectArchiveAsync_ReturnsExpectedInfo( string archiveName, ArchiveType expectedType, bool expectedRandomAccess ) { - var info = await ArchiveFactory.GetArchiveInformationAsync( + var info = await ArchiveFactory.DetectArchiveAsync( Path.Combine(TEST_ARCHIVES_PATH, archiveName) ); Assert.NotNull(info); - Assert.Equal(expectedType, info.Type); - Assert.Equal(expectedRandomAccess, info.SupportsRandomAccess); + Assert.Equal(expectedType, info.ContainerType); + Assert.Equal(expectedRandomAccess, info.SupportedApis.HasFlag(ArchiveAccessMode.Archive)); } [Theory] [InlineData("7Zip.LZMA2.exe", ArchiveType.SevenZip, true)] [InlineData("Rar.jpeg.jpg", ArchiveType.Rar, true)] - public async ValueTask GetArchiveInformationAsync_WithReaderOptions_ReturnsExpectedInfo( + public async ValueTask DetectArchiveAsync_WithReaderOptions_ReturnsExpectedInfo( string archiveName, ArchiveType expectedType, bool expectedRandomAccess ) { - var info = await ArchiveFactory.GetArchiveInformationAsync( + var info = await ArchiveFactory.DetectArchiveAsync( GetTestArchivePath(archiveName), ReaderOptions.ForFilePath.WithLookForHeader(true) ); Assert.NotNull(info); - Assert.Equal(expectedType, info.Type); - Assert.Equal(expectedRandomAccess, info.SupportsRandomAccess); + Assert.Equal(expectedType, info.ContainerType); + Assert.Equal(expectedRandomAccess, info.SupportedApis.HasFlag(ArchiveAccessMode.Archive)); } [Theory] @@ -466,17 +464,17 @@ public class ArchiveFactoryTests : TestBase [InlineData("SharpCompress.AES.zip", ArchiveType.Zip, true)] [InlineData("SharpCompress.Encrypted.zip", ArchiveType.Zip, true)] [InlineData("SharpCompress.Encrypted2.zip", ArchiveType.Zip, true)] - public void GetArchiveInformation_DetectsSingleFileTestArchives( + public void DetectArchive_DetectsSingleFileTestArchives( string archiveName, ArchiveType expectedType, bool expectedSeekable ) { - var info = ArchiveFactory.GetArchiveInformation(GetTestArchivePath(archiveName)); + var info = ArchiveFactory.DetectArchive(GetTestArchivePath(archiveName)); Assert.NotNull(info); - Assert.Equal(expectedType, info.Type); - Assert.Equal(expectedSeekable, info.SupportsRandomAccess); + Assert.Equal(expectedType, info.ContainerType); + Assert.Equal(expectedSeekable, info.SupportedApis.HasFlag(ArchiveAccessMode.Archive)); } [Theory] @@ -623,35 +621,35 @@ public class ArchiveFactoryTests : TestBase [InlineData("SharpCompress.AES.zip", ArchiveType.Zip, true)] [InlineData("SharpCompress.Encrypted.zip", ArchiveType.Zip, true)] [InlineData("SharpCompress.Encrypted2.zip", ArchiveType.Zip, true)] - public async ValueTask GetArchiveInformationAsync_DetectsSingleFileTestArchives( + public async ValueTask DetectArchiveAsync_DetectsSingleFileTestArchives( string archiveName, ArchiveType expectedType, bool expectedSeekable ) { - var info = await ArchiveFactory.GetArchiveInformationAsync(GetTestArchivePath(archiveName)); + var info = await ArchiveFactory.DetectArchiveAsync(GetTestArchivePath(archiveName)); Assert.NotNull(info); - Assert.Equal(expectedType, info.Type); - Assert.Equal(expectedSeekable, info.SupportsRandomAccess); + Assert.Equal(expectedType, info.ContainerType); + Assert.Equal(expectedSeekable, info.SupportedApis.HasFlag(ArchiveAccessMode.Archive)); } [Fact] - public void GetArchiveInformation_ReturnsNull_ForNonArchive() + public void DetectArchive_ReturnsNull_ForNonArchive() { using var stream = new MemoryStream(Encoding.ASCII.GetBytes("not an archive")); - var info = ArchiveFactory.GetArchiveInformation(stream); + var info = ArchiveFactory.DetectArchive(stream); Assert.Null(info); } [Fact] - public async ValueTask GetArchiveInformationAsync_ReturnsNull_ForNonArchive() + public async ValueTask DetectArchiveAsync_ReturnsNull_ForNonArchive() { using var stream = new MemoryStream(Encoding.ASCII.GetBytes("not an archive")); - var info = await ArchiveFactory.GetArchiveInformationAsync(stream); + var info = await ArchiveFactory.DetectArchiveAsync(stream); Assert.Null(info); } @@ -697,13 +695,16 @@ public class ArchiveFactoryTests : TestBase [InlineData("Tar.tar.xz")] [InlineData("Tar.tar.zst")] [InlineData("Tar.tar.Z")] - public void GetArchiveInformation_ReturnsNull_ForCompressedTar(string archiveName) + public void DetectArchive_RecognizesCompressedTar(string archiveName) { using var stream = File.OpenRead(GetTestArchivePath(archiveName)); - var info = ArchiveFactory.GetArchiveInformation(stream); + var info = ArchiveFactory.DetectArchive(stream); - Assert.Null(info); + Assert.NotNull(info); + Assert.Equal(ArchiveType.Tar, info.ContainerType); + Assert.NotNull(info.OuterCompressionType); + Assert.Equal(ArchiveAccessMode.Reader, info.SupportedApis); } [Theory] @@ -713,21 +714,37 @@ public class ArchiveFactoryTests : TestBase [InlineData("Tar.tar.xz")] [InlineData("Tar.tar.zst")] [InlineData("Tar.tar.Z")] - public async ValueTask GetArchiveInformationAsync_ReturnsNull_ForCompressedTar( - string archiveName - ) + public async ValueTask DetectArchiveAsync_RecognizesCompressedTar(string archiveName) { using var stream = File.OpenRead(GetTestArchivePath(archiveName)); - var info = await ArchiveFactory.GetArchiveInformationAsync(stream); + var info = await ArchiveFactory.DetectArchiveAsync(stream); - Assert.Null(info); + Assert.NotNull(info); + Assert.Equal(ArchiveType.Tar, info.ContainerType); + Assert.NotNull(info.OuterCompressionType); + Assert.Equal(ArchiveAccessMode.Reader, info.SupportedApis); } [Theory] [InlineData("Zip.deflate.zip", ArchiveType.Zip)] [InlineData("Tar.noEmptyDirs.tar", ArchiveType.Tar)] - public void GetArchiveInformation_Stream_PreservesPosition( + public void DetectArchive_Stream_PreservesPosition(string archiveName, ArchiveType expectedType) + { + using var stream = CreatePrefixedArchiveStream(archiveName, 13); + var startPosition = stream.Position; + + var info = ArchiveFactory.DetectArchive(stream); + + Assert.NotNull(info); + Assert.Equal(expectedType, info.ContainerType); + Assert.Equal(startPosition, stream.Position); + } + + [Theory] + [InlineData("Zip.deflate.zip", ArchiveType.Zip)] + [InlineData("Tar.noEmptyDirs.tar", ArchiveType.Tar)] + public async ValueTask DetectArchiveAsync_Stream_PreservesPosition( string archiveName, ArchiveType expectedType ) @@ -735,17 +752,76 @@ public class ArchiveFactoryTests : TestBase using var stream = CreatePrefixedArchiveStream(archiveName, 13); var startPosition = stream.Position; - var info = ArchiveFactory.GetArchiveInformation(stream); + var info = await ArchiveFactory.DetectArchiveAsync(stream); Assert.NotNull(info); - Assert.Equal(expectedType, info.Type); + Assert.Equal(expectedType, info.ContainerType); Assert.Equal(startPosition, stream.Position); } + [Theory] + [InlineData("Zip.none.datadescriptors.zip", 2)] + [InlineData("Zip.deflate.zip", 0)] + public void InspectArchive_ZipDataDescriptorEntryCount( + string archiveName, + long expectedDataDescriptorEntryCount + ) + { + using var stream = File.OpenRead(GetTestArchivePath(archiveName)); + + var info = ArchiveFactory.InspectArchive(stream); + + Assert.NotNull(info); + Assert.Equal(ArchiveType.Zip, info.Detection.ContainerType); + Assert.NotNull(info.Zip); + Assert.Equal(expectedDataDescriptorEntryCount, info.Zip.DataDescriptorEntryCount); + Assert.Equal(expectedDataDescriptorEntryCount, info.EntriesWithUnknownSizeCount); + } + + [Theory] + [InlineData("7Zip.solid.7z", true, 1)] + [InlineData("7Zip.nonsolid.7z", false, 0)] + [InlineData("Rar.rar", false, 0)] + [InlineData("Rar.solid.rar", true, 1)] + public void InspectArchive_SolidStreamCount( + string archiveName, + bool expectedSolid, + long expectedSolidStreamCount + ) + { + using var stream = File.OpenRead(GetTestArchivePath(archiveName)); + + var info = ArchiveFactory.InspectArchive(stream); + + Assert.NotNull(info); + Assert.Equal(expectedSolid, info.IsSolid); + Assert.Equal(expectedSolidStreamCount, info.SolidStreamCount); + } + + [Theory] + [InlineData("Zip.none.datadescriptors.zip", ArchiveType.Zip)] + [InlineData("7Zip.solid.7z", ArchiveType.SevenZip)] + public async ValueTask InspectArchiveAsync_ReturnsMetadata( + string archiveName, + ArchiveType expectedType + ) + { + using var stream = File.OpenRead(GetTestArchivePath(archiveName)); + + var info = await ArchiveFactory.InspectArchiveAsync(stream); + + Assert.NotNull(info); + Assert.Equal(expectedType, info.Detection.ContainerType); + Assert.Equal(ArchiveInformationStatus.Complete, info.Status); + Assert.NotNull(info.EntryCount); + } + [Theory] [InlineData("Zip.deflate.zip", ArchiveType.Zip)] [InlineData("Tar.noEmptyDirs.tar", ArchiveType.Tar)] - public async ValueTask GetArchiveInformationAsync_Stream_PreservesPosition( + [InlineData("Rar.rar", ArchiveType.Rar)] + [InlineData("7Zip.nonsolid.7z", ArchiveType.SevenZip)] + public void InspectArchive_Stream_PreservesPosition( string archiveName, ArchiveType expectedType ) @@ -753,62 +829,172 @@ public class ArchiveFactoryTests : TestBase using var stream = CreatePrefixedArchiveStream(archiveName, 13); var startPosition = stream.Position; - var info = await ArchiveFactory.GetArchiveInformationAsync(stream); + var info = ArchiveFactory.InspectArchive(stream); Assert.NotNull(info); - Assert.Equal(expectedType, info.Type); + Assert.Equal(expectedType, info.Detection.ContainerType); Assert.Equal(startPosition, stream.Position); } [Theory] - [InlineData("Zip.none.datadescriptors.zip", true)] - [InlineData("Zip.deflate.zip", false)] - public void GetArchiveInformation_ZipDataDescriptorEntryCount( + [InlineData("Zip.deflate.zip", ArchiveType.Zip)] + [InlineData("Tar.noEmptyDirs.tar", ArchiveType.Tar)] + [InlineData("Rar.rar", ArchiveType.Rar)] + [InlineData("7Zip.nonsolid.7z", ArchiveType.SevenZip)] + public async ValueTask InspectArchiveAsync_Stream_PreservesPosition( string archiveName, - bool expectsDataDescriptorTrailers + ArchiveType expectedType ) { - using var stream = File.OpenRead(GetTestArchivePath(archiveName)); + using var stream = CreatePrefixedArchiveStream(archiveName, 13); + var startPosition = stream.Position; - var info = ArchiveFactory.GetArchiveInformation(stream); + var info = await ArchiveFactory.InspectArchiveAsync(stream); Assert.NotNull(info); - Assert.Equal(ArchiveType.Zip, info.Type); - Assert.NotNull(info.ZipDataDescriptorEntryCount); - Assert.Equal(expectsDataDescriptorTrailers, info.ZipDataDescriptorEntryCount > 0); + Assert.Equal(expectedType, info.Detection.ContainerType); + Assert.Equal(startPosition, stream.Position); + } + + [Fact] + public void InspectArchive_CompressedTar_ReportsContainerAndWrapper() + { + var info = ArchiveFactory.InspectArchive(GetTestArchivePath("Tar.tar.xz")); + + Assert.NotNull(info); + Assert.Equal(ArchiveType.Tar, info.Detection.ContainerType); + Assert.Equal(CompressionType.Xz, info.Detection.OuterCompressionType); + Assert.Equal(ArchiveInformationStatus.Partial, info.Status); + Assert.Null(info.CompressedPayloadSize); + Assert.NotNull(info.EntryCount); } [Theory] - [InlineData("7Zip.solid.7z", true)] - [InlineData("7Zip.nonsolid.7z", false)] - [InlineData("Rar.rar", false)] - public void GetArchiveInformation_SolidStreamCount(string archiveName, bool expectsSolidStreams) + [InlineData("Ace.method1-solid.ace", true)] + [InlineData("Ace.method1.ace", false)] + public void InspectArchive_Ace_ReportsSolidMetadata(string archiveName, bool expectedSolid) { - using var stream = File.OpenRead(GetTestArchivePath(archiveName)); - - var info = ArchiveFactory.GetArchiveInformation(stream); + var info = ArchiveFactory.InspectArchive(GetTestArchivePath(archiveName)); Assert.NotNull(info); - Assert.NotNull(info.SolidStreamCount); - Assert.Equal(expectsSolidStreams, info.SolidStreamCount > 0); + Assert.Equal(expectedSolid, info.IsSolid); + Assert.Equal(expectedSolid ? 1 : 0, info.SolidStreamCount); + } + + [Fact] + public void InspectArchive_Arc_ReportsUnavailableUncompressedSizes() + { + var info = ArchiveFactory.InspectArchive(GetTestArchivePath("Arc.uncompressed.arc")); + + Assert.NotNull(info); + Assert.Equal(ArchiveInformationStatus.Partial, info.Status); + Assert.True(info.Limitations.HasFlag(ArchiveInformationLimitations.UnavailableMetadata)); + Assert.Null(info.UncompressedPayloadSize); + Assert.True(info.EntriesWithUnknownSizeCount > 0); } [Theory] - [InlineData("Zip.none.datadescriptors.zip", true)] - [InlineData("7Zip.solid.7z", true)] - public async ValueTask GetArchiveInformationAsync_IncludesFormatSpecificDetails( + [InlineData("Arj.store.arj", ArchiveType.Arj, ArchiveInformationStatus.Complete)] + [InlineData("large_test.txt.Z", ArchiveType.Lzw, ArchiveInformationStatus.Partial)] + public void InspectArchive_ReaderOnlyFormats_ReturnsMetadata( string archiveName, - bool expectsAnyFormatSpecificDetails + ArchiveType expectedType, + ArchiveInformationStatus expectedStatus ) { - using var stream = File.OpenRead(GetTestArchivePath(archiveName)); - - var info = await ArchiveFactory.GetArchiveInformationAsync(stream); + var info = ArchiveFactory.InspectArchive(GetTestArchivePath(archiveName)); Assert.NotNull(info); - var hasZipDetail = info.ZipDataDescriptorEntryCount > 0; - var hasSolidDetail = info.SolidStreamCount > 0; - Assert.Equal(expectsAnyFormatSpecificDetails, hasZipDetail || hasSolidDetail); + Assert.Equal(expectedType, info.Detection.ContainerType); + Assert.Equal(expectedStatus, info.Status); + Assert.NotNull(info.EntryCount); + } + + [Fact] + public void InspectArchive_EncryptedHeaders_ReturnsPartialInformation() + { + var info = ArchiveFactory.InspectArchive( + GetTestArchivePath("Rar.encrypted_filesAndHeader.rar") + ); + + Assert.NotNull(info); + Assert.Equal(ArchiveInformationStatus.Partial, info.Status); + Assert.True(info.Limitations.HasFlag(ArchiveInformationLimitations.EncryptedHeaders)); + Assert.Equal(ArchiveEncryptionScope.Headers, info.Encryption); + Assert.True(info.IsEncrypted); + } + + [Fact] + public void InspectArchive_EncryptedHeadersWithPassword_ReportsBothEncryptionScopes() + { + var info = ArchiveFactory.InspectArchive( + GetTestArchivePath("Rar.encrypted_filesAndHeader.rar"), + ReaderOptions.ForFilePath.WithPassword("test") + ); + + Assert.NotNull(info); + Assert.Equal(ArchiveInformationStatus.Complete, info.Status); + Assert.Equal( + ArchiveEncryptionScope.Headers | ArchiveEncryptionScope.EntryData, + info.Encryption + ); + Assert.True(info.IsEncrypted); + } + + [Fact] + public void InspectArchive_IncompleteRarStream_ReturnsPartialInformation() + { + using var stream = File.OpenRead(GetTestArchivePath("Rar.multi.solid.part01.rar")); + + var info = ArchiveFactory.InspectArchive(stream); + + Assert.NotNull(info); + Assert.Equal(ArchiveInformationStatus.Partial, info.Status); + Assert.True(info.Limitations.HasFlag(ArchiveInformationLimitations.MissingVolumes)); + Assert.Null(info.CompressedPayloadSize); + Assert.Null(info.UncompressedPayloadSize); + } + + [Fact] + public async ValueTask InspectArchiveAsync_CompressedTar_ReturnsMetadata() + { + var info = await ArchiveFactory.InspectArchiveAsync(GetTestArchivePath("Tar.tar.xz")); + + Assert.NotNull(info); + Assert.Equal(ArchiveType.Tar, info.Detection.ContainerType); + Assert.Equal(CompressionType.Xz, info.Detection.OuterCompressionType); + Assert.NotNull(info.EntryCount); + } + + [Fact] + public void InspectArchive_MultiVolumeRar_ReturnsVolumeMetadata() + { + FileInfo[] parts = + [ + new(GetTestArchivePath("Rar.multi.solid.part01.rar")), + new(GetTestArchivePath("Rar.multi.solid.part02.rar")), + new(GetTestArchivePath("Rar.multi.solid.part03.rar")), + new(GetTestArchivePath("Rar.multi.solid.part04.rar")), + new(GetTestArchivePath("Rar.multi.solid.part05.rar")), + new(GetTestArchivePath("Rar.multi.solid.part06.rar")), + ]; + + var info = ArchiveFactory.InspectArchive(parts); + + Assert.NotNull(info); + Assert.Equal(ArchiveType.Rar, info.Detection.ContainerType); + Assert.Equal(parts.Length, info.PhysicalPartCount); + Assert.True(info.IsMultiVolume); + } + + [Fact] + public void InspectArchive_MultiVolumeRarPath_DiscoversAllParts() + { + var info = ArchiveFactory.InspectArchive(GetTestArchivePath("Rar.multi.solid.part01.rar")); + + Assert.NotNull(info); + Assert.Equal(6, info.PhysicalPartCount); + Assert.True(info.IsMultiVolume); } private MemoryStream CreatePrefixedArchiveStream(string archiveName, int prefixLength)