diff --git a/src/Ramstack.FileProviders.Extensions/DirectoryNode.cs b/src/Ramstack.FileProviders.Extensions/DirectoryNode.cs index e3fbdc8..4cec2e1 100644 --- a/src/Ramstack.FileProviders.Extensions/DirectoryNode.cs +++ b/src/Ramstack.FileProviders.Extensions/DirectoryNode.cs @@ -166,7 +166,7 @@ private sealed class DirectoryFileInfoContents(DirectoryNode directory) : IFileI /// public Stream CreateReadStream() => - throw new NotSupportedException("Cannot create a stream for a directory"); + throw new NotSupportedException("Cannot create a read stream for a directory."); /// public IEnumerator GetEnumerator() => diff --git a/src/Ramstack.FileProviders/ZipFileProvider.cs b/src/Ramstack.FileProviders/ZipFileProvider.cs index 3dc3854..d639a26 100644 --- a/src/Ramstack.FileProviders/ZipFileProvider.cs +++ b/src/Ramstack.FileProviders/ZipFileProvider.cs @@ -1,4 +1,5 @@ using System.IO.Compression; +using System.Runtime.CompilerServices; namespace Ramstack.FileProviders; @@ -8,7 +9,7 @@ namespace Ramstack.FileProviders; public sealed class ZipFileProvider : IFileProvider, IDisposable { private readonly ZipArchive _archive; - private readonly Dictionary _directories = + private readonly Dictionary _cache = new() { ["/"] = new ZipDirectoryInfo("/") }; /// @@ -29,8 +30,12 @@ public ZipFileProvider(string path) /// to leave the stream open /// after the object is disposed; otherwise, . public ZipFileProvider(Stream stream, bool leaveOpen = false) - : this(new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen)) { + if (!stream.CanSeek) + throw new ArgumentException("Stream does not support seeking.", nameof(stream)); + + _archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen); + Initialize(_archive, _cache); } /// @@ -41,13 +46,18 @@ public ZipFileProvider(Stream stream, bool leaveOpen = false) /// 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, _directories); + Initialize(archive, _cache); } /// public IFileInfo GetFileInfo(string subpath) => - Find(subpath) ?? new NotFoundFileInfo(Path.GetFileName(subpath)); + Find(subpath) ?? new NotFoundFileInfo(FilePath.GetFileName(subpath)); /// public IDirectoryContents GetDirectoryContents(string subpath) => @@ -62,7 +72,7 @@ public void Dispose() => _archive.Dispose(); private IFileInfo? Find(string path) => - _directories.GetValueOrDefault(FilePath.Normalize(path)); + _cache.GetValueOrDefault(FilePath.Normalize(path)); /// /// Initializes the current provider by populating it with entries from the underlying ZIP archive. @@ -71,21 +81,29 @@ private static void Initialize(ZipArchive archive, Dictionary { foreach (var entry in archive.Entries) { - // Skip directories. - // Directory entries are represented by a trailing slash in their names. // - // Since we cannot rely on all archivers to represent directory entries within the archive, - // it's simpler to assume their absence and disregard entries ending with a forward slash '/' + // Strip common path prefixes from zip entries to handle archives + // saved with absolute paths. + // + var path = FilePath.Normalize( + entry.FullName[GetPrefixLength(entry.FullName)..]); - if (entry.FullName.EndsWith('/')) + if (FilePath.HasTrailingSlash(entry.FullName)) + { + GetDirectory(path); continue; + } - var path = FilePath.Normalize(entry.FullName); var directory = GetDirectory(FilePath.GetDirectoryName(path)); - var file = new ZipFileInfo(entry); + var file = new ZipFileInfo(FilePath.GetFileName(path), entry); - directory.RegisterFile(file); - cache.Add(path, file); + // + // 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) @@ -93,7 +111,7 @@ ZipDirectoryInfo GetDirectory(string path) if (cache.TryGetValue(path, out var di)) return (ZipDirectoryInfo)di; - di = new ZipDirectoryInfo(path); + di = new ZipDirectoryInfo(FilePath.GetFileName(path)); var parent = GetDirectory(FilePath.GetDirectoryName(path)); parent.RegisterFile(di); cache.Add(path, di); @@ -102,16 +120,47 @@ ZipDirectoryInfo GetDirectory(string path) } } + [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 path of the directory within the ZIP archive. - [DebuggerDisplay("{ToStringDebugger(),nq}")] + /// The name of the directory, not including any path. + [DebuggerDisplay("{Name,nq}")] [DebuggerTypeProxy(typeof(ZipDirectoryInfoDebuggerProxy))] - private sealed class ZipDirectoryInfo(string path) : IDirectoryContents, IFileInfo + private sealed class ZipDirectoryInfo(string name) : IDirectoryContents, IFileInfo { /// /// The list of the within this directory. @@ -128,7 +177,7 @@ private sealed class ZipDirectoryInfo(string path) : IDirectoryContents, IFileIn public string? PhysicalPath => null; /// - public string Name => Path.GetFileName(path); + public string Name => name; /// public DateTimeOffset LastModified => default; @@ -138,7 +187,7 @@ private sealed class ZipDirectoryInfo(string path) : IDirectoryContents, IFileIn /// public Stream CreateReadStream() => - throw new NotSupportedException("Cannot create a stream for a directory"); + throw new NotSupportedException("Cannot create a read stream for a directory."); /// public IEnumerator GetEnumerator() => @@ -154,15 +203,6 @@ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => /// The file associated with this directory. public void RegisterFile(IFileInfo file) => _files.Add(file); - - /// - /// Returns a string representation of the current instance's state, intended for debugging purposes. - /// - /// - /// A string containing information about the current instance. - /// - private string ToStringDebugger() => - path; } #endregion @@ -172,9 +212,10 @@ private string ToStringDebugger() => /// /// 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(ZipArchiveEntry entry) : IFileInfo + private sealed class ZipFileInfo(string name, ZipArchiveEntry entry) : IFileInfo { /// public bool Exists => true; @@ -192,20 +233,14 @@ private sealed class ZipFileInfo(ZipArchiveEntry entry) : IFileInfo public string? PhysicalPath => null; /// - public string Name => entry.Name; + public string Name => name; /// public Stream CreateReadStream() => entry.Open(); - /// - /// Returns a string representation of the current instance's state, intended for debugging purposes. - /// - /// - /// A string containing information about the current instance. - /// private string ToStringDebugger() => - $"/{entry.FullName}"; + entry.FullName; } #endregion diff --git a/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs index eee0c4e..4a3bfa6 100644 --- a/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs +++ b/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs @@ -31,6 +31,159 @@ public void Cleanup() 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);