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);