From 4c66aa4fe095df912fcc56bbdefe1aafcc254706 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 1 Sep 2025 03:01:11 +0500 Subject: [PATCH] Final improvements and bug fixes to ZipFileSystem --- src/Ramstack.FileSystem.Zip/ZipDirectory.cs | 1 + src/Ramstack.FileSystem.Zip/ZipFile.cs | 1 + src/Ramstack.FileSystem.Zip/ZipFileSystem.cs | 58 ++++++- .../ZipFileSystemTests.cs | 156 ++++++++++++++++++ 4 files changed, 207 insertions(+), 9 deletions(-) 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);