From 32ad1f9b28de06162ff6f4995b0091612b177ac0 Mon Sep 17 00:00:00 2001 From: Daniel Walder Date: Sat, 19 Oct 2024 17:26:10 +1000 Subject: [PATCH] Additions to the IFileSystem interface --- .../FileSystem/TestAllFileResolvers.cs | 329 ++++++++++++++++++ .../FileSystem/TestDiskFileResolver.cs | 2 +- .../TestVirtualSubdirectoryFileResolver.cs | 114 ++++++ .../FileSystem/TestZipArchiveResolver.cs | 40 ++- .../Sledge.Formats.Tests.csproj | 2 + .../FileSystem/CompositeFileResolver.cs | 19 +- Sledge.Formats/FileSystem/DiskFileResolver.cs | 21 +- Sledge.Formats/FileSystem/IFileResolver.cs | 44 +++ .../VirtualSubdirectoryFileResolver.cs | 110 ++++++ .../FileSystem/ZipArchiveResolver.cs | 60 ++-- Sledge.Formats/Sledge.Formats.csproj | 4 +- 11 files changed, 701 insertions(+), 44 deletions(-) create mode 100644 Sledge.Formats.Tests/FileSystem/TestAllFileResolvers.cs create mode 100644 Sledge.Formats.Tests/FileSystem/TestVirtualSubdirectoryFileResolver.cs create mode 100644 Sledge.Formats/FileSystem/VirtualSubdirectoryFileResolver.cs diff --git a/Sledge.Formats.Tests/FileSystem/TestAllFileResolvers.cs b/Sledge.Formats.Tests/FileSystem/TestAllFileResolvers.cs new file mode 100644 index 0000000..bc58863 --- /dev/null +++ b/Sledge.Formats.Tests/FileSystem/TestAllFileResolvers.cs @@ -0,0 +1,329 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.IO.Compression; +using System.Linq; +using System.Text; +using Sledge.Formats.FileSystem; +using System.IO; + +namespace Sledge.Formats.Tests.FileSystem; + +[TestClass] +public class TestAllFileResolvers +{ + private static readonly List Folders = + [ + "", + "folder", + "folder2", + "folder3", + "folder4", + "folder5", + "folder5/folder5.1", + ]; + + private static readonly List Files = + [ + "test1.txt", + "test2.txt", + "folder/data1.log", + "folder/data2.log", + "folder2/data1.log", + "folder2/data2.log", + "folder3/f3.txt", + "folder4/f4.txt", + "folder5/folder5.1/aaa.txt" + ]; + + private static Dictionary _resolvers; + + private static readonly List CleanupActions = []; + + [ClassInitialize] + public static void Initialize(TestContext testContext) + { + _resolvers = new Dictionary + { + { "Disk", CreateDiskFileResolver() }, + { "ZipArchive", CreateZipArchiveResolver() }, + { "VirtualSubdirectory", CreateVirtualSubdirectoryFileResolver() }, + { "Composite", CreateCompositeFileResolver() } + }; + } + + [ClassCleanup] + public static void Cleanup() + { + CleanupActions.ForEach(x => + { + try + { + x.Invoke(); + } + catch + { + // + } + }); + CleanupActions.Clear(); + } + + private static IFileResolver CreateDiskFileResolver(string subdirectory = "") + { + var tempFolder = Path.GetTempPath(); + var baseDir = Path.Combine(tempFolder, "sledge.formats.tests-filesystem-" + Guid.NewGuid()); + if (subdirectory is { Length: > 0 }) baseDir = Path.Combine(baseDir, subdirectory); + if (Directory.Exists(baseDir)) Directory.Delete(baseDir, true); + Directory.CreateDirectory(baseDir); + foreach (var filepath in Files) + { + var path = Path.Combine(baseDir, filepath); + var dir = Path.GetDirectoryName(path); + if (!Directory.Exists(dir)) Directory.CreateDirectory(dir!); + File.WriteAllText(path, filepath, Encoding.ASCII); + } + CleanupActions.Add(() => + { + Directory.Delete(tempFolder); + }); + return new DiskFileResolver(baseDir); + } + + private static IFileResolver CreateVirtualSubdirectoryFileResolver() => CreateDiskFileResolver("subdir1/subdir2"); + + private static IFileResolver CreateZipArchiveResolver() + { + var ms = new MemoryStream(); + var zip = new ZipArchive(ms, ZipArchiveMode.Update, true, Encoding.ASCII); + foreach (var filepath in Files) + { + var folder = Folders.Where(x => filepath.StartsWith(x + "/")).MaxBy(x => x.Length); + if (!string.IsNullOrEmpty(folder) && zip.GetEntry(folder + "/") == null) zip.CreateEntry(folder + "/"); + var entry = zip.CreateEntry(filepath, CompressionLevel.Fastest); + using var s = entry.Open(); + var bytes = Encoding.ASCII.GetBytes(filepath); + s.Write(bytes, 0, bytes.Length); + } + + // need to close and re-create the zip archive as the .Length property requires a read-only archive + zip.Dispose(); + ms.Seek(0, SeekOrigin.Begin); + zip = new ZipArchive(ms, ZipArchiveMode.Read, true, Encoding.ASCII); + + var resolver = new ZipArchiveResolver(zip, true); + CleanupActions.Add(() => + { + resolver.Dispose(); + zip.Dispose(); + ms.Dispose(); + }); + return resolver; + } + + private static IFileResolver CreateCompositeFileResolver() + { + var ms = new MemoryStream(); + var zip = new ZipArchive(ms, ZipArchiveMode.Update, true, Encoding.ASCII); + + var tempFolder = Path.GetTempPath(); + var baseDir = Path.Combine(tempFolder, "sledge.formats.tests-filesystem-" + Guid.NewGuid()); + if (Directory.Exists(baseDir)) Directory.Delete(baseDir, true); + Directory.CreateDirectory(baseDir); + + void AddZip(string filepath) + { + var folder = Folders.Where(x => filepath.StartsWith(x + "/")).MaxBy(x => x.Length); + if (!string.IsNullOrEmpty(folder) && zip.GetEntry(folder + "/") == null) zip.CreateEntry(folder + "/"); + var entry = zip.CreateEntry(filepath, CompressionLevel.Fastest); + using var s = entry.Open(); + var bytes = Encoding.ASCII.GetBytes(filepath); + s.Write(bytes, 0, bytes.Length); + } + + void AddDisk(string filepath) + { + var path = Path.Combine(baseDir, filepath); + var dir = Path.GetDirectoryName(path); + if (!Directory.Exists(dir)) Directory.CreateDirectory(dir!); + File.WriteAllText(path, filepath, Encoding.ASCII); + } + + var idx = 0; + var rotation = new[] { AddZip, AddDisk }; + foreach (var filepath in Files) + { + rotation[idx].Invoke(filepath); + idx = (idx + 1) % rotation.Length; + } + + // need to close and re-create the zip archive as the .Length property requires a read-only archive + zip.Dispose(); + ms.Seek(0, SeekOrigin.Begin); + zip = new ZipArchive(ms, ZipArchiveMode.Read, true, Encoding.ASCII); + + var diskResolver = new DiskFileResolver(baseDir); + var zipResolver = new ZipArchiveResolver(zip, true); + + CleanupActions.Add(() => + { + Directory.Delete(tempFolder); + zipResolver.Dispose(); + zip.Dispose(); + ms.Dispose(); + }); + return new CompositeFileResolver( + diskResolver, + zipResolver + ); + } + + [DataTestMethod] + [DataRow("Disk")] + [DataRow("ZipArchive")] + [DataRow("VirtualSubdirectory")] + [DataRow("Composite")] + public void TestRootDirectory(string implementationName) + { + // test that "" and "/" both take us to the root folder + var resolver = _resolvers[implementationName]; + CollectionAssert.AreEquivalent(Files.Where(x => !x.Contains('/')).ToList(), resolver.GetFiles("").ToList()); + CollectionAssert.AreEquivalent(Files.Where(x => !x.Contains('/')).ToList(), resolver.GetFiles("/").ToList()); + CollectionAssert.AreEquivalent(Folders.Where(x => !x.Contains('/') && x != "").ToList(), resolver.GetFolders("").ToList()); + CollectionAssert.AreEquivalent(Folders.Where(x => !x.Contains('/') && x != "").ToList(), resolver.GetFolders("/").ToList()); + CollectionAssert.AreEquivalent(resolver.GetFiles("/").ToList(), resolver.GetFiles("").ToList()); + CollectionAssert.AreEquivalent(resolver.GetFolders("/").ToList(), resolver.GetFolders("").ToList()); + } + + [DataTestMethod] + [DataRow("Disk")] + [DataRow("ZipArchive")] + [DataRow("VirtualSubdirectory")] + [DataRow("Composite")] + public void TestFolderExists(string implementationName) + { + var resolver = _resolvers[implementationName]; + Assert.IsTrue(resolver.FolderExists("")); + Assert.IsTrue(resolver.FolderExists("/")); + Assert.IsFalse(resolver.FolderExists("aaaaaa")); + Assert.IsFalse(resolver.FolderExists("aaaaaa/bbbbb")); + foreach (var folder in Folders) + { + Assert.IsTrue(resolver.FolderExists(folder)); + } + foreach (var file in Files) + { + Assert.IsFalse(resolver.FolderExists(file)); + } + } + + [DataTestMethod] + [DataRow("Disk")] + [DataRow("ZipArchive")] + [DataRow("VirtualSubdirectory")] + [DataRow("Composite")] + public void TestFileExists(string implementationName) + { + var resolver = _resolvers[implementationName]; + Assert.IsTrue(resolver.FileExists("test1.txt")); + Assert.IsTrue(resolver.FileExists("/test1.txt")); + Assert.IsFalse(resolver.FileExists("bbbbb.txt")); + Assert.IsFalse(resolver.FileExists("aaaa/bbbb.log")); + foreach (var folder in Folders) + { + Assert.IsFalse(resolver.FileExists(folder)); + } + foreach (var file in Files) + { + Assert.IsTrue(resolver.FileExists(file)); + } + } + + [DataTestMethod] + [DataRow("Disk")] + [DataRow("ZipArchive")] + [DataRow("VirtualSubdirectory")] + [DataRow("Composite")] + public void TestOpenFile(string implementationName) + { + var resolver = _resolvers[implementationName]; + foreach (var file in Files) + { + using var stream = resolver.OpenFile(file); + using var tr = new StreamReader(stream, Encoding.ASCII); + Assert.AreEqual(file, tr.ReadToEnd()); + } + } + + [DataTestMethod] + [DataRow("Disk")] + [DataRow("ZipArchive")] + [DataRow("VirtualSubdirectory")] + [DataRow("Composite")] + public void TestFileSize(string implementationName) + { + var resolver = _resolvers[implementationName]; + foreach (var file in Files) + { + Assert.AreEqual(file.Length, resolver.FileSize(file)); + } + } + + [DataTestMethod] + [DataRow("Disk")] + [DataRow("ZipArchive")] + [DataRow("VirtualSubdirectory")] + [DataRow("Composite")] + public void TestFileNotFound(string implementationName) + { + var resolver = _resolvers[implementationName]; + Assert.ThrowsException(() => + { + resolver.OpenFile("not_found.txt"); + }); + Assert.ThrowsException(() => + { + resolver.OpenFile("not/found"); + }); + Assert.ThrowsException(() => + { + resolver.GetFiles("not/found"); + }); + Assert.ThrowsException(() => + { + resolver.GetFolders("not/found"); + }); + } + + [DataTestMethod] + [DataRow("Disk")] + [DataRow("ZipArchive")] + [DataRow("VirtualSubdirectory")] + [DataRow("Composite")] + public void TestGetFiles(string implementationName) + { + var resolver = _resolvers[implementationName]; + foreach (var folder in Folders) + { + var fpath = folder == "" ? "" : folder + '/'; + var expectedFiles = Files.Where(x => x.StartsWith(fpath) && !x[fpath.Length..].Contains('/')).ToList(); + CollectionAssert.AreEquivalent(expectedFiles, resolver.GetFiles(folder).ToList()); + } + } + + [DataTestMethod] + [DataRow("Disk")] + [DataRow("ZipArchive")] + [DataRow("VirtualSubdirectory")] + [DataRow("Composite")] + public void TestGetFolders(string implementationName) + { + var resolver = _resolvers[implementationName]; + foreach (var folder in Folders) + { + var fpath = folder == "" ? "" : folder + '/'; + var expectedFolders = Folders.Where(x => x != folder && x.StartsWith(fpath) && !x[fpath.Length..].Contains('/')).ToList(); + CollectionAssert.AreEquivalent(expectedFolders, resolver.GetFolders(folder).ToList()); + } + } +} \ No newline at end of file diff --git a/Sledge.Formats.Tests/FileSystem/TestDiskFileResolver.cs b/Sledge.Formats.Tests/FileSystem/TestDiskFileResolver.cs index dded6a0..ac602c2 100644 --- a/Sledge.Formats.Tests/FileSystem/TestDiskFileResolver.cs +++ b/Sledge.Formats.Tests/FileSystem/TestDiskFileResolver.cs @@ -97,7 +97,7 @@ public void TestFileNotFound() { dfs.OpenFile("not_found.txt"); }); - Assert.ThrowsException(() => + Assert.ThrowsException(() => { dfs.OpenFile("not/found"); }); diff --git a/Sledge.Formats.Tests/FileSystem/TestVirtualSubdirectoryFileResolver.cs b/Sledge.Formats.Tests/FileSystem/TestVirtualSubdirectoryFileResolver.cs new file mode 100644 index 0000000..416224c --- /dev/null +++ b/Sledge.Formats.Tests/FileSystem/TestVirtualSubdirectoryFileResolver.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.IO.Compression; +using System.IO; +using System.Linq; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Sledge.Formats.FileSystem; + +namespace Sledge.Formats.Tests.FileSystem; + +[TestClass] +public class TestVirtualSubdirectoryFileResolver +{ + private Dictionary Files => TestZipArchiveResolver.Files; + + private static ZipArchive _instance; + + [ClassInitialize] + public static void Initialize(TestContext testContext) + { + _instance = TestZipArchiveResolver.CreateZipArchive(); + } + + [ClassCleanup] + public static void Cleanup() + { + _instance?.Dispose(); + } + + [TestMethod] + public void TestFileExists() + { + var vsfr = new VirtualSubdirectoryFileResolver("something/", new ZipArchiveResolver(_instance)); + foreach (var kv in Files) + { + Assert.IsTrue(vsfr.FileExists("something/" + kv.Key)); + } + } + + [TestMethod] + public void TestOpenFile() + { + var vsfr = new VirtualSubdirectoryFileResolver("something/", new ZipArchiveResolver(_instance)); + foreach (var kv in Files) + { + using var s = vsfr.OpenFile("something/" + kv.Key); + using var tr = new StreamReader(s, Encoding.ASCII); + Assert.AreEqual(kv.Value, tr.ReadToEnd()); + } + } + + [TestMethod] + public void TestGetFiles() + { + var vsfr = new VirtualSubdirectoryFileResolver("something/", new ZipArchiveResolver(_instance)); + + var files = vsfr.GetFiles("/").ToList(); + Assert.AreEqual(0, files.Count); + CollectionAssert.AreEquivalent(Array.Empty(), files); + + files = vsfr.GetFiles("something/").ToList(); + Assert.AreEqual(5, files.Count); + CollectionAssert.AreEquivalent(new[] { "something/test1.txt", "something/test2.txt", "something/test3.txt", "something/test4.txt", "something/test5.txt" }, files); + } + + [TestMethod] + public void TestGetFolders() + { + var vsfr = new VirtualSubdirectoryFileResolver("something/", new ZipArchiveResolver(_instance)); + + var folders = vsfr.GetFolders("/").ToList(); + Assert.AreEqual(1, folders.Count); + CollectionAssert.AreEquivalent(new[] { "something" }, folders); + + folders = vsfr.GetFolders("something/").ToList(); + Assert.AreEqual(3, folders.Count); + CollectionAssert.AreEquivalent(new[] { "something/folder", "something/folder2", "something/folder3" }, folders); + + folders = vsfr.GetFolders("something/folder3").ToList(); + Assert.AreEqual(1, folders.Count); + CollectionAssert.AreEquivalent(new[] { "something/folder3/folder3.1" }, folders); + } + + [TestMethod] + public void TestFileNotFound() + { + var vsfr = new VirtualSubdirectoryFileResolver("something/", new ZipArchiveResolver(_instance)); + Assert.ThrowsException(() => + { + vsfr.OpenFile("test1.txt"); + }); + Assert.ThrowsException(() => + { + vsfr.OpenFile("not_found.txt"); + }); + Assert.ThrowsException(() => + { + vsfr.OpenFile("something/not_found.txt"); + }); + Assert.ThrowsException(() => + { + vsfr.OpenFile("something/not/found"); + }); + Assert.ThrowsException(() => + { + vsfr.GetFiles("something/not/found"); + }); + Assert.ThrowsException(() => + { + vsfr.GetFolders("something/not/found"); + }); + } +} \ No newline at end of file diff --git a/Sledge.Formats.Tests/FileSystem/TestZipArchiveResolver.cs b/Sledge.Formats.Tests/FileSystem/TestZipArchiveResolver.cs index 7a57f10..a30888d 100644 --- a/Sledge.Formats.Tests/FileSystem/TestZipArchiveResolver.cs +++ b/Sledge.Formats.Tests/FileSystem/TestZipArchiveResolver.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; @@ -12,7 +11,7 @@ namespace Sledge.Formats.Tests.FileSystem; [TestClass] public class TestZipArchiveResolver { - private static readonly Dictionary Files = new() + internal static readonly Dictionary Files = new() { { "test1.txt", "1" }, { "test2.txt", "22" }, @@ -25,19 +24,29 @@ public class TestZipArchiveResolver { "folder2/data1.log", "data goes here" }, { "folder2/data2.log", "data goes here" }, { "folder2/data3.log", "data goes here" }, + { "folder3/folder3.1/data3.log", "data goes here" }, }; - private const string Archive = "UEsDBBQAAAAAAFWQNlYAAAAAAAAAAAAAAAAHAAAAZm9sZGVyL1BLAwQUAAAAAABVkDZWAAAAAAAAAAAAAAAACAAAAGZvbGRlcjIvUEsDBAoAAAAAAFWQNlZav4taDgAAAA4AAAARAAAAZm9sZGVyMi9kYXRhMS5sb2dkYXRhIGdvZXMgaGVyZVBLAwQKAAAAAABVkDZWWr+LWg4AAAAOAAAAEQAAAGZvbGRlcjIvZGF0YTIubG9nZGF0YSBnb2VzIGhlcmVQSwMECgAAAAAAVZA2Vlq/i1oOAAAADgAAABEAAABmb2xkZXIyL2RhdGEzLmxvZ2RhdGEgZ29lcyBoZXJlUEsDBAoAAAAAAFWQNlZav4taDgAAAA4AAAAQAAAAZm9sZGVyL2RhdGExLmxvZ2RhdGEgZ29lcyBoZXJlUEsDBAoAAAAAAFWQNlZav4taDgAAAA4AAAAQAAAAZm9sZGVyL2RhdGEyLmxvZ2RhdGEgZ29lcyBoZXJlUEsDBAoAAAAAAFWQNlZav4taDgAAAA4AAAAQAAAAZm9sZGVyL2RhdGEzLmxvZ2RhdGEgZ29lcyBoZXJlUEsDBAoAAAAAAFWQNla379yDAQAAAAEAAAAJAAAAdGVzdDEudHh0MVBLAwQKAAAAAABVkDZWDhd+ZAIAAAACAAAACQAAAHRlc3QyLnR4dDIyUEsDBAoAAAAAAFWQNlb9hteSAwAAAAMAAAAJAAAAdGVzdDMudHh0MzMzUEsDBAoAAAAAAFWQNlbk+vHnBAAAAAQAAAAJAAAAdGVzdDQudHh0NDQ0NFBLAwQUAAAACABVkDZWluk0vgQAAAAFAAAACQAAAHRlc3Q1LnR4dDMFAQBQSwECPwAUAAAAAABVkDZWAAAAAAAAAAAAAAAABwAkAAAAAAAAABAAAAAAAAAAZm9sZGVyLwoAIAAAAAAAAQAYANfPmuY3LtkB18+a5jcu2QHDqJrmNy7ZAVBLAQI/ABQAAAAAAFWQNlYAAAAAAAAAAAAAAAAIACQAAAAAAAAAEAAAACUAAABmb2xkZXIyLwoAIAAAAAAAAQAYANP2muY3LtkBZOAs8Dcu2QHXz5rmNy7ZAVBLAQI/AAoAAAAAAFWQNlZav4taDgAAAA4AAAARACQAAAAAAAAAIAAAAEsAAABmb2xkZXIyL2RhdGExLmxvZwoAIAAAAAAAAQAYANfPmuY3LtkB18+a5jcu2QHXz5rmNy7ZAVBLAQI/AAoAAAAAAFWQNlZav4taDgAAAA4AAAARACQAAAAAAAAAIAAAAIgAAABmb2xkZXIyL2RhdGEyLmxvZwoAIAAAAAAAAQAYANfPmuY3LtkB18+a5jcu2QHXz5rmNy7ZAVBLAQI/AAoAAAAAAFWQNlZav4taDgAAAA4AAAARACQAAAAAAAAAIAAAAMUAAABmb2xkZXIyL2RhdGEzLmxvZwoAIAAAAAAAAQAYANP2muY3LtkB0/aa5jcu2QHT9prmNy7ZAVBLAQI/AAoAAAAAAFWQNlZav4taDgAAAA4AAAAQACQAAAAAAAAAIAAAAAIBAABmb2xkZXIvZGF0YTEubG9nCgAgAAAAAAABABgAw6ia5jcu2QHDqJrmNy7ZAcOomuY3LtkBUEsBAj8ACgAAAAAAVZA2Vlq/i1oOAAAADgAAABAAJAAAAAAAAAAgAAAAPgEAAGZvbGRlci9kYXRhMi5sb2cKACAAAAAAAAEAGADDqJrmNy7ZAcOomuY3LtkBw6ia5jcu2QFQSwECPwAKAAAAAABVkDZWWr+LWg4AAAAOAAAAEAAkAAAAAAAAACAAAAB6AQAAZm9sZGVyL2RhdGEzLmxvZwoAIAAAAAAAAQAYANfPmuY3LtkB18+a5jcu2QHXz5rmNy7ZAVBLAQI/AAoAAAAAAFWQNla379yDAQAAAAEAAAAJACQAAAAAAAAAIAAAALYBAAB0ZXN0MS50eHQKACAAAAAAAAEAGAB2DJrmNy7ZAXYMmuY3LtkBdgya5jcu2QFQSwECPwAKAAAAAABVkDZWDhd+ZAIAAAACAAAACQAkAAAAAAAAACAAAADeAQAAdGVzdDIudHh0CgAgAAAAAAABABgAhDOa5jcu2QGEM5rmNy7ZAYQzmuY3LtkBUEsBAj8ACgAAAAAAVZA2Vv2G15IDAAAAAwAAAAkAJAAAAAAAAAAgAAAABwIAAHRlc3QzLnR4dAoAIAAAAAAAAQAYAKaBmuY3LtkBpoGa5jcu2QGmgZrmNy7ZAVBLAQI/AAoAAAAAAFWQNlbk+vHnBAAAAAQAAAAJACQAAAAAAAAAIAAAADECAAB0ZXN0NC50eHQKACAAAAAAAAEAGACmgZrmNy7ZAaaBmuY3LtkBpoGa5jcu2QFQSwECPwAUAAAACABVkDZWluk0vgQAAAAFAAAACQAkAAAAAAAAACAAAABcAgAAdGVzdDUudHh0CgAgAAAAAAABABgAw6ia5jcu2QHDqJrmNy7ZAaaBmuY3LtkBUEsFBgAAAAANAA0AyQQAAIcCAAAAAA=="; - private static ZipArchive _instance; + internal static ZipArchive CreateZipArchive() + { + var ms = new MemoryStream(); + var zip = new ZipArchive(ms, ZipArchiveMode.Update, false); + foreach (var (k, v) in Files) + { + var e = zip.CreateEntry(k); + using var s = e.Open(); + var bytes = Encoding.ASCII.GetBytes(v); + s.Write(bytes, 0, bytes.Length); + } + return zip; + } + [ClassInitialize] public static void Initialize(TestContext testContext) { - var data = Convert.FromBase64String(Archive); - var ms = new MemoryStream(data); - ms.Position = 0; - _instance = new ZipArchive(ms, ZipArchiveMode.Read, false); + _instance = CreateZipArchive(); } [ClassCleanup] @@ -81,9 +90,14 @@ public void TestGetFiles() public void TestGetFolders() { var zfs = new ZipArchiveResolver(_instance); + var folders = zfs.GetFolders("/").ToList(); - Assert.AreEqual(2, folders.Count); - CollectionAssert.AreEquivalent(new[] { "folder", "folder2" }, folders); + Assert.AreEqual(3, folders.Count); + CollectionAssert.AreEquivalent(new[] { "folder", "folder2", "folder3" }, folders); + + var folders2 = zfs.GetFolders("folder3").ToList(); + Assert.AreEqual(1, folders2.Count); + CollectionAssert.AreEquivalent(new[] { "folder3/folder3.1" }, folders2); } [TestMethod] @@ -98,11 +112,11 @@ public void TestFileNotFound() { zfs.OpenFile("not/found"); }); - Assert.ThrowsException(() => + Assert.ThrowsException(() => { zfs.GetFiles("not/found"); }); - Assert.ThrowsException(() => + Assert.ThrowsException(() => { zfs.GetFolders("not/found"); }); diff --git a/Sledge.Formats.Tests/Sledge.Formats.Tests.csproj b/Sledge.Formats.Tests/Sledge.Formats.Tests.csproj index ade0056..6264a47 100644 --- a/Sledge.Formats.Tests/Sledge.Formats.Tests.csproj +++ b/Sledge.Formats.Tests/Sledge.Formats.Tests.csproj @@ -4,6 +4,8 @@ net6.0 false + + 12 diff --git a/Sledge.Formats/FileSystem/CompositeFileResolver.cs b/Sledge.Formats/FileSystem/CompositeFileResolver.cs index a081571..9324813 100644 --- a/Sledge.Formats/FileSystem/CompositeFileResolver.cs +++ b/Sledge.Formats/FileSystem/CompositeFileResolver.cs @@ -27,11 +27,22 @@ public CompositeFileResolver(params IFileResolver[] resolvers) _resolvers = resolvers.ToList(); } + public bool FolderExists(string path) + { + return _resolvers.Any(x => x.FolderExists(path)); + } + public bool FileExists(string path) { return _resolvers.Any(x => x.FileExists(path)); } + public long FileSize(string path) + { + var resolver = _resolvers.FirstOrDefault(x => x.FileExists(path)) ?? throw new FileNotFoundException(); + return resolver.FileSize(path); + } + public Stream OpenFile(string path) { var resolver = _resolvers.FirstOrDefault(x => x.FileExists(path)) ?? throw new FileNotFoundException(); @@ -40,12 +51,16 @@ public Stream OpenFile(string path) public IEnumerable GetFiles(string path) { - return _resolvers.SelectMany(x => x.GetFiles(path)).Distinct(); + var resolvers = _resolvers.Where(x => x.FolderExists(path)).ToList(); + if (resolvers.Count == 0) throw new DirectoryNotFoundException(); + return resolvers.SelectMany(x => x.GetFiles(path)).Distinct(); } public IEnumerable GetFolders(string path) { - return _resolvers.SelectMany(x => x.GetFolders(path)).Distinct(); + var resolvers = _resolvers.Where(x => x.FolderExists(path)).ToList(); + if (resolvers.Count == 0) throw new DirectoryNotFoundException(); + return resolvers.SelectMany(x => x.GetFolders(path)).Distinct(); } } } \ No newline at end of file diff --git a/Sledge.Formats/FileSystem/DiskFileResolver.cs b/Sledge.Formats/FileSystem/DiskFileResolver.cs index b2531d4..eaa6a29 100644 --- a/Sledge.Formats/FileSystem/DiskFileResolver.cs +++ b/Sledge.Formats/FileSystem/DiskFileResolver.cs @@ -31,8 +31,27 @@ private string NormalisePath(string path) return path.Substring(_basePath.Length).TrimStart('/'); } + public bool FolderExists(string path) => Directory.Exists(MakePath(path)); public bool FileExists(string path) => File.Exists(MakePath(path)); - public Stream OpenFile(string path) => File.Open(MakePath(path), FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + + public long FileSize(string path) + { + var fi = new FileInfo(MakePath(path)); + return fi.Length; + } + + public Stream OpenFile(string path) + { + try + { + return File.Open(MakePath(path), FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + } + catch (DirectoryNotFoundException ex) + { + throw new FileNotFoundException(ex.Message, ex); + } + } + public IEnumerable GetFiles(string path) => Directory.GetFiles(MakePath(path)).Select(NormalisePath); public IEnumerable GetFolders(string path) => Directory.GetDirectories(MakePath(path)).Select(NormalisePath); } diff --git a/Sledge.Formats/FileSystem/IFileResolver.cs b/Sledge.Formats/FileSystem/IFileResolver.cs index 53d06e4..0bb5819 100644 --- a/Sledge.Formats/FileSystem/IFileResolver.cs +++ b/Sledge.Formats/FileSystem/IFileResolver.cs @@ -3,11 +3,55 @@ namespace Sledge.Formats.FileSystem { + /// + /// Abstraction for a file system + /// public interface IFileResolver { + /// + /// Check if a folder exists or not + /// + /// An absolute path of a folder to check existance of + /// True if the folder exists + bool FolderExists(string path); + + /// + /// Check if a file exists or not + /// + /// An absolute path of a file to check existance of + /// True if the file exists bool FileExists(string path); + + /// + /// Get the size, in bytes, of a file + /// + /// An absolute path of a file to get the size of + /// The size of the file in bytes + /// If the file doesn't exist + long FileSize(string path); + + /// + /// Open a read-only stream to a file + /// + /// An absolute path of a file to open + /// A stream + /// If the file doesn't exist Stream OpenFile(string path); + + /// + /// Get a list of all the files in a folder + /// + /// An absolute path of a folder to enumerate the files of + /// A list of absolute paths for the list of files in the folder. May be empty. + /// If the folder doesn't exist IEnumerable GetFiles(string path); + + /// + /// Get a list of all the subfolders in a folder + /// + /// An absolute path of a folder to enumerate the subfolders of + /// A list of absolute paths for the list of subfolders in the folder. May be empty. + /// If the folder doesn't exist IEnumerable GetFolders(string path); } } diff --git a/Sledge.Formats/FileSystem/VirtualSubdirectoryFileResolver.cs b/Sledge.Formats/FileSystem/VirtualSubdirectoryFileResolver.cs new file mode 100644 index 0000000..a0e8c36 --- /dev/null +++ b/Sledge.Formats/FileSystem/VirtualSubdirectoryFileResolver.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; + +namespace Sledge.Formats.FileSystem +{ + /// + /// A file resolver that can emulate a package to have all its contents in a subfolder instead of the root path. + /// + public class VirtualSubdirectoryFileResolver : IFileResolver + { + private readonly string _subdirectoryPath; + private readonly IFileResolver _fileResolver; + + public VirtualSubdirectoryFileResolver(string subdirectoryPath, IFileResolver fileResolver) + { + _subdirectoryPath = subdirectoryPath.Trim('/'); + _fileResolver = fileResolver; + } + + /// + /// Gets the non-virtual child or parent path given virtual path. + /// Only one will be non-null, if any. + /// If both are null, the virtual path cannot be resolved. + /// + /// The virtual path from the caller + /// A tuple of the unvirtualised parent or child paths + private (string parent, string child) MakePaths(string path) + { + path = path.TrimStart('/'); + string parent = null, child = null; + if (path == _subdirectoryPath) + { + child = ""; + } + else if (path.StartsWith(_subdirectoryPath + "/")) + { + child = path.Substring(_subdirectoryPath.Length + 1); + } + else if (path.Length == 0) + { + parent = _subdirectoryPath; + } + else if ((_subdirectoryPath + '/').StartsWith(path + '/')) + { + parent = _subdirectoryPath.Substring(0, path.Length); + } + return (parent, child); + } + + public bool FolderExists(string path) + { + var (parent, child) = MakePaths(path.TrimEnd('/')); + if (child != null) return _fileResolver.FolderExists(child); + else return parent != null; + } + + public bool FileExists(string path) + { + var (_, child) = MakePaths(path); + if (child == null) return false; + return _fileResolver.FileExists(child); + } + + public long FileSize(string path) + { + var (_, child) = MakePaths(path); + if (child == null) throw new FileNotFoundException(); + return _fileResolver.FileSize(child); + } + + public Stream OpenFile(string path) + { + var (_, child) = MakePaths(path); + if (child == null) throw new FileNotFoundException(); + return _fileResolver.OpenFile(child); + } + + public IEnumerable GetFiles(string path) + { + var (parent, child) = MakePaths(path.TrimEnd('/')); + if (child != null) + { + return _fileResolver.GetFiles(child).Select(x => _subdirectoryPath + "/" + x); + } + else if (parent != null) + { + // we have a parent path, but we don't have any files in there + return Array.Empty(); + } + throw new DirectoryNotFoundException(); + } + + public IEnumerable GetFolders(string path) + { + var (parent, child) = MakePaths(path); + if (child != null) + { + return _fileResolver.GetFolders(child).Select(x => _subdirectoryPath + "/" + x); + } + else if (parent != null) + { + // the only folder we know about is our virtual one + return new[] { parent }; + } + throw new DirectoryNotFoundException(); + } + } +} diff --git a/Sledge.Formats/FileSystem/ZipArchiveResolver.cs b/Sledge.Formats/FileSystem/ZipArchiveResolver.cs index 4a5bed8..05283bf 100644 --- a/Sledge.Formats/FileSystem/ZipArchiveResolver.cs +++ b/Sledge.Formats/FileSystem/ZipArchiveResolver.cs @@ -7,7 +7,7 @@ namespace Sledge.Formats.FileSystem { /// - /// A file resolver for a zip file. + /// A file resolver for a zip file. Zip archives are treated as case-sensitive on all platforms. /// public class ZipArchiveResolver : IFileResolver, IDisposable { @@ -28,7 +28,7 @@ public ZipArchiveResolver(string filePath) /// Create an instance for a . /// /// The ZipArchive instance - /// False to dispose the archive when this instance is dispose, true to leave it undisposed + /// False to dispose the archive when this instance is disposed, true to leave it undisposed public ZipArchiveResolver(ZipArchive zip, bool leaveOpen = false) { _zip = zip; @@ -40,49 +40,59 @@ private string NormalisePath(string path) return path.TrimStart('/'); } + public bool FolderExists(string path) + { + path = NormalisePath(path) + "/"; + if (path == "/" || _zip.GetEntry(path) != null) return true; // directory entry exists + return _zip.Entries.Any(x => x.FullName.StartsWith(path)); + } + public bool FileExists(string path) { path = NormalisePath(path); - return _zip.GetEntry(path) != null; + var e = _zip.GetEntry(path); + return e != null && !e.FullName.EndsWith("/"); + } + + public long FileSize(string path) + { + path = NormalisePath(path); + var e = _zip.GetEntry(path); + if (e == null || e.FullName.EndsWith("/")) throw new FileNotFoundException(); + return e.Length; } public Stream OpenFile(string path) { path = NormalisePath(path); var e = _zip.GetEntry(path) ?? throw new FileNotFoundException(); + if (e.FullName.EndsWith("/")) throw new FileNotFoundException(); // its a directory entry return e.Open(); } public IEnumerable GetFiles(string path) { - path = NormalisePath(path); - var basePath = path; - - if (basePath != string.Empty) - { - var e = _zip.GetEntry(basePath) ?? throw new FileNotFoundException(); - if (e.Length != 0) throw new FileNotFoundException(); - } + if (!FolderExists(path)) throw new DirectoryNotFoundException(); + path = NormalisePath(path) + "/"; + if (path == "/") path = ""; - return _zip.Entries.Where(x => x.Name != String.Empty && x.FullName.StartsWith(basePath) && !x.FullName.EndsWith("/")) - .Select(x => x.FullName.Substring(basePath.Length)) - .Where(x => !x.Contains('/')); + return _zip.Entries.Where(x => x.FullName != path && x.FullName.StartsWith(path)) + .Select(x => x.FullName.Substring(path.Length)) + .Where(x => !x.Contains('/')) + .Select(x => path + x); } public IEnumerable GetFolders(string path) { - path = NormalisePath(path); - var basePath = path; - - if (basePath != string.Empty) - { - var e = _zip.GetEntry(basePath) ?? throw new FileNotFoundException(); - if (e.Length != 0) throw new FileNotFoundException(); - } + if (!FolderExists(path)) throw new DirectoryNotFoundException(); + path = NormalisePath(path) + "/"; + if (path == "/") path = ""; - return _zip.Entries.Where(x => x.Name == String.Empty && x.FullName.StartsWith(basePath) && x.FullName.EndsWith("/")) - .Select(x => x.FullName.Substring(basePath.Length, x.FullName.Length - basePath.Length - 1)) - .Where(x => !x.Contains('/')); + return _zip.Entries.Where(x => x.FullName != path && x.FullName.StartsWith(path)) + .Select(x => x.FullName.Substring(path.Length)) + .Where(x => x.Contains('/')) + .Select(x => path + x.Split('/').First()) + .Distinct(); } public void Dispose() diff --git a/Sledge.Formats/Sledge.Formats.csproj b/Sledge.Formats/Sledge.Formats.csproj index b660e6c..047d2d4 100644 --- a/Sledge.Formats/Sledge.Formats.csproj +++ b/Sledge.Formats/Sledge.Formats.csproj @@ -13,10 +13,10 @@ https://github.com/LogicAndTrick/sledge-formats Git half-life quake valve liblist vdf - Fix bug preventing SerialisedObject children from having quoted names + Additions to the IFileSystem interface MIT - 1.2.6 + 1.3.0