diff --git a/README.md b/README.md index 1b06bbe..c719657 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ building upon `Microsoft.Extensions.FileProviders`. * [Ramstack.FileProviders](#ramstackfileproviders-2) * [PrefixedFileProvider](#prefixedfileprovider) * [SubFileProvider](#subfileprovider) + * [ZipFileProvider](#zipfileprovider) * [Ramstack.FileProviders.Globbing](#ramstackfileprovidersglobbing-1) * [Ramstack.FileProviders.Extensions](#ramstackfileprovidersextensions-1) * [Ramstack.FileProviders.Composition](#ramstackfileproviderscomposition-1) @@ -44,7 +45,7 @@ dotnet add package Ramstack.FileProviders.Extensions ``` ### Ramstack.FileProviders -Provides additional implementations of `IFileProvider` including `PrefixedFileProvider` and `SubFileProvider`. +Provides additional implementations of `IFileProvider` including `PrefixedFileProvider`, `SubFileProvider`, and `ZipFileProvider`. To install the `Ramstack.FileProviders` [NuGet package](https://www.nuget.org/packages/Ramstack.FileProviders) in your project, run the following command: @@ -79,6 +80,7 @@ This library offers additional implementations of the [IFileProvider](https://le - `SubFileProvider` - `PrefixedFileProvider` +- `ZipFileProvider` #### PrefixedFileProvider @@ -148,6 +150,17 @@ IFileInfo file = provider.GetFileInfo("/README"); Console.WriteLine(file.Exists); ``` +#### ZipFileProvider + +`ZipFileProvider` enables access to files within ZIP archives as if they were part of the file system. + +Example: +```csharp +IFileProvider provider = new ZipFileProvider("/path/to/archive.zip"); +foreach (IFileInfo file in provider.GetDirectoryContents("/")) + Console.WriteLine(file.Name); +``` + ### Ramstack.FileProviders.Globbing `GlobbingFileProvider` class filters files using include and/or exclude glob patterns. Include patterns make only matching files visible, @@ -277,15 +290,15 @@ var changeToken = compositeFileProvider.Watch("**/*.json").Flatten(); ## NuGet Packages - [Ramstack.FileProviders.Extensions](https://www.nuget.org/packages/Ramstack.FileProviders.Extensions) — Useful and convenient extensions for `IFileProvider`, bringing its capabilities and experience closer to what's provided by the `DirectoryInfo` and `FileInfo` classes. -- [Ramstack.FileProviders](https://www.nuget.org/packages/Ramstack.FileProviders) — Additional file providers, including `PrefixedFileProvider` and `SubFileProvider`. +- [Ramstack.FileProviders](https://www.nuget.org/packages/Ramstack.FileProviders) — Additional file providers, including `ZipFileProvider`, `PrefixedFileProvider`, and `SubFileProvider`. - [Ramstack.FileProviders.Globbing](https://www.nuget.org/packages/Ramstack.FileProviders.Globbing) — A file provider that filters files using include and/or exclude glob patterns. Include patterns make only matching files visible, while exclude patterns hide specific files. Both include and exclude patterns can be combined for flexible file visibility control. - [Ramstack.FileProviders.Composition](https://www.nuget.org/packages/Ramstack.FileProviders.Composition) — Provides a helper class for flattening and composing `IFileProvider`. ## Supported versions -| | Version | -|------|----------------| -| .NET | 6, 7, 8, 9, 10 | +| | Version | +|------|------------| +| .NET | 6, 7, 8, 9 | ## Contributions diff --git a/src/Ramstack.FileProviders.Composition/README.md b/src/Ramstack.FileProviders.Composition/README.md index 542469b..dab4ca0 100644 --- a/src/Ramstack.FileProviders.Composition/README.md +++ b/src/Ramstack.FileProviders.Composition/README.md @@ -1,6 +1,4 @@ # Ramstack.FileProviders.Composition -[![NuGet](https://img.shields.io/nuget/v/Ramstack.FileProviders.Composition.svg)](https://nuget.org/packages/Ramstack.FileProviders.Composition) -[![MIT](https://img.shields.io/github/license/rameel/ramstack.fileproviders)](https://github.com/rameel/ramstack.fileproviders/blob/main/LICENSE) Represents a .NET library that provides a helper class for flattening and composing `IFileProvider` instances. @@ -65,14 +63,14 @@ var changeToken = compositeFileProvider.Watch("**/*.json").Flatten(); ## Related Packages - [Ramstack.FileProviders.Extensions](https://www.nuget.org/packages/Ramstack.FileProviders.Extensions) — Useful and convenient extensions for `IFileProvider`, bringing its capabilities and experience closer to what's provided by the `DirectoryInfo` and `FileInfo` classes. -- [Ramstack.FileProviders](https://www.nuget.org/packages/Ramstack.FileProviders) — Additional file providers, including `PrefixedFileProvider` and `SubFileProvider`. +- [Ramstack.FileProviders](https://www.nuget.org/packages/Ramstack.FileProviders) — Additional file providers, including `ZipFileProvider`, `PrefixedFileProvider`, and `SubFileProvider`. - [Ramstack.FileProviders.Globbing](https://www.nuget.org/packages/Ramstack.FileProviders.Globbing) — A file provider that filters files using include and/or exclude glob patterns. Include patterns make only matching files visible, while exclude patterns hide specific files. Both include and exclude patterns can be combined for flexible file visibility control. ## Supported versions -| | Version | -|------|----------------| -| .NET | 6, 7, 8, 9, 10 | +| | Version | +|------|------------| +| .NET | 6, 7, 8, 9 | ## Contributions diff --git a/src/Ramstack.FileProviders.Extensions/README.md b/src/Ramstack.FileProviders.Extensions/README.md index 37a2f27..5e87a92 100644 --- a/src/Ramstack.FileProviders.Extensions/README.md +++ b/src/Ramstack.FileProviders.Extensions/README.md @@ -1,6 +1,4 @@ # Ramstack.FileProviders.Extensions -[![NuGet](https://img.shields.io/nuget/v/Ramstack.FileProviders.Extensions.svg)](https://nuget.org/packages/Ramstack.FileProviders.Extensions) -[![MIT](https://img.shields.io/github/license/rameel/ramstack.fileproviders)](https://github.com/rameel/ramstack.fileproviders/blob/main/LICENSE) Represents a lightweight .NET library of useful and convenient extensions for `Microsoft.Extensions.FileProviders` that enhances file handling capabilities in .NET applications. @@ -68,16 +66,16 @@ foreach (FileNode file in provider.EnumerateFiles("/project", pattern: "**/*.md" ``` ## Related Packages -- [Ramstack.FileProviders](https://www.nuget.org/packages/Ramstack.FileProviders) — Additional file providers, including `PrefixedFileProvider` and `SubFileProvider`. +- [Ramstack.FileProviders](https://www.nuget.org/packages/Ramstack.FileProviders) — Additional file providers, including `ZipFileProvider`, `PrefixedFileProvider`, and `SubFileProvider`. - [Ramstack.FileProviders.Globbing](https://www.nuget.org/packages/Ramstack.FileProviders.Globbing) — A file provider that filters files using include and/or exclude glob patterns. Include patterns make only matching files visible, while exclude patterns hide specific files. Both include and exclude patterns can be combined for flexible file visibility control. - [Ramstack.FileProviders.Composition](https://www.nuget.org/packages/Ramstack.FileProviders.Composition) — Provides a helper class for flattening and composing `IFileProvider`. ## Supported versions -| | Version | -|------|----------------| -| .NET | 6, 7, 8, 9, 10 | +| | Version | +|------|------------| +| .NET | 6, 7, 8, 9 | ## Contributions diff --git a/src/Ramstack.FileProviders.Globbing/README.md b/src/Ramstack.FileProviders.Globbing/README.md index 4e4896b..635cfbe 100644 --- a/src/Ramstack.FileProviders.Globbing/README.md +++ b/src/Ramstack.FileProviders.Globbing/README.md @@ -1,6 +1,4 @@ # Ramstack.FileProviders.Globbing -[![NuGet](https://img.shields.io/nuget/v/Ramstack.FileProviders.Globbing.svg)](https://nuget.org/packages/Ramstack.FileProviders.Globbing) -[![MIT](https://img.shields.io/github/license/rameel/ramstack.fileproviders)](https://github.com/rameel/ramstack.fileproviders/blob/main/LICENSE) Represents a .NET library implementing an `IFileProvider` that filters files using include and/or exclude glob patterns for flexible file visibility control. @@ -28,15 +26,15 @@ foreach (IFileInfo file in provider.GetDirectoryContents("/")) ## Related Packages - [Ramstack.FileProviders.Extensions](https://www.nuget.org/packages/Ramstack.FileProviders.Extensions) — Useful and convenient extensions for `IFileProvider`, bringing its capabilities and experience closer to what's provided by the `DirectoryInfo` and `FileInfo` classes. -- [Ramstack.FileProviders](https://www.nuget.org/packages/Ramstack.FileProviders) — Additional file providers, including `PrefixedFileProvider` and `SubFileProvider`. +- [Ramstack.FileProviders](https://www.nuget.org/packages/Ramstack.FileProviders) — Additional file providers, including `ZipFileProvider`, `PrefixedFileProvider`, and `SubFileProvider`. - [Ramstack.FileProviders.Composition](https://www.nuget.org/packages/Ramstack.FileProviders.Composition) — Provides a helper class for flattening and composing `IFileProvider`. ## Supported versions -| | Version | -|------|----------------| -| .NET | 6, 7, 8, 9, 10 | +| | Version | +|------|---------| +| .NET | 6, 7, 8 | ## Contributions diff --git a/src/Ramstack.FileProviders/README.md b/src/Ramstack.FileProviders/README.md index 5118256..00e86db 100644 --- a/src/Ramstack.FileProviders/README.md +++ b/src/Ramstack.FileProviders/README.md @@ -1,10 +1,9 @@ # Ramstack.FileProviders -[![NuGet](https://img.shields.io/nuget/v/Ramstack.FileProviders.svg)](https://nuget.org/packages/Ramstack.FileProviders) -[![MIT](https://img.shields.io/github/license/rameel/ramstack.fileproviders)](https://github.com/rameel/ramstack.fileproviders/blob/main/LICENSE) Represents a .NET library that provides additional implementations for `Microsoft.Extensions.FileProviders` including: - `PrefixedFileProvider` - `SubFileProvider` +- `ZipFileProvider` ## Getting Started @@ -82,6 +81,16 @@ IFileInfo file = provider.GetFileInfo("/README"); Console.WriteLine(file.Exists); ``` +## ZipFileProvider +`ZipFileProvider` enables access to files within ZIP archives as if they were part of the file system. + +Example: +```csharp +IFileProvider provider = new ZipFileProvider("/path/to/archive.zip"); +foreach (IFileInfo file in provider.GetDirectoryContents("/")) + Console.WriteLine(file.Name); +``` + ## Related Packages - [Ramstack.FileProviders.Extensions](https://www.nuget.org/packages/Ramstack.FileProviders.Extensions) — Useful and convenient extensions for `IFileProvider`, bringing its capabilities and experience closer to what's provided by the `DirectoryInfo` and `FileInfo` classes. - [Ramstack.FileProviders.Globbing](https://www.nuget.org/packages/Ramstack.FileProviders.Globbing) — A file provider that filters files using include and/or exclude glob patterns. Include patterns make only matching files visible, while exclude patterns hide specific files. Both include and exclude patterns can be combined for flexible file visibility control. @@ -89,9 +98,9 @@ Console.WriteLine(file.Exists); ## Supported versions -| | Version | -|------|----------------| -| .NET | 6, 7, 8, 9, 10 | +| | Version | +|------|------------| +| .NET | 6, 7, 8, 9 | ## Contributions diff --git a/src/Ramstack.FileProviders/ZipFileProvider.cs b/src/Ramstack.FileProviders/ZipFileProvider.cs new file mode 100644 index 0000000..c21425f --- /dev/null +++ b/src/Ramstack.FileProviders/ZipFileProvider.cs @@ -0,0 +1,272 @@ +using System.IO.Compression; +using System.Runtime.CompilerServices; + +namespace Ramstack.FileProviders; + +/// +/// Provides access to files within a ZIP archive. +/// +/// +/// **WARNING:** +/// +/// This class is not thread-safe and is designed to handle only one file read operation at a time. +/// It does not support parallel or simultaneous opening of multiple files. +/// +/// +[Obsolete("Deprecated due to thread safety limitations and parallel file access capabilities.")] +public sealed class ZipFileProvider : IFileProvider, IDisposable +{ + private readonly ZipArchive _archive; + private readonly Dictionary _cache = + new() { ["/"] = new ZipDirectoryInfo("/") }; + + /// + /// Initializes a new instance of the class + /// using a ZIP archive located at the specified file path. + /// + /// The path to the ZIP archive file. + public ZipFileProvider(string path) + : this(new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + } + + /// + /// Initializes a new instance of the class + /// using a stream containing a ZIP archive. + /// + /// The stream containing the ZIP archive. + /// to leave the stream open + /// after the object is disposed; otherwise, . + public ZipFileProvider(Stream stream, bool leaveOpen = false) + { + if (!stream.CanSeek) + throw new ArgumentException("Stream does not support seeking.", nameof(stream)); + + _archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen); + Initialize(_archive, _cache); + } + + /// + /// Initializes a new instance of the class + /// using an existing . + /// + /// The instance + /// to use for providing access to ZIP archive content. + public ZipFileProvider(ZipArchive archive) + { + if (archive.Mode != ZipArchiveMode.Read) + throw new ArgumentException( + "Archive must be opened in read mode (ZipArchiveMode.Read).", + nameof(archive)); + + _archive = archive; + Initialize(archive, _cache); + } + + /// + public IFileInfo GetFileInfo(string subpath) => + Find(subpath) ?? new NotFoundFileInfo(FilePath.GetFileName(subpath)); + + /// + public IDirectoryContents GetDirectoryContents(string subpath) => + Find(subpath) as IDirectoryContents ?? NotFoundDirectoryContents.Singleton; + + /// + public IChangeToken Watch(string filter) => + NullChangeToken.Singleton; + + /// + public void Dispose() => + _archive.Dispose(); + + private IFileInfo? Find(string path) => + _cache.GetValueOrDefault(FilePath.Normalize(path)); + + /// + /// Initializes the current provider by populating it with entries from the underlying ZIP archive. + /// + private static void Initialize(ZipArchive archive, Dictionary cache) + { + foreach (var entry in archive.Entries) + { + // + // Strip common path prefixes from zip entries to handle archives + // saved with absolute paths. + // + var path = FilePath.Normalize( + entry.FullName[GetPrefixLength(entry.FullName)..]); + + if (FilePath.HasTrailingSlash(entry.FullName)) + { + GetDirectory(path); + continue; + } + + var directory = GetDirectory(FilePath.GetDirectoryName(path)); + var file = new ZipFileInfo(FilePath.GetFileName(path), entry); + + // + // Archives legitimately may contain entries with identical names, + // so skip if a file with this name has already been added, + // avoiding duplicates in the directory file list. + // + if (cache.TryAdd(path, file)) + directory.RegisterFile(file); + } + + ZipDirectoryInfo GetDirectory(string path) + { + if (cache.TryGetValue(path, out var di)) + return (ZipDirectoryInfo)di; + + di = new ZipDirectoryInfo(FilePath.GetFileName(path)); + var parent = GetDirectory(FilePath.GetDirectoryName(path)); + parent.RegisterFile(di); + cache.Add(path, di); + + return (ZipDirectoryInfo)di; + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static int GetPrefixLength(string path) + { + // + // Check only well-known prefixes. + // Note: Since entry names can be arbitrary, + // we specifically target only common absolute path patterns. + // + + if (path.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase) + || path.StartsWith(@"\\.\UNC\", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("//?/UNC/", StringComparison.OrdinalIgnoreCase) + || path.StartsWith("//./UNC/", StringComparison.OrdinalIgnoreCase)) + return 8; + + if (path.StartsWith(@"\\?\", StringComparison.Ordinal) + || path.StartsWith(@"\\.\", StringComparison.Ordinal) + || path.StartsWith("//?/", StringComparison.Ordinal) + || path.StartsWith("//./", StringComparison.Ordinal)) + return path.Length >= 6 && IsAsciiLetter(path[4]) && path[5] == ':' ? 6 : 4; + + if (path.Length >= 2 + && IsAsciiLetter(path[0]) && path[1] == ':') + return 2; + + return 0; + + static bool IsAsciiLetter(char ch) => + (uint)((ch | 0x20) - 'a') <= 'z' - 'a'; + } + + #region Inner type: ZipDirectoryInfo + + /// + /// Represents directory contents and file information within a ZIP archive for the specified path. + /// This class is used to provide both and interfaces for directory entries in the ZIP archive. + /// + /// The name of the directory, not including any path. + [DebuggerDisplay("{Name,nq}")] + [DebuggerTypeProxy(typeof(ZipDirectoryInfoDebuggerProxy))] + private sealed class ZipDirectoryInfo(string name) : IDirectoryContents, IFileInfo + { + /// + /// The list of the within this directory. + /// + private readonly List _files = []; + + /// + public bool Exists => true; + + /// + public long Length => -1; + + /// + public string? PhysicalPath => null; + + /// + public string Name => name; + + /// + public DateTimeOffset LastModified => default; + + /// + public bool IsDirectory => true; + + /// + public Stream CreateReadStream() => + throw new NotSupportedException("Cannot create a read stream for a directory."); + + /// + public IEnumerator GetEnumerator() => + _files.AsEnumerable().GetEnumerator(); + + /// + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => + GetEnumerator(); + + /// + /// Register a file associated with this directory. + /// + /// The file associated with this directory. + public void RegisterFile(IFileInfo file) => + _files.Add(file); + } + + #endregion + + #region Inner type: ZipFileInfo + + /// + /// Represents a file within a ZIP archive as an implementation of the interface. + /// + /// The name of the file, not including any path. + /// The ZIP archive entry representing the file. + [DebuggerDisplay("{ToStringDebugger(),nq}")] + private sealed class ZipFileInfo(string name, ZipArchiveEntry entry) : IFileInfo + { + /// + public bool Exists => true; + + /// + public bool IsDirectory => false; + + /// + public DateTimeOffset LastModified => entry.LastWriteTime; + + /// + public long Length => entry.Length; + + /// + public string? PhysicalPath => null; + + /// + public string Name => name; + + /// + public Stream CreateReadStream() => + entry.Open(); + + private string ToStringDebugger() => + entry.FullName; + } + + #endregion + + #region Inner type: ZipDirectoryInfoDebuggerProxy + + /// + /// Represents a debugger proxy for viewing the contents of a instance in a more user-friendly way during debugging. + /// + /// The instance to provide debugging information for. + private sealed class ZipDirectoryInfoDebuggerProxy(ZipDirectoryInfo directoryInfo) + { + /// + /// Gets an array of instances representing the files within the associated . + /// + [DebuggerBrowsable(DebuggerBrowsableState.RootHidden)] + public IFileInfo[] Files { get; } = directoryInfo.ToArray(); + } + + #endregion +} diff --git a/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs new file mode 100644 index 0000000..4a3bfa6 --- /dev/null +++ b/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs @@ -0,0 +1,193 @@ +using System.IO.Compression; + +using Ramstack.FileProviders.Utilities; + +namespace Ramstack.FileProviders; + +[TestFixture] +public class ZipFileProviderTests : AbstractFileProviderTests +{ + private readonly TempFileStorage _storage = new TempFileStorage(); + private readonly string _path = + Path.Combine( + Path.GetTempPath(), + Path.GetRandomFileName() + ) + ".zip"; + + [OneTimeSetUp] + public void Setup() + { + ZipFile.CreateFromDirectory( + sourceDirectoryName: _storage.Root, + destinationArchiveFileName: _path, + compressionLevel: CompressionLevel.SmallestSize, + includeBaseDirectory: false); + } + + [OneTimeTearDown] + public void Cleanup() + { + _storage.Dispose(); + File.Delete(_path); + } + + [Test] + public void ZipArchive_WithIdenticalNameEntries() + { + using var provider = new ZipFileProvider(CreateArchive()); + + var list = provider + .EnumerateFiles("/1") + .ToArray(); + + Assert.That( + list.Length, + Is.EqualTo(1)); + + Assert.That( + list[0].ReadAllBytes(), + Is.EquivalentTo("Hello, World!"u8.ToArray())); + + static MemoryStream CreateArchive() + { + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + var a = archive.CreateEntry("1/text.txt"); + using (var writer = a.Open()) + writer.Write("Hello, World!"u8); + + archive.CreateEntry("1/text.txt"); + archive.CreateEntry(@"1\text.txt"); + } + + stream.Position = 0; + return stream; + } + } + + [Test] + public void ZipArchive_PrefixedEntries() + { + var archive = new ZipArchive(CreateArchive(), ZipArchiveMode.Read, leaveOpen: true); + using var provider = new ZipFileProvider(archive); + + var directories = provider + .EnumerateDirectories("/", "**") + .Select(f => + f.FullName) + .OrderBy(f => f) + .ToArray(); + + var files = provider + .EnumerateFiles("/", "**") + .Select(f => + f.FullName) + .OrderBy(f => f) + .ToArray(); + + Assert.That(files, Is.EquivalentTo( + [ + "/1/text.txt", + "/2/text.txt", + "/3/text.txt", + "/4/text.txt", + "/5/text.txt", + "/localhost/backup/text.txt", + "/localhost/share/text.txt", + "/server/backup/text.txt", + "/server/share/text.txt", + "/text.txt", + "/text.xml" + ])); + + Assert.That(directories, Is.EquivalentTo( + [ + "/1", + "/2", + "/3", + "/4", + "/5", + "/localhost", + "/localhost/backup", + "/localhost/share", + "/server", + "/server/backup", + "/server/share" + ])); + + static MemoryStream CreateArchive() + { + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + archive.CreateEntry(@"D:\1/text.txt"); + archive.CreateEntry(@"D:2\text.txt"); + + archive.CreateEntry(@"\\?\D:\text.txt"); + archive.CreateEntry(@"\\?\D:text.xml"); + archive.CreateEntry(@"\\.\D:\3\text.txt"); + archive.CreateEntry(@"//?/D:/4\text.txt"); + archive.CreateEntry(@"//./D:\5/text.txt"); + + archive.CreateEntry(@"\\?\UNC\localhost\share\text.txt"); + archive.CreateEntry(@"\\.\unc\server\share\text.txt"); + archive.CreateEntry(@"//?/UNC/localhost/backup\text.txt"); + archive.CreateEntry(@"//./unc/server/backup\text.txt"); + } + + stream.Position = 0; + return stream; + } + } + + [Test] + public void ZipArchive_Directories() + { + using var provider = new ZipFileProvider(CreateArchive()); + + var directories = provider + .EnumerateDirectories("/", "**") + .Select(f => + f.FullName) + .OrderBy(f => f) + .ToArray(); + + Assert.That(directories, Is.EquivalentTo( + [ + "/1", + "/2", + "/2/3", + "/4", + "/4/5", + "/4/5/6" + ])); + + static MemoryStream CreateArchive() + { + var stream = new MemoryStream(); + using (var archive = new ZipArchive(stream, ZipArchiveMode.Create, leaveOpen: true)) + { + archive.CreateEntry(@"\1/"); + archive.CreateEntry(@"\2/"); + archive.CreateEntry(@"/2\"); + archive.CreateEntry(@"/2\"); + archive.CreateEntry(@"/2\"); + archive.CreateEntry(@"/2\3/"); + archive.CreateEntry(@"/2\3/"); + archive.CreateEntry(@"/2\3/"); + archive.CreateEntry(@"4\5/6\"); + } + + stream.Position = 0; + return stream; + } + } + + protected override IFileProvider GetFileProvider() => + new ZipFileProvider(_path); + + protected override DirectoryInfo GetDirectoryInfo() => + new DirectoryInfo(_storage.Root); + +}