Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Ramstack.FileSystem.Zip/ZipDirectory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace Ramstack.FileSystem.Zip;
/// <summary>
/// Represents directory contents and file information within a ZIP archive for the specified path.
/// </summary>
[Obsolete]
[DebuggerTypeProxy(typeof(ZipDirectoryDebuggerProxy))]
internal sealed class ZipDirectory : VirtualDirectory
{
Expand Down
1 change: 1 addition & 0 deletions src/Ramstack.FileSystem.Zip/ZipFile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace Ramstack.FileSystem.Zip;
/// <summary>
/// Represents a file within a ZIP archive.
/// </summary>
[Obsolete]
internal sealed class ZipFile : VirtualFile
{
private readonly ZipFileSystem _fileSystem;
Expand Down
58 changes: 49 additions & 9 deletions src/Ramstack.FileSystem.Zip/ZipFileSystem.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.IO.Compression;
using System.Runtime.CompilerServices;

using Ramstack.FileSystem.Null;

Expand Down Expand Up @@ -100,22 +101,29 @@ private void Initialize(ZipArchive archive, Dictionary<string, VirtualNode> 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)
Expand All @@ -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';
}

}
156 changes: 156 additions & 0 deletions tests/Ramstack.FileSystem.Zip.Tests/ZipFileSystemTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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;
}
}


/// <inheritdoc />
protected override IVirtualFileSystem GetFileSystem() =>
new ZipFileSystem(_path);
Expand Down
Loading