diff --git a/src/Ramstack.FileSystem.Zip/ZipDirectory.cs b/src/Ramstack.FileSystem.Zip/ZipDirectory.cs
index 4603915..4fd2d01 100644
--- a/src/Ramstack.FileSystem.Zip/ZipDirectory.cs
+++ b/src/Ramstack.FileSystem.Zip/ZipDirectory.cs
@@ -7,6 +7,7 @@ namespace Ramstack.FileSystem.Zip;
///
/// Represents directory contents and file information within a ZIP archive for the specified path.
///
+[Obsolete]
[DebuggerTypeProxy(typeof(ZipDirectoryDebuggerProxy))]
internal sealed class ZipDirectory : VirtualDirectory
{
diff --git a/src/Ramstack.FileSystem.Zip/ZipFile.cs b/src/Ramstack.FileSystem.Zip/ZipFile.cs
index 7fec62b..76b2fe7 100644
--- a/src/Ramstack.FileSystem.Zip/ZipFile.cs
+++ b/src/Ramstack.FileSystem.Zip/ZipFile.cs
@@ -5,6 +5,7 @@ namespace Ramstack.FileSystem.Zip;
///
/// Represents a file within a ZIP archive.
///
+[Obsolete]
internal sealed class ZipFile : VirtualFile
{
private readonly ZipFileSystem _fileSystem;
diff --git a/src/Ramstack.FileSystem.Zip/ZipFileSystem.cs b/src/Ramstack.FileSystem.Zip/ZipFileSystem.cs
index 0274e12..0754f0e 100644
--- a/src/Ramstack.FileSystem.Zip/ZipFileSystem.cs
+++ b/src/Ramstack.FileSystem.Zip/ZipFileSystem.cs
@@ -1,4 +1,5 @@
using System.IO.Compression;
+using System.Runtime.CompilerServices;
using Ramstack.FileSystem.Null;
@@ -100,22 +101,29 @@ private void Initialize(ZipArchive archive, Dictionary cach
{
foreach (var entry in archive.Entries)
{
- // Skipping directories
- // --------------------
- // Directory entries are denoted by a trailing slash '/' in their names.
//
- // Since we can't rely on all archivers to include directory entries in archives,
- // it's simpler to assume their absence and ignore any entries ending with a forward slash '/'.
+ // Strip common path prefixes from zip entries to handle archives
+ // saved with absolute paths.
+ //
+ var path = VirtualPath.Normalize(
+ entry.FullName[GetPrefixLength(entry.FullName)..]);
- if (entry.FullName.EndsWith('/'))
+ if (VirtualPath.HasTrailingSlash(entry.FullName))
+ {
+ GetOrCreateDirectory(path);
continue;
+ }
- var path = VirtualPath.Normalize(entry.FullName);
var directory = GetOrCreateDirectory(VirtualPath.GetDirectoryName(path));
var file = new ZipFile(this, path, entry);
- directory.RegisterNode(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.RegisterNode(file);
}
ZipDirectory GetOrCreateDirectory(string path)
@@ -131,4 +139,36 @@ ZipDirectory GetOrCreateDirectory(string path)
return (ZipDirectory)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';
+ }
+
}
diff --git a/tests/Ramstack.FileSystem.Zip.Tests/ZipFileSystemTests.cs b/tests/Ramstack.FileSystem.Zip.Tests/ZipFileSystemTests.cs
index 9092723..8345e4c 100644
--- a/tests/Ramstack.FileSystem.Zip.Tests/ZipFileSystemTests.cs
+++ b/tests/Ramstack.FileSystem.Zip.Tests/ZipFileSystemTests.cs
@@ -3,6 +3,8 @@
using Ramstack.FileSystem.Specification.Tests;
using Ramstack.FileSystem.Specification.Tests.Utilities;
+#pragma warning disable CS0618 // Type or member is obsolete
+
namespace Ramstack.FileSystem.Zip;
[TestFixture]
@@ -24,6 +26,160 @@ public void Cleanup()
File.Delete(_path);
}
+ [Test]
+ public async Task ZipArchive_WithIdenticalNameEntries()
+ {
+ using var fs = new ZipFileSystem(CreateArchive());
+
+ var list = await fs
+ .GetFilesAsync("/1")
+ .ToArrayAsync();
+
+ Assert.That(
+ list.Length,
+ Is.EqualTo(1));
+
+ Assert.That(
+ await list[0].ReadAllBytesAsync(),
+ 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 async Task ZipArchive_PrefixedEntries()
+ {
+ var archive = new ZipArchive(CreateArchive(), ZipArchiveMode.Read, leaveOpen: true);
+ using var fs = new ZipFileSystem(archive);
+
+ var directories = await fs
+ .GetDirectoriesAsync("/", "**")
+ .Select(f =>
+ f.FullName)
+ .OrderBy(f => f)
+ .ToArrayAsync();
+
+ var files = await fs
+ .GetFilesAsync("/", "**")
+ .Select(f =>
+ f.FullName)
+ .OrderBy(f => f)
+ .ToArrayAsync();
+
+ 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 async Task ZipArchive_Directories()
+ {
+ using var fs = new ZipFileSystem(CreateArchive());
+
+ var directories = await fs
+ .GetDirectoriesAsync("/", "**")
+ .Select(f =>
+ f.FullName)
+ .OrderBy(f => f)
+ .ToArrayAsync();
+
+ 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 IVirtualFileSystem GetFileSystem() =>
new ZipFileSystem(_path);