From af5b1ba7b95c054ad66d43d9decf1416b1fc5a59 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 00:25:57 +0500 Subject: [PATCH 01/19] Expand GetDirectoryName test coverage --- tests/Ramstack.FileSystem.Abstractions.Tests/VirtualPathTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Ramstack.FileSystem.Abstractions.Tests/VirtualPathTests.cs b/tests/Ramstack.FileSystem.Abstractions.Tests/VirtualPathTests.cs index d9a751b..b02ea29 100644 --- a/tests/Ramstack.FileSystem.Abstractions.Tests/VirtualPathTests.cs +++ b/tests/Ramstack.FileSystem.Abstractions.Tests/VirtualPathTests.cs @@ -52,6 +52,7 @@ public void GetFileName(string path, string expected) [TestCase("", "")] [TestCase("/", "")] [TestCase("/dir", "/")] + [TestCase("dir", "")] [TestCase("/dir/file", "/dir")] [TestCase("/dir/dir/", "/dir/dir")] [TestCase("dir/dir", "dir")] From ac05158abf5a3fcf9e9037fefa4ef1c7aee8b594 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 00:41:41 +0500 Subject: [PATCH 02/19] Mark the NotFoundDirectory class as sealed --- src/Ramstack.FileSystem.Abstractions/Null/NotFoundDirectory.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Ramstack.FileSystem.Abstractions/Null/NotFoundDirectory.cs b/src/Ramstack.FileSystem.Abstractions/Null/NotFoundDirectory.cs index 4ffef93..57d70c3 100644 --- a/src/Ramstack.FileSystem.Abstractions/Null/NotFoundDirectory.cs +++ b/src/Ramstack.FileSystem.Abstractions/Null/NotFoundDirectory.cs @@ -5,7 +5,7 @@ namespace Ramstack.FileSystem.Null; /// /// Represents a non-existing directory. /// -public class NotFoundDirectory : VirtualDirectory +public sealed class NotFoundDirectory : VirtualDirectory { /// public override IVirtualFileSystem FileSystem { get; } From e64c587ad1482e9136addee96916d2e9a6179b4c Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 00:44:59 +0500 Subject: [PATCH 03/19] Throw UnauthorizedAccess when writing to non-existent file or directory --- .../Null/NotFoundDirectory.cs | 11 +++++++++-- .../Null/NotFoundFile.cs | 8 +++++--- .../Ramstack.FileSystem.Abstractions.csproj | 6 ++++++ 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/Ramstack.FileSystem.Abstractions/Null/NotFoundDirectory.cs b/src/Ramstack.FileSystem.Abstractions/Null/NotFoundDirectory.cs index 57d70c3..5d05bab 100644 --- a/src/Ramstack.FileSystem.Abstractions/Null/NotFoundDirectory.cs +++ b/src/Ramstack.FileSystem.Abstractions/Null/NotFoundDirectory.cs @@ -27,8 +27,11 @@ protected override ValueTask ExistsCoreAsync(CancellationToken cancellatio default; /// - protected override ValueTask CreateCoreAsync(CancellationToken cancellationToken) => - default; + protected override ValueTask CreateCoreAsync(CancellationToken cancellationToken) + { + Error_UnauthorizedAccess(FullName); + return default; + } /// protected override ValueTask DeleteCoreAsync(CancellationToken cancellationToken) => @@ -37,4 +40,8 @@ protected override ValueTask DeleteCoreAsync(CancellationToken cancellationToken /// protected override IAsyncEnumerable GetFileNodesCoreAsync(CancellationToken cancellationToken) => Array.Empty().ToAsyncEnumerable(); + + [DoesNotReturn] + private static void Error_UnauthorizedAccess(string path) => + throw new UnauthorizedAccessException($"Access to the path '{path}' is denied."); } diff --git a/src/Ramstack.FileSystem.Abstractions/Null/NotFoundFile.cs b/src/Ramstack.FileSystem.Abstractions/Null/NotFoundFile.cs index 8755d92..241a8f4 100644 --- a/src/Ramstack.FileSystem.Abstractions/Null/NotFoundFile.cs +++ b/src/Ramstack.FileSystem.Abstractions/Null/NotFoundFile.cs @@ -1,5 +1,3 @@ -using System.Diagnostics.CodeAnalysis; - namespace Ramstack.FileSystem.Null; /// @@ -43,7 +41,7 @@ protected override ValueTask OpenWriteCoreAsync(CancellationToken cancel /// protected override ValueTask WriteCoreAsync(Stream stream, bool overwrite, CancellationToken cancellationToken) { - Error_FileNotFound(FullName); + Error_UnauthorizedAccess(FullName); return default; } @@ -54,4 +52,8 @@ protected override ValueTask DeleteCoreAsync(CancellationToken cancellationToken [DoesNotReturn] private static void Error_FileNotFound(string path) => throw new FileNotFoundException($"Unable to find file '{path}'.", path); + + [DoesNotReturn] + private static void Error_UnauthorizedAccess(string path) => + throw new UnauthorizedAccessException($"Access to the path '{path}' is denied."); } diff --git a/src/Ramstack.FileSystem.Abstractions/Ramstack.FileSystem.Abstractions.csproj b/src/Ramstack.FileSystem.Abstractions/Ramstack.FileSystem.Abstractions.csproj index 2471127..742f17a 100644 --- a/src/Ramstack.FileSystem.Abstractions/Ramstack.FileSystem.Abstractions.csproj +++ b/src/Ramstack.FileSystem.Abstractions/Ramstack.FileSystem.Abstractions.csproj @@ -35,6 +35,12 @@ true + + + + + + all From 6f1588e6bac3af5814f948cda3798a2a20dc590a Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 00:46:08 +0500 Subject: [PATCH 04/19] Clean up redundant using directives --- src/Ramstack.FileSystem.Abstractions/VirtualDirectory.cs | 3 --- src/Ramstack.FileSystem.Abstractions/VirtualFile.cs | 2 -- src/Ramstack.FileSystem.Abstractions/VirtualNode.cs | 3 --- src/Ramstack.FileSystem.Abstractions/VirtualNodeProperties.cs | 2 -- 4 files changed, 10 deletions(-) diff --git a/src/Ramstack.FileSystem.Abstractions/VirtualDirectory.cs b/src/Ramstack.FileSystem.Abstractions/VirtualDirectory.cs index 21c50fc..3664cce 100644 --- a/src/Ramstack.FileSystem.Abstractions/VirtualDirectory.cs +++ b/src/Ramstack.FileSystem.Abstractions/VirtualDirectory.cs @@ -1,6 +1,3 @@ -using System.Diagnostics; -using System.Runtime.CompilerServices; - using Ramstack.Globbing.Traversal; namespace Ramstack.FileSystem; diff --git a/src/Ramstack.FileSystem.Abstractions/VirtualFile.cs b/src/Ramstack.FileSystem.Abstractions/VirtualFile.cs index d0f2065..76d74b9 100644 --- a/src/Ramstack.FileSystem.Abstractions/VirtualFile.cs +++ b/src/Ramstack.FileSystem.Abstractions/VirtualFile.cs @@ -1,5 +1,3 @@ -using System.Diagnostics; - namespace Ramstack.FileSystem; /// diff --git a/src/Ramstack.FileSystem.Abstractions/VirtualNode.cs b/src/Ramstack.FileSystem.Abstractions/VirtualNode.cs index 62e7610..e7db08c 100644 --- a/src/Ramstack.FileSystem.Abstractions/VirtualNode.cs +++ b/src/Ramstack.FileSystem.Abstractions/VirtualNode.cs @@ -1,6 +1,3 @@ -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; - namespace Ramstack.FileSystem; /// diff --git a/src/Ramstack.FileSystem.Abstractions/VirtualNodeProperties.cs b/src/Ramstack.FileSystem.Abstractions/VirtualNodeProperties.cs index 503f9c5..32bd808 100644 --- a/src/Ramstack.FileSystem.Abstractions/VirtualNodeProperties.cs +++ b/src/Ramstack.FileSystem.Abstractions/VirtualNodeProperties.cs @@ -1,5 +1,3 @@ -using System.Diagnostics; - namespace Ramstack.FileSystem; /// From b76844cce12f85031bc83d404d30ea2dc12f4f6b Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 00:52:46 +0500 Subject: [PATCH 05/19] Clean up and speed up VirtualFileSystemSpecificationTests --- .../PrefixedFileSystemTests.cs | 9 +- .../Utilities/TempFileStorage.cs | 189 ++--- .../VirtualFileSystemSpecificationTests.cs | 713 +++++++++++------- 3 files changed, 544 insertions(+), 367 deletions(-) diff --git a/tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemTests.cs b/tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemTests.cs index 1cc72a7..3f46efb 100644 --- a/tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemTests.cs +++ b/tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemTests.cs @@ -5,14 +5,15 @@ namespace Ramstack.FileSystem.Prefixed; [TestFixture] -public class PrefixedFileSystemTests() : VirtualFileSystemSpecificationTests(Prefix) +public class PrefixedFileSystemTests : VirtualFileSystemSpecificationTests { - private const string Prefix = "solution/app"; + private readonly TempFileStorage _storage = new TempFileStorage(); - private readonly TempFileStorage _storage = new TempFileStorage(Prefix); protected override IVirtualFileSystem GetFileSystem() => - new PrefixedFileSystem(Prefix, new PhysicalFileSystem(_storage.PrefixedPath)); + new PrefixedFileSystem("/project", + new PhysicalFileSystem( + Path.Join(_storage.Root, "project"))); /// protected override DirectoryInfo GetDirectoryInfo() => diff --git a/tests/Ramstack.FileSystem.Specification.Tests/Utilities/TempFileStorage.cs b/tests/Ramstack.FileSystem.Specification.Tests/Utilities/TempFileStorage.cs index 05c8185..f6403ad 100644 --- a/tests/Ramstack.FileSystem.Specification.Tests/Utilities/TempFileStorage.cs +++ b/tests/Ramstack.FileSystem.Specification.Tests/Utilities/TempFileStorage.cs @@ -3,94 +3,83 @@ namespace Ramstack.FileSystem.Specification.Tests.Utilities; public sealed class TempFileStorage : IDisposable { public string Root { get; } = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - public string PrefixedPath { get; } - public TempFileStorage(string prefix = "") + public TempFileStorage() { var path = Root; - - if (!string.IsNullOrEmpty(prefix)) - path = Path.GetFullPath(Path.Join(Root, prefix)); - - PrefixedPath = path; - var list = new[] - { - "project/docs/user_manual.pdf", - "project/docs/api_reference.md", - "project/docs/troubleshooting/common_issues.txt", - "project/docs/troubleshooting/faq.docx", - - "project/docs_generated/html/index.html", - "project/docs_generated/html/api.html", - "project/docs_generated/pdf/documentation.pdf", - - "project/src/App/App.csproj", - "project/src/App/Program.cs", - "project/src/App/Utils.cs", - "project/src/Modules/Module1/Module1.cs", - "project/src/Modules/Module1/Module1.csproj", - "project/src/Modules/Module1/Submodule/Submodule1.cs", - "project/src/Modules/Module1/Submodule/Submodule2.cs", - "project/src/Modules/Module1/Submodule/Submodule.csproj", - "project/src/Modules/Module2/Module2.cs", - "project/src/Modules/Module2/Module2.csproj", - "project/src/App.sln", - - "project/tests/TestMain.cs", - "project/tests/TestUtils.cs", - "project/tests/Tests.csproj", - "project/tests/Fixtures/SampleData.json", - "project/tests/Fixtures/MockResponses.xml", - - "project/data/raw/dataset1.csv", - "project/data/raw/dataset2.csv", - "project/data/raw/dataset-[data]-{1}.csv", - "project/data/processed/cleaned_data.csv", - "project/data/processed/aggregated_results.json", - - "project/data/temp/temp_file1.tmp", - "project/data/temp/temp_file2.tmp", - "project/data/temp/ac/b2/34/2d/7e/temp_file2.tmp", - "project/data/temp/hidden-folder/temp_1.tmp", - "project/data/temp/hidden-folder/temp_2.tmp", - "project/data/temp/hidden/temp_hidden3.dat", - "project/data/temp/hidden/temp_hidden4.dat", - - "project/scripts/setup.p1", - "project/scripts/deploy.ps1", - "project/scripts/build.bat", - "project/scripts/build.sh", - - "project/logs/app.log", - "project/logs/error.log", - "project/logs/archive/2019/01/app_2019-01.log", - "project/logs/archive/2019/02/app_2019-02.log", - "project/logs/archive/2019/03/app_2019-03.log", - - "project/config/production.json", - "project/config/development.json", - "project/config/test.json", - - "project/assets/images/logo.png", - "project/assets/images/icon.svg", - "project/assets/images/backgrounds/light.jpg", - "project/assets/images/backgrounds/dark.jpeg", - - "project/assets/fonts/opensans.ttf", - "project/assets/fonts/roboto.ttf", - "project/assets/styles/main.css", - "project/assets/styles/print.css", - - "project/packages/Ramstack.Globbing.2.1.0/lib/net60/Ramstack.Globbing.dll", - "project/packages/Ramstack.Globbing.2.1.0/Ramstack.Globbing.2.1.0.nupkg", - - "project/.gitignore", - "project/.editorconfig", - "project/README.md", - "project/global.json", - "project/nuget.config", - } + { + "project/docs/user_manual.pdf", + "project/docs/api_reference.md", + "project/docs/troubleshooting/common_issues.txt", + "project/docs/troubleshooting/faq.docx", + + "project/docs_generated/html/index.html", + "project/docs_generated/html/api.html", + "project/docs_generated/pdf/documentation.pdf", + + "project/src/App/App.csproj", + "project/src/App/Program.cs", + "project/src/App/Utils.cs", + "project/src/Modules/Module1/Module1.cs", + "project/src/Modules/Module1/Module1.csproj", + "project/src/Modules/Module1/Submodule/Submodule1.cs", + "project/src/Modules/Module1/Submodule/Submodule2.cs", + "project/src/Modules/Module1/Submodule/Submodule.csproj", + "project/src/Modules/Module2/Module2.cs", + "project/src/Modules/Module2/Module2.csproj", + "project/src/App.sln", + + "project/tests/TestMain.cs", + "project/tests/TestUtils.cs", + "project/tests/Tests.csproj", + "project/tests/Fixtures/SampleData.json", + "project/tests/Fixtures/MockResponses.xml", + + "project/data/raw/dataset1.csv", + "project/data/raw/dataset2.csv", + "project/data/raw/dataset-[data]-{1}.csv", + "project/data/processed/cleaned_data.csv", + "project/data/processed/aggregated_results.json", + + "project/data/temp/temp_file1.tmp", + "project/data/temp/temp_file2.tmp", + "project/data/temp/ac/b2/34/2d/7e/temp_file2.tmp", + + "project/scripts/setup.p1", + "project/scripts/deploy.ps1", + "project/scripts/build.bat", + "project/scripts/build.sh", + + "project/logs/app.log", + "project/logs/error.log", + "project/logs/archive/2019/01/app_2019-01.log", + "project/logs/archive/2019/02/app_2019-02.log", + "project/logs/archive/2019/03/app_2019-03.log", + + "project/config/production.json", + "project/config/development.json", + "project/config/test.json", + + "project/assets/images/logo.png", + "project/assets/images/icon.svg", + "project/assets/images/backgrounds/light.jpg", + "project/assets/images/backgrounds/dark.jpeg", + + "project/assets/fonts/Arial.ttf", + "project/assets/fonts/Roboto.ttf", + "project/assets/styles/main.css", + "project/assets/styles/print.css", + + "project/packages/Ramstack.Globbing.2.1.0/lib/net60/Ramstack.Globbing.dll", + "project/packages/Ramstack.Globbing.2.1.0/Ramstack.Globbing.2.1.0.zip", + + "project/.gitignore", + "project/.editorconfig", + "project/README.md", + "project/global.json", + "project/nuget.config", + } .Select(p => Path.Combine(path, p)) .ToArray(); @@ -105,14 +94,34 @@ public TempFileStorage(string prefix = "") foreach (var f in list) File.WriteAllText(f, $"Automatically generated on {DateTime.Now:s}\n\nId:{Guid.NewGuid()}"); + } - var hiddenFolder = directories.First(p => p.Contains("hidden-folder")); - File.SetAttributes(hiddenFolder, FileAttributes.Hidden); + public TempFileStorage(TempFileStorage storage) + { + CopyDirectory(storage.Root, Root); - foreach (var hiddenFile in list.Where(p => p.Contains("temp_hidden"))) - File.SetAttributes(hiddenFile, FileAttributes.Hidden); + static void CopyDirectory(string sourcePath, string destinationPath) + { + Directory.CreateDirectory(destinationPath); + + foreach (var sourceFileName in Directory.GetFiles(sourcePath)) + { + var name = Path.GetFileName(sourceFileName); + var destFileName = Path.Join(destinationPath, name); + File.Copy(sourceFileName, destFileName); + } + + foreach (var directoryPath in Directory.GetDirectories(sourcePath)) + { + var destination = Path.Join(destinationPath, Path.GetFileName(directoryPath)); + CopyDirectory(directoryPath, destination); + } + } } - public void Dispose() => - Directory.Delete(Root, true); + public void Dispose() + { + if (Directory.Exists(Root)) + Directory.Delete(Root, true); + } } diff --git a/tests/Ramstack.FileSystem.Specification.Tests/VirtualFileSystemSpecificationTests.cs b/tests/Ramstack.FileSystem.Specification.Tests/VirtualFileSystemSpecificationTests.cs index 7aa8dac..182ec64 100644 --- a/tests/Ramstack.FileSystem.Specification.Tests/VirtualFileSystemSpecificationTests.cs +++ b/tests/Ramstack.FileSystem.Specification.Tests/VirtualFileSystemSpecificationTests.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Security.Cryptography; using System.Text; namespace Ramstack.FileSystem.Specification.Tests; @@ -6,13 +6,12 @@ namespace Ramstack.FileSystem.Specification.Tests; /// /// Represents a base class for specification tests of virtual file systems. /// -/// The safe path for modifications. Defaults to "/". /// /// This class defines common functionality and setup for tests that validate the behavior /// of virtual file systems. Derived classes should implement the abstract methods to provide specific /// details about the virtual file system being tested. /// -public abstract class VirtualFileSystemSpecificationTests(string safePath = "/") +public abstract class VirtualFileSystemSpecificationTests { [Test] [Order(-1003)] @@ -84,19 +83,8 @@ public async Task Exists_ReturnsTrue_For_ExistingFile() { using var fs = GetFileSystem(); - Assert.That( - await fs.GetFilesAsync("/", "**").AnyAsync(), - Is.True); - - await foreach (var node in fs.GetFileNodesAsync("/", "**")) - { - VirtualNode byPath = node is VirtualFile - ? node.FileSystem.GetFile(node.FullName) - : node.FileSystem.GetDirectory(node.FullName); - - Assert.That(await node.ExistsAsync(), Is.True); - Assert.That(await byPath.ExistsAsync(), Is.True); - } + Assert.That(await fs.DirectoryExistsAsync("/project/assets"), Is.True); + Assert.That(await fs.FileExistsAsync("/project/README.md"), Is.True); } [Test] @@ -104,39 +92,26 @@ public async Task File_Exists_ReturnsFalse_For_NonExistingFile() { using var fs = GetFileSystem(); - var file = fs.GetFile($"{safePath}/{Guid.NewGuid()}"); - Assert.That(await file.ExistsAsync(), Is.False); + Assert.That(await fs.FileExistsAsync($"/{Guid.NewGuid()}"), Is.False); } [Test] public async Task File_OpenRead_ReturnsReadableStream() { using var fs = GetFileSystem(); + await using var stream = await fs.OpenReadAsync("/project/README.md"); - Assert.That( - await fs.GetFilesAsync("/", "**").AnyAsync(), - Is.True); - - await foreach (var file in fs.GetFilesAsync("/", "**")) - { - await using var stream = await file.OpenReadAsync(); - Assert.That(stream.CanRead, Is.True); - - stream.ReadByte(); - } + Assert.That(stream.CanRead, Is.True); + stream.ReadByte(); } [Test] - [SuppressMessage("ReSharper", "AccessToDisposedClosure")] - public void File_OpenRead_ThrowsException_For_NonExistingFile() + public async Task File_OpenRead_ThrowsException_For_NonExistingFile() { using var fs = GetFileSystem(); - var name = Guid.NewGuid().ToString(); - - Assert.That(() => fs.OpenReadAsync($"/{name}.txt"), Throws.Exception); - Assert.That(() => fs.OpenReadAsync($"{safePath}/{name}.txt"), Throws.Exception); - Assert.That(() => fs.OpenReadAsync($"{safePath}/{name}/{name}.txt"), Throws.Exception); + await Assert.ThatAsync(async () => await fs.OpenReadAsync("/6b01ba26.txt"), Throws.Exception); + await Assert.ThatAsync(async () => await fs.OpenReadAsync("/6b01ba26/6b01ba26.txt"), Throws.Exception); } [Test] @@ -147,25 +122,17 @@ public async Task File_OpenWrite_ReturnsWritableStream() if (fs.IsReadOnly) return; - Assert.That( - await fs.GetFilesAsync("/", "**").AnyAsync(), - Is.True); + var text = $"Id:{Guid.NewGuid()}"; - await foreach (var file in fs.GetFilesAsync("/", "**")) + await using (var stream = await fs.OpenWriteAsync("/project/README.md")) { - var content = $"Id:{Guid.NewGuid()}"; - - await using (var stream = await file.OpenWriteAsync()) - { - Assert.That(stream.CanWrite, Is.True); - - await stream.WriteAsync(Encoding.UTF8.GetBytes(content)); - } - - Assert.That( - await file.ReadAllTextAsync(), - Is.EqualTo(content)); + Assert.That(stream.CanWrite, Is.True); + await stream.WriteAsync(Encoding.UTF8.GetBytes(text)); } + + Assert.That( + await fs.ReadAllTextAsync("/project/README.md"), + Is.EqualTo(text)); } [Test] @@ -176,23 +143,23 @@ public async Task File_OpenWrite_NewFile() if (fs.IsReadOnly) return; - var content = $"Automatically generated on {DateTime.Now:s}\n\nNew Id:{Guid.NewGuid()}"; - var name = $"{safePath}/{Guid.NewGuid()}"; + var path = "/project/6b01ba2608be"; + var text = $"Automatically generated on {DateTime.Now:s}\n\nNew Id:{Guid.NewGuid()}"; - await using (var stream = await fs.OpenWriteAsync(name)) + await using (var stream = await fs.OpenWriteAsync(path)) { Assert.That(stream.CanWrite, Is.True); - await stream.WriteAsync(Encoding.UTF8.GetBytes(content)); + await stream.WriteAsync(Encoding.UTF8.GetBytes(text)); } Assert.That( - await fs.ReadAllTextAsync(name), - Is.EqualTo(content)); + await fs.ReadAllTextAsync(path), + Is.EqualTo(text)); - await fs.DeleteFileAsync(name); + await fs.DeleteFileAsync(path); Assert.That( - await fs.GetFile(name).ExistsAsync(), + await fs.FileExistsAsync(path), Is.False); } @@ -204,24 +171,18 @@ public async Task File_Write_OverwritesContent_When_OverwriteIsTrue() if (fs.IsReadOnly) return; - Assert.That( - await fs.GetFilesAsync("/", "**").AnyAsync(), - Is.True); + var file = fs.GetFile("/project/README.md"); + var text = $"Id:{Guid.NewGuid()}"; - await foreach (var file in fs.GetFilesAsync("/", "**")) - { - var content = $"Id:{Guid.NewGuid()}"; - - var ms = new MemoryStream(); - ms.Write(Encoding.UTF8.GetBytes(content)); - ms.Position = 0; + var ms = new MemoryStream(); + ms.Write(Encoding.UTF8.GetBytes(text)); + ms.Position = 0; - await file.WriteAsync(ms, overwrite: true); + await file.WriteAsync(ms, overwrite: true); - Assert.That( - await file.ReadAllTextAsync(), - Is.EqualTo(content)); - } + Assert.That( + await file.ReadAllTextAsync(), + Is.EqualTo(text)); } [Test] @@ -232,22 +193,20 @@ public async Task File_Write_ThrowsException_For_ExistingFile_When_OverwriteIsFa if (fs.IsReadOnly) return; - Assert.That( - await fs.GetFilesAsync("/", "**").AnyAsync(), - Is.True); + var file = fs.GetFile("/project/README.md"); + var text = await file.ReadAllTextAsync(); - await foreach (var file in fs.GetFilesAsync("/", "**")) - { - var current = await file.ReadAllTextAsync(); + var ms = new MemoryStream(); + ms.Write(Encoding.UTF8.GetBytes(Guid.NewGuid().ToString())); + ms.Position = 0; - Assert.That( - () => file.WriteAsync(new MemoryStream(), overwrite: false), - Throws.Exception); + Assert.That( + () => file.WriteAsync(ms, overwrite: false), + Throws.Exception); - Assert.That( - await file.ReadAllTextAsync(), - Is.EqualTo(current)); - } + Assert.That( + await file.ReadAllTextAsync(), + Is.EqualTo(text)); } [Test] @@ -258,11 +217,11 @@ public async Task File_Write_NewFile() if (fs.IsReadOnly) return; - var content = $"Automatically generated on {DateTime.Now:s}\n\nNew Id:{Guid.NewGuid()}"; - var path = $"{safePath}/{Guid.NewGuid()}"; + var path = "/project/0fce49e0.txt"; + var text = $"Automatically generated on {DateTime.Now:s}\n\nNew Id:{Guid.NewGuid()}"; var ms = new MemoryStream(); - ms.Write(Encoding.UTF8.GetBytes(content)); + ms.Write(Encoding.UTF8.GetBytes(text)); ms.Position = 0; var file = fs.GetFile(path); @@ -271,7 +230,7 @@ public async Task File_Write_NewFile() await file.WriteAsync(ms); Assert.That(await file.ExistsAsync(), Is.True); - Assert.That(await file.ReadAllTextAsync(), Is.EqualTo(content)); + Assert.That(await file.ReadAllTextAsync(), Is.EqualTo(text)); await file.DeleteAsync(); @@ -286,12 +245,12 @@ public async Task File_Delete_For_ExistingFile() if (fs.IsReadOnly) return; - var path = $"{safePath}/{Guid.NewGuid()}"; + var path = "/project/793cd29d96c4.txt"; var file = fs.GetFile(path); Assert.That(await file.ExistsAsync(), Is.False); - await file.WriteAsync(new MemoryStream()); + await file.WriteAsync(Stream.Null); Assert.That(await file.ExistsAsync(), Is.True); @@ -308,11 +267,8 @@ public async Task File_Delete_For_NonExistingFile() if (fs.IsReadOnly) return; - var name = Guid.NewGuid().ToString(); - - await fs.DeleteFileAsync($"/{name}.txt"); - await fs.DeleteFileAsync($"{safePath}/{name}.txt"); - await fs.DeleteFileAsync($"{safePath}/{name}/{name}.txt"); + await fs.DeleteFileAsync("/1861cb25.txt"); + await fs.DeleteFileAsync("/1861cb25/1861cb25.txt"); } [Test] @@ -323,33 +279,21 @@ public async Task File_Readonly_OpenWrite_ThrowsException() if (!fs.IsReadOnly) return; - Assert.That( - await fs.GetFilesAsync("/", "**").AnyAsync(), - Is.True); - - await foreach (var file in fs.GetFilesAsync("/", "**")) - { - Assert.That( - async () => { await using var stream = await file.OpenWriteAsync(); }, - Throws.Exception); - } + await Assert.ThatAsync( + async () => { await using var stream = await fs.OpenWriteAsync("/project/README.md"); }, + Throws.Exception); } [Test] - [SuppressMessage("ReSharper", "AccessToDisposedClosure")] - public void File_Readonly_OpenWrite_ThrowsException_For_NewFile() + public async Task File_Readonly_OpenWrite_ThrowsException_For_NewFile() { using var fs = GetFileSystem(); if (!fs.IsReadOnly) return; - Assert.That( - () => fs.OpenWriteAsync($"/{Guid.NewGuid()}"), - Throws.Exception); - - Assert.That( - () => fs.OpenWriteAsync($"{safePath}/{Guid.NewGuid()}"), + await Assert.ThatAsync( + async () => await fs.OpenWriteAsync("/project/6d368a9d0e97.md"), Throws.Exception); } @@ -361,29 +305,22 @@ public async Task File_Readonly_Write_ThrowsException() if (!fs.IsReadOnly) return; - Assert.That( - await fs.GetFilesAsync("/", "**").AnyAsync(), - Is.True); - - await foreach (var file in fs.GetFilesAsync("/", "**")) - { - Assert.That( - () => file.WriteAsync(new MemoryStream()), - Throws.Exception); - } + await Assert.ThatAsync( + async () => await fs.WriteAsync("/project/README.md", Stream.Null), + Throws.Exception); } [Test] - [SuppressMessage("ReSharper", "AccessToDisposedClosure")] - public void File_Readonly_Write_ThrowsException_For_NewFile() + public async Task File_Readonly_Write_ThrowsException_For_NewFile() { using var fs = GetFileSystem(); if (!fs.IsReadOnly) return; - Assert.That(() => fs.WriteAsync($"/{Guid.NewGuid()}", new MemoryStream()), Throws.Exception); - Assert.That(() => fs.WriteAsync($"{safePath}/{Guid.NewGuid()}", new MemoryStream()), Throws.Exception); + await Assert.ThatAsync( + async () => await fs.WriteAsync("/project/9286d04f.pdf", Stream.Null), + Throws.Exception); } [Test] @@ -394,25 +331,26 @@ public async Task File_Readonly_Delete_ThrowsException() if (!fs.IsReadOnly) return; + await Assert.ThatAsync( + async () => await fs.DeleteFileAsync("/project/README.md"), + Throws.Exception); + Assert.That( - await fs.GetFilesAsync("/", "**").AnyAsync(), + await fs.FileExistsAsync("/project/README.md"), Is.True); - - await foreach (var file in fs.GetFilesAsync("/", "**")) - Assert.That(() => file.DeleteAsync(), Throws.Exception); } [Test] - [SuppressMessage("ReSharper", "AccessToDisposedClosure")] - public void File_Readonly_Delete_ThrowsException_For_NonExistingFile() + public async Task File_Readonly_Delete_ThrowsException_For_NonExistingFile() { using var fs = GetFileSystem(); if (!fs.IsReadOnly) return; - Assert.That(() => fs.DeleteFileAsync($"/{Guid.NewGuid()}"), Throws.Exception); - Assert.That(() => fs.DeleteFileAsync($"{safePath}/{Guid.NewGuid()}"), Throws.Exception); + await Assert.ThatAsync( + async () => await fs.DeleteFileAsync("/project/c180408e8005.png"), + Throws.Exception); } [Test] @@ -422,20 +360,26 @@ public async Task File_CopyTo_Path() if (fs.IsReadOnly) return; - var file = await fs.GetFilesAsync("/", "**").FirstAsync(); - var destinationPath = file.FullName + ".copy"; + var sourcePath = "/project/README.md"; + var destinationPath = "/project/README - Copy.md"; - Assert.That(await fs.FileExistsAsync(destinationPath), Is.False); + Assert.That( + await fs.FileExistsAsync(destinationPath), + Is.False); - await file.CopyToAsync(destinationPath); - Assert.That(await fs.FileExistsAsync(destinationPath), Is.True); + await fs.CopyFileAsync(sourcePath, destinationPath); + Assert.That( + await fs.FileExistsAsync(destinationPath), + Is.True); Assert.That( - await fs.GetFile(destinationPath).ReadAllTextAsync(), - Is.EqualTo(await file.ReadAllTextAsync())); + await fs.ReadAllTextAsync(destinationPath), + Is.EqualTo(await fs.ReadAllTextAsync(sourcePath))); await fs.DeleteFileAsync(destinationPath); - Assert.That(await fs.FileExistsAsync(destinationPath), Is.False); + Assert.That( + await fs.FileExistsAsync(destinationPath), + Is.False); } [Test] @@ -445,16 +389,23 @@ public async Task File_CopyTo_Path_NoOverwrite_ThrowsException() if (fs.IsReadOnly) return; - var file = await fs.GetFilesAsync("/", "**").FirstAsync(); - var destinationPath = file.FullName + ".copy"; + var sourcePath = "/project/README.md"; + var destinationPath = "/project/README - Copy.md"; - await file.CopyToAsync(destinationPath); + await fs.CopyFileAsync(sourcePath, destinationPath); - Assert.That(await fs.FileExistsAsync(destinationPath), Is.True); - Assert.That(() => file.CopyToAsync(destinationPath), Throws.Exception); + Assert.That( + await fs.FileExistsAsync(destinationPath), + Is.True); + + await Assert.ThatAsync( + async () => await fs.CopyFileAsync(sourcePath, destinationPath), + Throws.Exception); await fs.DeleteFileAsync(destinationPath); - Assert.That(await fs.FileExistsAsync(destinationPath), Is.False); + Assert.That( + await fs.FileExistsAsync(destinationPath), + Is.False); } [Test] @@ -464,20 +415,27 @@ public async Task File_CopyTo_File_SameFileSystems() if (fs.IsReadOnly) return; - var file = await fs.GetFilesAsync("/", "**").FirstAsync(); - var destination = fs.GetFile(file.FullName + ".copy"); + var source = fs.GetFile("/project/README.md"); + var destination = fs.GetFile("/project/README - Copy.md"); + + Assert.That( + await destination.ExistsAsync(), + Is.False); - Assert.That(await destination.ExistsAsync(), Is.False); + await source.CopyToAsync(destination); - await file.CopyToAsync(destination); + Assert.That( + await destination.ExistsAsync(), + Is.True); - Assert.That(await destination.ExistsAsync(), Is.True); Assert.That( await destination.ReadAllTextAsync(), - Is.EqualTo(await file.ReadAllTextAsync())); + Is.EqualTo(await source.ReadAllTextAsync())); await destination.DeleteAsync(); - Assert.That(await destination.ExistsAsync(), Is.False); + Assert.That( + await destination.ExistsAsync(), + Is.False); } [Test] @@ -489,20 +447,27 @@ public async Task File_CopyTo_File_NotSameFileSystems() if (fs1.IsReadOnly) return; - var file = await fs1.GetFilesAsync("/", "**").FirstAsync(); - var destination = fs2.GetFile(file.FullName + ".copy"); + var source = fs1.GetFile("/project/README.md"); + var destination = fs2.GetFile("/project/README - Copy.md"); + + Assert.That( + await destination.ExistsAsync(), + Is.False); - Assert.That(await destination.ExistsAsync(), Is.False); + await source.CopyToAsync(destination); - await file.CopyToAsync(destination); + Assert.That( + await destination.ExistsAsync(), + Is.True); - Assert.That(await destination.ExistsAsync(), Is.True); Assert.That( await destination.ReadAllTextAsync(), - Is.EqualTo(await file.ReadAllTextAsync())); + Is.EqualTo(await source.ReadAllTextAsync())); await destination.DeleteAsync(); - Assert.That(await destination.ExistsAsync(), Is.False); + Assert.That( + await destination.ExistsAsync(), + Is.False); } [Test] @@ -512,51 +477,61 @@ public async Task File_CopyTo_File_NoOverwrite_ThrowsException() if (fs.IsReadOnly) return; - var file = await fs.GetFilesAsync("/", "**").FirstAsync(); - var destination = fs.GetFile(file.FullName + ".copy"); + var source = fs.GetFile("/project/README.md"); + var destination = fs.GetFile("/project/README - Copy.md"); + + Assert.That( + await destination.ExistsAsync(), + Is.False); - Assert.That(await destination.ExistsAsync(), Is.False); - await file.CopyToAsync(destination); + await source.CopyToAsync(destination); - Assert.That(await destination.ExistsAsync(), Is.True); - Assert.That(() => file.CopyToAsync(destination), Throws.Exception); + Assert.That( + await destination.ExistsAsync(), + Is.True); + + await Assert.ThatAsync( + async () => await source.CopyToAsync(destination), + Throws.Exception); await destination.DeleteAsync(); - Assert.That(await destination.ExistsAsync(), Is.False); + Assert.That( + await destination.ExistsAsync(), + Is.False); } [Test] - [SuppressMessage("ReSharper", "AccessToDisposedClosure")] public async Task File_CopyTo_ThrowsException_When_CopyingToItself() { - using var fs1 = GetFileSystem(); - using var fs2 = GetFileSystem(); + using var fs = GetFileSystem(); - if (fs1.IsReadOnly) + if (fs.IsReadOnly) return; - var file = await fs1.GetFilesAsync("/", "**").FirstAsync(); - Assert.That(() => file.CopyToAsync(file.FullName), Throws.Exception); - Assert.That(() => file.CopyToAsync(file), Throws.Exception); - Assert.That(() => file.CopyToAsync(fs1.GetFile(file.FullName)), Throws.Exception); - Assert.That(() => file.CopyToAsync(fs2.GetFile(file.FullName)), Throws.Exception); + var file = fs.GetFile("/project/README.md"); + await Assert.ThatAsync(async () => await file.CopyToAsync(file.FullName), Throws.Exception); + await Assert.ThatAsync(async () => await file.CopyToAsync(file), Throws.Exception); + await Assert.ThatAsync(async () => await file.CopyToAsync(fs.GetFile(file.FullName)), Throws.Exception); } [Test] - [SuppressMessage("ReSharper", "AccessToDisposedClosure")] - public void File_CopyTo_ThrowException_For_NonExistingFile() + public async Task File_CopyTo_ThrowException_For_NonExistingFile() { using var fs = GetFileSystem(); if (fs.IsReadOnly) return; - Assert.That(() => fs.CopyFileAsync($"/{Guid.NewGuid()}", "/test.txt"), Throws.Exception); - Assert.That(() => fs.CopyFileAsync($"{safePath}/{Guid.NewGuid()}", $"/{safePath}/test.txt"), Throws.Exception); + await Assert.ThatAsync( + async () => await fs.CopyFileAsync("/project/800569b11aaa0438.txt", "/project/test-b11aaa.txt"), + Throws.Exception); + + Assert.That( + await fs.FileExistsAsync("/project/test-b11aaa.txt"), + Is.False); } [Test] - [SuppressMessage("ReSharper", "AccessToDisposedClosure")] public async Task File_Readonly_CopyTo_ThrowException_For_NonExistingFile() { using var fs = GetFileSystem(); @@ -564,8 +539,13 @@ public async Task File_Readonly_CopyTo_ThrowException_For_NonExistingFile() if (!fs.IsReadOnly) return; - var file = await fs.GetFilesAsync("/", "**").FirstAsync(); - Assert.That(() => file.CopyToAsync(file.FullName + ".copy"), Throws.Exception); + await Assert.ThatAsync( + async () => await fs.CopyFileAsync("/project/README.md", "/project/README - Copy.md"), + Throws.Exception); + + Assert.That( + await fs.FileExistsAsync("/project/README - Copy.md"), + Is.False); } [Test] @@ -573,16 +553,20 @@ public async Task File_ReadAllBytes() { using var fs = GetFileSystem(); - await foreach (var file in fs.GetFilesAsync("/", "**")) - { - var stream = await file.OpenReadAsync(); - using var reader = new BinaryReader(stream); + var file = fs.GetFile("/project/README.md"); + using var reader = new BinaryReader(await file.OpenReadAsync()); - var bytes = await fs.ReadAllBytesAsync(file.FullName); - var expected = reader.ReadBytes(4096); + var bytes = await fs.ReadAllBytesAsync(file.FullName); + var expected = reader.ReadBytes(4096); + var properties = await file.GetPropertiesAsync(); - Assert.That(bytes.SequenceEqual(expected), Is.True); - } + Assert.That( + bytes.SequenceEqual(expected), + Is.True); + + Assert.That( + bytes.Length, + Is.EqualTo(properties.Length)); } [Test] @@ -590,15 +574,13 @@ public async Task File_ReadAllText() { using var fs = GetFileSystem(); - await foreach (var file in fs.GetFilesAsync("/", "**")) - { - var stream = await file.OpenReadAsync(); - using var reader = new StreamReader(stream); + var file = fs.GetFile("/project/README.md"); + var stream = await file.OpenReadAsync(); + using var reader = new StreamReader(stream); - Assert.That( - await fs.ReadAllTextAsync(file.FullName), - Is.EqualTo(await reader.ReadToEndAsync())); - } + Assert.That( + await fs.ReadAllTextAsync(file.FullName), + Is.EqualTo(await reader.ReadToEndAsync())); } [Test] @@ -606,19 +588,17 @@ public async Task File_ReadAllLines() { using var fs = GetFileSystem(); - await foreach (var file in fs.GetFilesAsync("/", "**")) - { - var stream = await file.OpenReadAsync(); - using var reader = new StreamReader(stream); + var file = fs.GetFile("/project/README.md"); + var stream = await file.OpenReadAsync(); + using var reader = new StreamReader(stream); - var lines = new List(); - while (await reader.ReadLineAsync() is {} line) - lines.Add(line); + var lines = new List(); + while (await reader.ReadLineAsync() is {} line) + lines.Add(line); - Assert.That( - await fs.ReadAllLinesAsync(file.FullName), - Is.EquivalentTo(lines)); - } + Assert.That( + await fs.ReadAllLinesAsync(file.FullName), + Is.EquivalentTo(lines)); } [Test] @@ -628,14 +608,15 @@ public async Task File_WriteAllBytes() if (fs.IsReadOnly) return; - var expected = new byte[1024 * 1024]; - Random.Shared.NextBytes(expected); - var path = $"{safePath}/{Guid.NewGuid()}"; - await fs.WriteAllBytesAsync(path, expected); + var path = "/project/b02a67d8.bin"; + var data = RandomNumberGenerator.GetBytes(10_000); - var data = await fs.ReadAllBytesAsync(path); - Assert.That(data.SequenceEqual(expected), Is.True); + await fs.WriteAllBytesAsync(path, data); + + Assert.That( + await fs.ReadAllBytesAsync(path), + Is.EquivalentTo(data)); await fs.DeleteFileAsync(path); } @@ -648,17 +629,17 @@ public async Task File_WriteAllText() return; var list = new List(); - for (var i = 0; i < 10240; i++) + for (var i = 0; i < 1000; i++) list.Add($"Hello, 世界! Unicode test: café, weiß, Привет, ёжик! こんにちは! {Guid.NewGuid()}"); - var contents = string.Join(Environment.NewLine, list); + var data = string.Join(Environment.NewLine, list); + var path = "/project/5cc34e90a7c9.txt"; - var path = $"{safePath}/{Guid.NewGuid()}"; - await fs.WriteAllTextAsync(path, contents); + await fs.WriteAllTextAsync(path, data); Assert.That( await fs.ReadAllTextAsync(path), - Is.EqualTo(contents)); + Is.EqualTo(data)); await fs.DeleteFileAsync(path); } @@ -671,10 +652,10 @@ public async Task File_WriteAllLines() return; var list = new List(); - for (var i = 0; i < 10240; i++) + for (var i = 0; i < 1000; i++) list.Add($"Hello, 世界! Unicode test: café, weiß, Привет, ёжик! こんにちは! {Guid.NewGuid()}"); - var path = $"{safePath}/{Guid.NewGuid()}"; + var path = "/project/76bb51ee6cb7.txt"; await fs.WriteAllLinesAsync(path, list); Assert.That( @@ -684,16 +665,219 @@ await fs.ReadAllLinesAsync(path), await fs.DeleteFileAsync(path); } + [Test] + public async Task Directory_GetFiles() + { + using var fs = GetFileSystem(); + + Assert.That( + await fs.GetDirectory("/project/assets").GetFilesAsync().AnyAsync(), + Is.False); + + Assert.That( + await fs + .GetDirectory("/project/assets/images") + .GetFilesAsync() + .Select(f => f.FullName) + .OrderBy(p => p) + .ToArrayAsync(), + Is.EquivalentTo( + [ + "/project/assets/images/icon.svg", + "/project/assets/images/logo.png" + ])); + } + + [Test] + public async Task Directory_GetDirectories() + { + using var fs = GetFileSystem(); + + Assert.That( + await fs.GetDirectory("/project/assets/fonts").GetDirectoriesAsync().AnyAsync(), + Is.False); + + Assert.That( + await fs + .GetDirectory("/project/assets") + .GetDirectoriesAsync() + .Select(f => f.FullName) + .OrderBy(p => p) + .ToArrayAsync(), + Is.EquivalentTo( + [ + "/project/assets/fonts", + "/project/assets/images", + "/project/assets/styles" + ]) + ); + } + + [Test] + public async Task Directory_GetFileNodes() + { + using var fs = GetFileSystem(); + + Assert.That( + await fs.GetDirectory("/project/assets/scripts").GetFileNodesAsync().AnyAsync(), + Is.False); + + Assert.That( + await fs + .GetDirectory("/project/assets/images") + .GetFileNodesAsync() + .Select(f => f.FullName) + .OrderBy(p => p) + .ToArrayAsync(), + Is.EquivalentTo( + [ + "/project/assets/images/backgrounds", + "/project/assets/images/icon.svg", + "/project/assets/images/logo.png" + ]) + ); + } + + [Test] + public async Task Directory_Glob_GetFiles() + { + using var fs = GetFileSystem(); + + Assert.That( + await fs.GetDirectory("/project/assets").GetFilesAsync("*/*.otf").AnyAsync(), + Is.False); + + Assert.That( + await fs + .GetDirectory("/project") + .GetFilesAsync( + pattern: "[a][s]set[s]/{images,styles}/{main.css,logo.png}") + .Select(f => f.FullName) + .OrderBy(p => p) + .ToArrayAsync(), + Is.EquivalentTo( + [ + "/project/assets/images/logo.png", + "/project/assets/styles/main.css" + ]) + ); + + Assert.That( + await fs + .GetDirectory("/project") + .GetFilesAsync( + patterns: ["[a]sset{,s}/*/*.{svg,png}", "assets/{images,styles}/*.css"], + excludes: ["assets/imag*/*icon*", "**/style*/print.css"]) + .Select(f => f.FullName) + .OrderBy(p => p) + .ToArrayAsync(), + Is.EquivalentTo( + [ + "/project/assets/images/logo.png", + "/project/assets/styles/main.css" + ]) + ); + } + + [Test] + public async Task Directory_Glob_GetDirectories() + { + using var fs = GetFileSystem(); + + Assert.That( + await fs.GetDirectory("/project/assets").GetDirectoriesAsync("{styles,fonts}/*").AnyAsync(), + Is.False); + + Assert.That( + await fs + .GetDirectory("/project") + .GetDirectoriesAsync("[a]sset{,s}/*/*") + .Select(f => f.FullName) + .OrderBy(p => p) + .ToArrayAsync(), + Is.EquivalentTo(["/project/assets/images/backgrounds"])); + + Assert.That( + await fs + .GetDirectory("/project") + .GetDirectoriesAsync( + patterns: ["[a]sset{,s}/*/*", "*/**/*s"], + excludes: ["src/**", "tests/**/Fixtures"]) + .Select(f => f.FullName) + .OrderBy(p => p) + .ToArrayAsync(), + Is.EquivalentTo( + [ + "/project/assets/fonts", + "/project/assets/images", + "/project/assets/images/backgrounds", + "/project/assets/styles" + ]) + ); + } + + [Test] + public async Task Directory_Glob_GetFileNodes() + { + using var fs = GetFileSystem(); + + Assert.That( + await fs.GetDirectory("/project/assets").GetFileNodesAsync("{styles,fonts}/*/*").AnyAsync(), + Is.False); + + Assert.That( + await fs + .GetDirectory("/project") + .GetFileNodesAsync("[a]sset{,s}/*/*") + .Select(f => f.FullName) + .OrderBy(p => p) + .ToArrayAsync(), + Is.EquivalentTo( + [ + "/project/assets/fonts/Arial.ttf", + "/project/assets/fonts/Roboto.ttf", + "/project/assets/images/backgrounds", + "/project/assets/images/icon.svg", + "/project/assets/images/logo.png", + "/project/assets/styles/main.css", + "/project/assets/styles/print.css" + ])); + + Assert.That( + await fs + .GetDirectory("/project") + .GetFileNodesAsync( + patterns: ["[a]sset{,s}/*/*", "*/**/*s"], + excludes: ["src/**", "tests/**"]) + .Select(f => f.FullName) + .OrderBy(p => p) + .ToArrayAsync(), + Is.EquivalentTo( + [ + "/project/assets/fonts", + "/project/assets/fonts/Arial.ttf", + "/project/assets/fonts/Roboto.ttf", + "/project/assets/images", + "/project/assets/images/backgrounds", + "/project/assets/images/icon.svg", + "/project/assets/images/logo.png", + "/project/assets/styles", + "/project/assets/styles/main.css", + "/project/assets/styles/print.css" + ]) + ); + } + [Test] public async Task Directory_Enumerate_ReturnsEmpty_For_NonExistingDirectory() { using var fs = GetFileSystem(); - var directory = fs.GetDirectory($"/{Guid.NewGuid()}"); + var directory = fs.GetDirectory("/project/b02a67d85cc34e90"); - Assert.That(await directory.GetFileNodesAsync().CountAsync(), Is.Zero); - Assert.That(await directory.GetFilesAsync().CountAsync(), Is.Zero); - Assert.That(await directory.GetDirectoriesAsync().CountAsync(), Is.Zero); + Assert.That(await directory.GetFileNodesAsync().AnyAsync(), Is.False); + Assert.That(await directory.GetFilesAsync().AnyAsync(), Is.False); + Assert.That(await directory.GetDirectoriesAsync().AnyAsync(), Is.False); } [Test] @@ -704,12 +888,7 @@ public async Task Directory_Create_For_ExistingDirectory() if (fs.IsReadOnly) return; - Assert.That( - await fs.GetDirectoriesAsync("/", "**").AnyAsync(), - Is.True); - - await foreach (var directory in fs.GetDirectoriesAsync("/", "**")) - await directory.CreateAsync(); + await fs.CreateDirectoryAsync("/project/assets"); } [Test] @@ -720,11 +899,13 @@ public async Task Directory_Create_For_NonExistingDirectory() if (fs.IsReadOnly) return; - var name = Guid.NewGuid().ToString(); - var directory = fs.GetDirectory($"{safePath}/{name}/{name}"); + await fs.CreateDirectoryAsync("/project/dcc2d926/8ddb/4a79/8246/a60ed6146c53"); + + Assert.That( + await fs.DirectoryExistsAsync("/project/dcc2d926/8ddb/4a79/8246/a60ed6146c53"), + Is.True); - await directory.CreateAsync(); - Assert.That(await directory.ExistsAsync(), Is.True); + await fs.DeleteDirectoryAsync("/project/dcc2d926"); } [Test] @@ -735,25 +916,20 @@ public async Task Directory_Delete() if (fs.IsReadOnly) return; - var name = Guid.NewGuid().ToString(); + var directory = fs.GetDirectory("/project/1111b18e"); - var directory = fs.GetDirectory($"{safePath}/{name}-dir"); - - for (var i = 0; i < 10; i++) - { - await fs.WriteAsync($"{directory.FullName}/{Guid.NewGuid()}.txt", new MemoryStream()); - await fs.WriteAsync($"{directory.FullName}/{Guid.NewGuid()}/{Guid.NewGuid()}.txt", new MemoryStream()); - } + await fs.WriteAsync("/project/1111b18e/4bb69411.txt", Stream.Null); + await fs.WriteAsync("/project/1111b18e/f1e5f79eb6be/909c4bb69411.txt", Stream.Null); Assert.That( await directory.GetFilesAsync("**").CountAsync(), - Is.EqualTo(20)); + Is.EqualTo(2)); await directory.DeleteAsync(); Assert.That( - await directory.GetFilesAsync("**").CountAsync(), - Is.Zero); + await directory.GetFilesAsync("**").AnyAsync(), + Is.False); } [Test] @@ -764,35 +940,26 @@ public async Task Directory_Delete_For_NonExistingDirectory() if (fs.IsReadOnly) return; - var name = Guid.NewGuid().ToString(); + var directory = fs.GetDirectory("/project/73d0e99e"); - var dir1 = fs.GetDirectory($"/{name}-1"); - var dir2 = fs.GetDirectory($"/{name}-2/{name}-3"); - var dir3 = fs.GetDirectory($"{safePath}/{name}-4"); - var dir4 = fs.GetDirectory($"{safePath}/{name}-5/{name}-6"); - - Assert.That(await dir1.GetFileNodesAsync().CountAsync(), Is.Zero); - Assert.That(await dir2.GetFileNodesAsync().CountAsync(), Is.Zero); - Assert.That(await dir3.GetFileNodesAsync().CountAsync(), Is.Zero); - Assert.That(await dir4.GetFileNodesAsync().CountAsync(), Is.Zero); + Assert.That( + await directory.GetFileNodesAsync().AnyAsync(), + Is.False); - await dir1.DeleteAsync(); - await dir2.DeleteAsync(); - await dir3.DeleteAsync(); - await dir4.DeleteAsync(); + await directory.DeleteAsync(); } [Test] - [SuppressMessage("ReSharper", "AccessToDisposedClosure")] - public void Directory_Readonly_Delete_ThrowsException_For_NonExistingDirectory() + public async Task Directory_Readonly_Delete_ThrowsException_For_NonExistingDirectory() { using var fs = GetFileSystem(); if (!fs.IsReadOnly) return; - Assert.That(() => fs.DeleteDirectoryAsync($"/{Guid.NewGuid()}"), Throws.Exception); - Assert.That(() => fs.DeleteDirectoryAsync($"{safePath}/{Guid.NewGuid()}"), Throws.Exception); + await Assert.ThatAsync( + async () => await fs.DeleteDirectoryAsync("/project/22ef456"), + Throws.Exception); } /// From 51ee462f58e2432c05e0eb804851b1e1c82a5ea0 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 00:55:21 +0500 Subject: [PATCH 06/19] Update tests for SubFileSystem --- .../Ramstack.FileSystem.Sub.Tests.csproj | 1 + tests/Ramstack.FileSystem.Sub.Tests/SubFileSystemTests.cs | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/Ramstack.FileSystem.Sub.Tests/Ramstack.FileSystem.Sub.Tests.csproj b/tests/Ramstack.FileSystem.Sub.Tests/Ramstack.FileSystem.Sub.Tests.csproj index 32f405a..377b86d 100644 --- a/tests/Ramstack.FileSystem.Sub.Tests/Ramstack.FileSystem.Sub.Tests.csproj +++ b/tests/Ramstack.FileSystem.Sub.Tests/Ramstack.FileSystem.Sub.Tests.csproj @@ -21,6 +21,7 @@ + diff --git a/tests/Ramstack.FileSystem.Sub.Tests/SubFileSystemTests.cs b/tests/Ramstack.FileSystem.Sub.Tests/SubFileSystemTests.cs index 25d34e5..a535b39 100644 --- a/tests/Ramstack.FileSystem.Sub.Tests/SubFileSystemTests.cs +++ b/tests/Ramstack.FileSystem.Sub.Tests/SubFileSystemTests.cs @@ -1,4 +1,5 @@ using Ramstack.FileSystem.Physical; +using Ramstack.FileSystem.Prefixed; using Ramstack.FileSystem.Specification.Tests; using Ramstack.FileSystem.Specification.Tests.Utilities; @@ -14,8 +15,10 @@ public void Cleanup() => _storage.Dispose(); protected override IVirtualFileSystem GetFileSystem() => - new SubFileSystem("project/docs", new PhysicalFileSystem(_storage.Root)); + new SubFileSystem("/bin/app", + new PrefixedFileSystem("/bin/app", + new PhysicalFileSystem(_storage.Root))); protected override DirectoryInfo GetDirectoryInfo() => - new(Path.Join(_storage.Root, "project", "docs")); + new DirectoryInfo(_storage.Root); } From c322ecc00874969fae31416d4a50a3eb0fd5f657 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 00:58:03 +0500 Subject: [PATCH 07/19] Clean up GCS test setup: remove commented-out emulator host configuration code --- .../ReadonlyGoogleFileSystemTests.cs | 11 ----------- .../WritableGoogleFileSystemTests.cs | 9 --------- 2 files changed, 20 deletions(-) diff --git a/tests/Ramstack.FileSystem.Google.Tests/ReadonlyGoogleFileSystemTests.cs b/tests/Ramstack.FileSystem.Google.Tests/ReadonlyGoogleFileSystemTests.cs index 36053fe..fc6c802 100644 --- a/tests/Ramstack.FileSystem.Google.Tests/ReadonlyGoogleFileSystemTests.cs +++ b/tests/Ramstack.FileSystem.Google.Tests/ReadonlyGoogleFileSystemTests.cs @@ -16,9 +16,6 @@ public class ReadonlyGoogleFileSystemTests : VirtualFileSystemSpecificationTests [OneTimeSetUp] public async Task Setup() { - // if (Environment.GetEnvironmentVariable("STORAGE_EMULATOR_HOST") is null) - // Environment.SetEnvironmentVariable("STORAGE_EMULATOR_HOST", "http://localhost:4443/storage/v1/"); - using var fs = CreateFileSystem(isReadOnly: false); await fs.CreateBucketAsync("ramstack-project"); @@ -46,14 +43,6 @@ protected override DirectoryInfo GetDirectoryInfo() => private static GoogleFileSystem CreateFileSystem(bool isReadOnly) { - // var builder = new StorageClientBuilder - // { - // BaseUri = Environment.GetEnvironmentVariable("STORAGE_EMULATOR_HOST"), - // UnauthenticatedAccess = true - // }; - // - // var client = builder.Build(); - var client = StorageClient.Create( GoogleCredential.FromFile("credentials.json")); diff --git a/tests/Ramstack.FileSystem.Google.Tests/WritableGoogleFileSystemTests.cs b/tests/Ramstack.FileSystem.Google.Tests/WritableGoogleFileSystemTests.cs index f3cd860..11f08cf 100644 --- a/tests/Ramstack.FileSystem.Google.Tests/WritableGoogleFileSystemTests.cs +++ b/tests/Ramstack.FileSystem.Google.Tests/WritableGoogleFileSystemTests.cs @@ -20,9 +20,6 @@ public class WritableGoogleFileSystemTests : VirtualFileSystemSpecificationTests [OneTimeSetUp] public async Task Setup() { - // if (Environment.GetEnvironmentVariable("STORAGE_EMULATOR_HOST") is null) - // Environment.SetEnvironmentVariable("STORAGE_EMULATOR_HOST", "http://localhost:4443/storage/v1/"); - using var fs = GetFileSystem(); await fs.CreateBucketAsync("ramstack-project"); @@ -204,12 +201,6 @@ private GoogleFileSystem CreateFileSystem(string bucket) { _buckets.Add(bucket); - // var client = new StorageClientBuilder - // { - // BaseUri = Environment.GetEnvironmentVariable("STORAGE_EMULATOR_HOST"), - // UnauthenticatedAccess = true - // }.Build(); - var client = StorageClient.Create( GoogleCredential.FromFile("credentials.json")); From c17f118ddd963b67d4888adc29d69887209e0881 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 00:59:25 +0500 Subject: [PATCH 08/19] Optimize directory emptiness check --- .../WritableAmazonFileSystemTests.cs | 4 ++-- .../WritableAzureFileSystemTests.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs b/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs index a6cbeca..f548dd7 100644 --- a/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs +++ b/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs @@ -212,8 +212,8 @@ await fs.GetFilesAsync("/temp").CountAsync(), await fs.DeleteDirectoryAsync("/temp"); Assert.That( - await fs.GetFilesAsync("/temp").CountAsync(), - Is.EqualTo(0)); + await fs.GetFilesAsync("/temp").AnyAsync(), + Is.False); } protected override AmazonS3FileSystem GetFileSystem() => diff --git a/tests/Ramstack.FileSystem.Azure.Tests/WritableAzureFileSystemTests.cs b/tests/Ramstack.FileSystem.Azure.Tests/WritableAzureFileSystemTests.cs index 3b20174..8a81080 100644 --- a/tests/Ramstack.FileSystem.Azure.Tests/WritableAzureFileSystemTests.cs +++ b/tests/Ramstack.FileSystem.Azure.Tests/WritableAzureFileSystemTests.cs @@ -119,8 +119,8 @@ await fs.GetFilesAsync("/temp").CountAsync(), await fs.DeleteDirectoryAsync("/temp"); Assert.That( - await fs.GetFilesAsync("/temp").CountAsync(), - Is.EqualTo(0)); + await fs.GetFilesAsync("/temp").AnyAsync(), + Is.False); } protected override AzureFileSystem GetFileSystem() => From fa16f9886cd16dcfc4311d30e8f88a9f8d609bb6 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 01:00:49 +0500 Subject: [PATCH 09/19] Remove debug output for expected exceptions --- .../WritableAmazonFileSystemTests.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs b/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs index f548dd7..911dd27 100644 --- a/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs +++ b/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs @@ -72,10 +72,9 @@ public async Task File_OpenWrite_InternalBufferWriteError_DoesNotCreateFile() { await stream.WriteAsync(new ReadOnlyMemory(new byte[1024])); } - catch (Exception exception) + catch { - Console.WriteLine("Exception expected!"); - Console.WriteLine(exception); + // Ignore } } From c6d4385152e242d026b6924a1b85b93bdac7053e Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 01:14:44 +0500 Subject: [PATCH 10/19] GlobbingFileSystem: Match directory paths against glob patterns correctly Previously, only full file paths were matched against glob patterns, allowing unintended directories to be enumerated. Now folder paths are properly validated against the patterns (e.g. `/assets/{images,styles}/**/*.{png,gif,css}` correctly restricts access to only allowed paths). --- .../GlobbingDirectory.cs | 22 +- .../GlobbingFileSystem.cs | 40 +-- .../Internal/PathHelper.cs | 340 ++++++++++++++++++ 3 files changed, 353 insertions(+), 49 deletions(-) create mode 100644 src/Ramstack.FileSystem.Globbing/Internal/PathHelper.cs diff --git a/src/Ramstack.FileSystem.Globbing/GlobbingDirectory.cs b/src/Ramstack.FileSystem.Globbing/GlobbingDirectory.cs index 2022fa4..8598b85 100644 --- a/src/Ramstack.FileSystem.Globbing/GlobbingDirectory.cs +++ b/src/Ramstack.FileSystem.Globbing/GlobbingDirectory.cs @@ -60,17 +60,15 @@ protected override async IAsyncEnumerable GetFileNodesCoreAsync([En { await foreach (var node in _directory.GetFileNodesAsync(cancellationToken).ConfigureAwait(false)) { - if (!_fs.IsExcluded(node.FullName)) + if (node is VirtualFile file) { - if (node is VirtualFile file) - { - if (_fs.IsIncluded(node.FullName)) - yield return new GlobbingFile(_fs, file, true); - } - else - { - yield return new GlobbingDirectory(_fs, (VirtualDirectory)node, true); - } + if (_fs.IsFileIncluded(file.FullName)) + yield return new GlobbingFile(_fs, file, included: true); + } + else + { + if (_fs.IsDirectoryIncluded(node.FullName)) + yield return new GlobbingDirectory(_fs, (VirtualDirectory)node, included: true); } } } @@ -84,7 +82,7 @@ protected override async IAsyncEnumerable GetFilesCoreAsync([Enumer await foreach (var file in _directory.GetFilesAsync(cancellationToken).ConfigureAwait(false)) { if (_fs.IsFileIncluded(file.FullName)) - yield return new GlobbingFile(_fs, file, true); + yield return new GlobbingFile(_fs, file, included: true); } } } @@ -97,7 +95,7 @@ protected override async IAsyncEnumerable GetDirectoriesCoreAs await foreach (var directory in _directory.GetDirectoriesAsync(cancellationToken).ConfigureAwait(false)) { if (_fs.IsDirectoryIncluded(directory.FullName)) - yield return new GlobbingDirectory(_fs, directory, true); + yield return new GlobbingDirectory(_fs, directory, included: true); } } } diff --git a/src/Ramstack.FileSystem.Globbing/GlobbingFileSystem.cs b/src/Ramstack.FileSystem.Globbing/GlobbingFileSystem.cs index 7fb42bf..18a793b 100644 --- a/src/Ramstack.FileSystem.Globbing/GlobbingFileSystem.cs +++ b/src/Ramstack.FileSystem.Globbing/GlobbingFileSystem.cs @@ -1,4 +1,4 @@ -using Ramstack.Globbing; +using Ramstack.FileSystem.Globbing.Internal; namespace Ramstack.FileSystem.Globbing; @@ -92,7 +92,7 @@ public void Dispose() => /// otherwise, . /// internal bool IsFileIncluded(string path) => - !IsExcluded(path) && IsIncluded(path); + !PathHelper.IsMatch(path, _excludes) && PathHelper.IsMatch(path, _patterns); /// /// Determines if a directory is included based on the specified exclusions. @@ -103,39 +103,5 @@ internal bool IsFileIncluded(string path) => /// otherwise, . /// internal bool IsDirectoryIncluded(string path) => - !IsExcluded(path); - - /// - /// Checks if a path matches any of the include patterns. - /// - /// The path to check. - /// - /// if the path matches an include pattern; - /// otherwise, . - /// - internal bool IsIncluded(string path) - { - foreach (var pattern in _patterns) - if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) - return true; - - return false; - } - - /// - /// Checks if a path matches any of the exclude patterns. - /// - /// The path to check. - /// - /// if the path matches an exclude pattern; - /// otherwise, . - /// - internal bool IsExcluded(string path) - { - foreach (var pattern in _excludes) - if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) - return true; - - return false; - } + path == "/" || !PathHelper.IsMatch(path, _excludes) && PathHelper.IsPartialMatch(path, _patterns); } diff --git a/src/Ramstack.FileSystem.Globbing/Internal/PathHelper.cs b/src/Ramstack.FileSystem.Globbing/Internal/PathHelper.cs new file mode 100644 index 0000000..b2fd46c --- /dev/null +++ b/src/Ramstack.FileSystem.Globbing/Internal/PathHelper.cs @@ -0,0 +1,340 @@ +using System.Diagnostics; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Runtime.Intrinsics; +using System.Runtime.Intrinsics.Arm; +using System.Runtime.Intrinsics.X86; + +using Ramstack.Globbing; + +namespace Ramstack.FileSystem.Globbing.Internal; + +/// +/// Provides helper methods for path manipulations. +/// +internal static class PathHelper +{ + /// + /// Determines whether the specified path matches any of the specified patterns. + /// + /// The path to match for a match. + /// An array of patterns to match against the path. + /// + /// if the path matches any of the patterns; + /// otherwise, . + /// + public static bool IsMatch(scoped ReadOnlySpan path, string[] patterns) + { + foreach (var pattern in patterns) + if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) + return true; + + return false; + } + + /// + /// Determines whether the specified path partially matches any of the specified patterns. + /// + /// The path to be partially matched. + /// An array of patterns to match against the path. + /// + /// if the path partially matches any of the patterns; + /// otherwise, . + /// + public static bool IsPartialMatch(scoped ReadOnlySpan path, string[] patterns) + { + Debug.Assert(path is not "/"); + + var count = CountPathSegments(path); + + foreach (var pattern in patterns) + if (Matcher.IsMatch(path, GetPartialPattern(pattern, count), MatchFlags.Unix)) + return true; + + return false; + } + + /// + /// Counts the number of segments in the specified path. + /// + /// The path to count segments for. + /// + /// The number of segments in the path. + /// + public static int CountPathSegments(scoped ReadOnlySpan path) + { + var count = 0; + var iterator = new PathSegmentIterator(); + ref var s = ref Unsafe.AsRef(in MemoryMarshal.GetReference(path)); + var length = path.Length; + + while (true) + { + var r = iterator.GetNext(ref s, length); + + if (r.start != r.final) + count++; + + if (r.final == length) + break; + } + + if (count == 0) + count = 1; + + return count; + } + + /// + /// Returns a partial pattern from the specified pattern string based on the specified depth. + /// + /// The pattern string to extract from. + /// The depth level to extract the partial pattern up to. + /// + /// A representing the partial pattern. + /// + public static ReadOnlySpan GetPartialPattern(string pattern, int depth) + { + Debug.Assert(depth >= 1); + + var iterator = new PathSegmentIterator(); + ref var s = ref Unsafe.AsRef(in pattern.GetPinnableReference()); + var length = pattern.Length; + + while (true) + { + var r = iterator.GetNext(ref s, length); + if (r.start != r.final) + depth--; + + if (depth < 1 + || r.final == length + || IsGlobStar(ref s, r.start, r.final)) + return MemoryMarshal.CreateReadOnlySpan(ref s, r.final); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static bool IsGlobStar(ref char s, int index, int final) => + index + 2 == final && Unsafe.ReadUnaligned( + ref Unsafe.As( + ref Unsafe.Add(ref s, (nint)(uint)index))) == ('*' << 16 | '*'); + } + + #region Vector helper methods + + /// + /// Loads a 256-bit vector from the specified source. + /// + /// The source from which the vector will be loaded. + /// The offset from the from which the vector will be loaded. + /// + /// The loaded 256-bit vector. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector256 LoadVector256(ref char source, nint offset) => + Unsafe.ReadUnaligned>( + ref Unsafe.As(ref Unsafe.Add(ref source, offset))); + + /// + /// Loads a 128-bit vector from the specified source. + /// + /// The source from which the vector will be loaded. + /// The offset from from which the vector will be loaded. + /// + /// The loaded 128-bit vector. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Vector128 LoadVector128(ref char source, nint offset) => + Unsafe.ReadUnaligned>( + ref Unsafe.As( + ref Unsafe.Add(ref source, offset))); + + #endregion + + #region Inner type: PathSegmentIterator + + /// + /// Provides functionality to iterate over segments of a path. + /// + private struct PathSegmentIterator + { + private int _last; + private nint _position; + private uint _mask; + + /// + /// Initializes a new instance of the structure. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public PathSegmentIterator() => + _last = -1; + + /// + /// Retrieves the next segment of the path. + /// + /// A reference to the starting character of the path. + /// The total number of characters in the input path starting from . + /// + /// A tuple containing the start and end indices of the next path segment. + /// start indicates the beginning of the segment, and final satisfies + /// the condition that final - start equals the length of the segment. + /// The end of the iteration is indicated by final being equal to the length of the path. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public (int start, int final) GetNext(ref char source, int length) + { + var start = _last + 1; + + while ((int)_position < length) + { + if ((Avx2.IsSupported || Sse2.IsSupported || AdvSimd.Arm64.IsSupported) && _mask != 0) + { + var offset = BitOperations.TrailingZeroCount(_mask); + if (AdvSimd.IsSupported) + { + // + // On ARM, ExtractMostSignificantBits returns a mask where each bit + // represents one vector element (1 bit per ushort), so offset + // directly corresponds to the element index + // + _last = (int)(_position + (nint)(uint)offset); + + // + // Clear the bits for the current separator + // + _mask &= ~(1u << offset); + } + else + { + // + // On x86, MoveMask (and ExtractMostSignificantBits on byte-based vectors) + // returns a mask where each bit represents one byte (2 bits per ushort), + // so we need to divide offset by 2 to get the actual element index + // + _last = (int)(_position + (nint)((uint)offset >> 1)); + + // + // Clear the bits for the current separator + // + _mask &= ~(0b_11u << offset); + } + + // + // Advance position to the next chunk when no separators remain in the mask + // + if (_mask == 0) + { + // + // https://github.com/dotnet/runtime/issues/117416 + // + // Precompute the stride size instead of calculating it inline + // to avoid stack spilling. For some unknown reason, the JIT + // fails to optimize properly when this is written inline, like so: + // _position += Avx2.IsSupported + // ? Vector256.Count + // : Vector128.Count; + // + + var stride = Avx2.IsSupported + ? Vector256.Count + : Vector128.Count; + + _position += stride; + } + + return (start, _last); + } + + if (Avx2.IsSupported && (int)_position + Vector256.Count <= length) + { + var chunk = LoadVector256(ref source, _position); + var slash = Vector256.Create('/'); + var comparison = Avx2.CompareEqual(chunk, slash); + + // + // Store the comparison bitmask and reuse it across iterations + // as long as it contains non-zero bits. + // This avoids reloading SIMD registers and repeating comparisons + // on the same chunk of data. + // + _mask = (uint)Avx2.MoveMask(comparison.AsByte()); + + // + // Advance position to the next chunk when no separators found + // + if (_mask == 0) + _position += Vector256.Count; + } + else if (Sse2.IsSupported && !Avx2.IsSupported && (int)_position + Vector128.Count <= length) + { + var chunk = LoadVector128(ref source, _position); + var slash = Vector128.Create('/'); + var comparison = Sse2.CompareEqual(chunk, slash); + + // + // Store the comparison bitmask and reuse it across iterations + // as long as it contains non-zero bits. + // This avoids reloading SIMD registers and repeating comparisons + // on the same chunk of data. + // + _mask = (uint)Sse2.MoveMask(comparison.AsByte()); + + // + // Advance position to the next chunk when no separators found + // + if (_mask == 0) + _position += Vector128.Count; + } + else if (AdvSimd.Arm64.IsSupported && (int)_position + Vector128.Count <= length) + { + var chunk = LoadVector128(ref source, _position); + var slash = Vector128.Create('/'); + var comparison = AdvSimd.CompareEqual(chunk, slash); + + // + // Store the comparison bitmask and reuse it across iterations + // as long as it contains non-zero bits. + // This avoids reloading SIMD registers and repeating comparisons + // on the same chunk of data. + // + _mask = ExtractMostSignificantBits(comparison); + + // + // Advance position to the next chunk when no separators found + // + if (_mask == 0) + _position += Vector128.Count; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + static uint ExtractMostSignificantBits(Vector128 v) + { + var sum = AdvSimd.Arm64.AddAcross( + AdvSimd.ShiftLogical( + AdvSimd.And(v, Vector128.Create((ushort)0x8000)), + Vector128.Create(-15, -14, -13, -12, -11, -10, -9, -8))); + return sum.ToScalar(); + } + } + else + { + for (; (int)_position < length; _position++) + { + var ch = Unsafe.Add(ref source, _position); + if (ch == '/') + { + _last = (int)_position; + _position++; + + return (start, _last); + } + } + } + } + + return (start, length); + } + } + + #endregion +} From d15201ec6e372cf9add7912c6854be91eb5a9a20 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 01:17:18 +0500 Subject: [PATCH 11/19] Add virtual glob pattern matching methods for file system operations Introduce core virtual methods (GetFileNodesCoreAsync, GetFilesCoreAsync, GetDirectoriesCoreAsync) to allow custom glob pattern matching implementations in derived classes. This enables cloud providers to optimize file searches. --- .../VirtualDirectory.cs | 106 ++++++++++++------ 1 file changed, 74 insertions(+), 32 deletions(-) diff --git a/src/Ramstack.FileSystem.Abstractions/VirtualDirectory.cs b/src/Ramstack.FileSystem.Abstractions/VirtualDirectory.cs index 3664cce..c665afb 100644 --- a/src/Ramstack.FileSystem.Abstractions/VirtualDirectory.cs +++ b/src/Ramstack.FileSystem.Abstractions/VirtualDirectory.cs @@ -118,16 +118,7 @@ public IAsyncEnumerable GetDirectoriesAsync(CancellationToken public IAsyncEnumerable GetFileNodesAsync(string[] patterns, string[]? excludes, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(patterns); - - return new FileTreeAsyncEnumerable(this, cancellationToken) - { - Patterns = patterns, - Excludes = excludes ?? [], - FileNameSelector = node => node.FullName, - ShouldRecursePredicate = node => node is VirtualDirectory, - ChildrenSelector = (node, token) => ((VirtualDirectory)node).GetFileNodesCoreAsync(token), - ResultSelector = node => node - }; + return GetFileNodesCoreAsync(patterns, excludes, cancellationToken); } /// @@ -143,17 +134,7 @@ public IAsyncEnumerable GetFileNodesAsync(string[] patterns, string public IAsyncEnumerable GetFilesAsync(string[] patterns, string[]? excludes, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(patterns); - - return new FileTreeAsyncEnumerable(this, cancellationToken) - { - Patterns = patterns, - Excludes = excludes ?? [], - FileNameSelector = node => node.FullName, - ShouldIncludePredicate = node => node is VirtualFile, - ShouldRecursePredicate = node => node is VirtualDirectory, - ChildrenSelector = (node, token) => ((VirtualDirectory)node).GetFileNodesCoreAsync(token), - ResultSelector = node => (VirtualFile)node - }; + return GetFilesCoreAsync(patterns, excludes, cancellationToken); } /// @@ -169,17 +150,7 @@ public IAsyncEnumerable GetFilesAsync(string[] patterns, string[]? public IAsyncEnumerable GetDirectoriesAsync(string[] patterns, string[]? excludes, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(patterns); - - return new FileTreeAsyncEnumerable(this, cancellationToken) - { - Patterns = patterns, - Excludes = excludes ?? [], - FileNameSelector = node => node.FullName, - ShouldIncludePredicate = node => node is VirtualDirectory, - ShouldRecursePredicate = node => node is VirtualDirectory, - ChildrenSelector = (node, token) => ((VirtualDirectory)node).GetFileNodesCoreAsync(token), - ResultSelector = node => (VirtualDirectory)node - }; + return GetDirectoriesCoreAsync(patterns, excludes, cancellationToken); } /// @@ -240,4 +211,75 @@ protected virtual async IAsyncEnumerable GetDirectoriesCoreAsy yield return directory; } } + + /// + /// Core implementation for asynchronously returning an async-enumerable collection of file nodes (both directories and files) + /// within the current directory that match any of the specified glob patterns. + /// + /// An array of glob patterns to match against the names of file nodes. + /// An optional array of glob patterns to exclude file nodes. + /// An optional cancellation token to cancel the operation. + /// + /// An async-enumerable collection of instances. + /// + protected virtual IAsyncEnumerable GetFileNodesCoreAsync(string[] patterns, string[]? excludes, CancellationToken cancellationToken) + { + return new FileTreeAsyncEnumerable(this, cancellationToken) + { + Patterns = patterns, + Excludes = excludes ?? [], + FileNameSelector = node => node.Name, + ShouldRecursePredicate = node => node is VirtualDirectory, + ChildrenSelector = (node, token) => ((VirtualDirectory)node).GetFileNodesCoreAsync(token), + ResultSelector = node => node + }; + } + + /// + /// Core implementation for asynchronously returning an async-enumerable collection of files within the current directory + /// that match any of the specified glob patterns. + /// + /// An array of glob patterns to match against the names of files. + /// An optional array of glob patterns to exclude files. + /// An optional cancellation token to cancel the operation. + /// + /// An async-enumerable collection of instances. + /// + protected virtual IAsyncEnumerable GetFilesCoreAsync(string[] patterns, string[]? excludes, CancellationToken cancellationToken) + { + return new FileTreeAsyncEnumerable(this, cancellationToken) + { + Patterns = patterns, + Excludes = excludes ?? [], + FileNameSelector = node => node.Name, + ShouldIncludePredicate = node => node is VirtualFile, + ShouldRecursePredicate = node => node is VirtualDirectory, + ChildrenSelector = (node, token) => ((VirtualDirectory)node).GetFileNodesCoreAsync(token), + ResultSelector = node => (VirtualFile)node + }; + } + + /// + /// Core implementation for asynchronously returning an async-enumerable collection of directories within the current directory + /// that match any of the specified glob patterns. + /// + /// An array of glob patterns to match against the names of directories. + /// An optional array of glob patterns to exclude directories. + /// An optional cancellation token to cancel the operation. + /// + /// An async-enumerable collection of instances. + /// + protected virtual IAsyncEnumerable GetDirectoriesCoreAsync(string[] patterns, string[]? excludes, CancellationToken cancellationToken) + { + return new FileTreeAsyncEnumerable(this, cancellationToken) + { + Patterns = patterns, + Excludes = excludes ?? [], + FileNameSelector = node => node.Name, + ShouldIncludePredicate = node => node is VirtualDirectory, + ShouldRecursePredicate = node => node is VirtualDirectory, + ChildrenSelector = (node, token) => ((VirtualDirectory)node).GetFileNodesCoreAsync(token), + ResultSelector = node => (VirtualDirectory)node + }; + } } From 139b67d619d504a6c5778ffbbc5380c1579f14c1 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 01:19:13 +0500 Subject: [PATCH 12/19] Optimize S3 glob pattern matching with batch listing and local filtering --- src/Ramstack.FileSystem.Amazon/S3Directory.cs | 239 +++++++++++++++++- src/Ramstack.FileSystem.Amazon/S3File.cs | 9 + 2 files changed, 237 insertions(+), 11 deletions(-) diff --git a/src/Ramstack.FileSystem.Amazon/S3Directory.cs b/src/Ramstack.FileSystem.Amazon/S3Directory.cs index 2e1641a..7d4aae9 100644 --- a/src/Ramstack.FileSystem.Amazon/S3Directory.cs +++ b/src/Ramstack.FileSystem.Amazon/S3Directory.cs @@ -2,6 +2,8 @@ using Amazon.S3.Model; +using Ramstack.Globbing; + namespace Ramstack.FileSystem.Amazon; /// @@ -11,7 +13,6 @@ namespace Ramstack.FileSystem.Amazon; internal sealed class S3Directory : VirtualDirectory { private readonly AmazonS3FileSystem _fs; - private readonly string _prefix; /// public override IVirtualFileSystem FileSystem => _fs; @@ -21,11 +22,8 @@ internal sealed class S3Directory : VirtualDirectory /// /// The file system associated with this directory. /// The path to the directory within the specified Amazon S3 bucket. - public S3Directory(AmazonS3FileSystem fileSystem, string path) : base(path) - { + public S3Directory(AmazonS3FileSystem fileSystem, string path) : base(path) => _fs = fileSystem; - _prefix = path == "/" ? "" : $"{path[1..]}/"; - } /// protected override ValueTask GetPropertiesCoreAsync(CancellationToken cancellationToken) => @@ -41,7 +39,7 @@ protected override async ValueTask DeleteCoreAsync(CancellationToken cancellatio var lr = new ListObjectsV2Request { BucketName = _fs.BucketName, - Prefix = _prefix + Prefix = GetPrefix(FullName) }; var dr = new DeleteObjectsRequest @@ -80,7 +78,7 @@ protected override async IAsyncEnumerable GetFileNodesCoreAsync([En var request = new ListObjectsV2Request { BucketName = _fs.BucketName, - Prefix = _prefix, + Prefix = GetPrefix(FullName), Delimiter = "/" }; @@ -94,7 +92,7 @@ protected override async IAsyncEnumerable GetFileNodesCoreAsync([En yield return new S3Directory(_fs, VirtualPath.Normalize(prefix)); foreach (var obj in response.S3Objects) - yield return new S3File(_fs, VirtualPath.Normalize(obj.Key)); + yield return CreateVirtualFile(obj); request.ContinuationToken = response.NextContinuationToken; } @@ -107,7 +105,7 @@ protected override async IAsyncEnumerable GetFilesCoreAsync([Enumer var request = new ListObjectsV2Request { BucketName = _fs.BucketName, - Prefix = _prefix, + Prefix = GetPrefix(FullName), Delimiter = "/" }; @@ -118,7 +116,7 @@ protected override async IAsyncEnumerable GetFilesCoreAsync([Enumer .ConfigureAwait(false); foreach (var obj in response.S3Objects) - yield return new S3File(_fs, VirtualPath.Normalize(obj.Key)); + yield return CreateVirtualFile(obj); request.ContinuationToken = response.NextContinuationToken; } @@ -131,7 +129,7 @@ protected override async IAsyncEnumerable GetDirectoriesCoreAs var request = new ListObjectsV2Request { BucketName = _fs.BucketName, - Prefix = _prefix, + Prefix = GetPrefix(FullName), Delimiter = "/" }; @@ -148,4 +146,223 @@ protected override async IAsyncEnumerable GetDirectoriesCoreAs } while (request.ContinuationToken is not null && !cancellationToken.IsCancellationRequested); } + + /// + protected override async IAsyncEnumerable GetFileNodesCoreAsync(string[] patterns, string[]? excludes, [EnumeratorCancellation] CancellationToken cancellationToken) + { + // + // Cloud storage optimization strategy: + // 1. List all objects in one batch using prefix filtering (matches our virtual directory). + // 2. Perform pattern matching and exclusion filters locally. + // + // Benefits: + // - Single API call instead of per-directory requests + // - Reduced network latency, especially for deep directory structures + // - More efficient than recursive directory scanning + // + + var request = new ListObjectsV2Request + { + BucketName = _fs.BucketName, + Prefix = GetPrefix(FullName) + }; + + var directories = new HashSet + { + FullName + }; + + do + { + var response = await _fs.AmazonClient + .ListObjectsV2Async(request, cancellationToken) + .ConfigureAwait(false); + + foreach (var obj in response.S3Objects) + { + var directoryPath = VirtualPath.GetDirectoryName( + VirtualPath.Join("/", obj.Key)); + + while (directoryPath.Length != 0 && directories.Add(directoryPath)) + { + // + // Directories are yielded in reverse order (deepest first). + // + // Note: We could use a Stack to control the order, + // but since order isn't guaranteed anyway and to avoid + // unnecessary memory allocation, we process them directly. + // + if (IsMatched(directoryPath.AsSpan(FullName.Length), patterns, excludes)) + yield return new S3Directory(_fs, VirtualPath.Normalize(directoryPath)); + + directoryPath = VirtualPath.GetDirectoryName(directoryPath); + } + + if (IsMatched(obj.Key.AsSpan(request.Prefix.Length), patterns, excludes)) + yield return CreateVirtualFile(obj); + } + + request.ContinuationToken = response.NextContinuationToken; + } + while (request.ContinuationToken is not null && !cancellationToken.IsCancellationRequested); + } + + /// + protected override async IAsyncEnumerable GetFilesCoreAsync(string[] patterns, string[]? excludes, [EnumeratorCancellation] CancellationToken cancellationToken) + { + // + // Cloud storage optimization strategy: + // 1. List all objects in one batch using prefix filtering (matches our virtual directory). + // 2. Perform pattern matching and exclusion filters locally. + // + // Benefits: + // - Single API call instead of per-directory requests + // - Reduced network latency, especially for deep directory structures + // - More efficient than recursive directory scanning + // + + var request = new ListObjectsV2Request + { + BucketName = _fs.BucketName, + Prefix = GetPrefix(FullName) + }; + + do + { + var response = await _fs.AmazonClient + .ListObjectsV2Async(request, cancellationToken) + .ConfigureAwait(false); + + foreach (var obj in response.S3Objects) + if (IsMatched(obj.Key.AsSpan(request.Prefix.Length), patterns, excludes)) + yield return CreateVirtualFile(obj); + + request.ContinuationToken = response.NextContinuationToken; + } + while (request.ContinuationToken is not null && !cancellationToken.IsCancellationRequested); + } + + /// + protected override async IAsyncEnumerable GetDirectoriesCoreAsync(string[] patterns, string[]? excludes, [EnumeratorCancellation] CancellationToken cancellationToken) + { + // + // Cloud storage optimization strategy: + // 1. List all objects in one batch using prefix filtering (matches our virtual directory). + // 2. Perform pattern matching and exclusion filters locally. + // + // Benefits: + // - Single API call instead of per-directory requests + // - Reduced network latency, especially for deep directory structures + // - More efficient than recursive directory scanning + // + + var request = new ListObjectsV2Request + { + BucketName = _fs.BucketName, + Prefix = GetPrefix(FullName) + }; + + var directories = new HashSet + { + FullName + }; + + do + { + var response = await _fs.AmazonClient + .ListObjectsV2Async(request, cancellationToken) + .ConfigureAwait(false); + + foreach (var obj in response.S3Objects) + { + var directoryPath = VirtualPath.GetDirectoryName( + VirtualPath.Join("/", obj.Key)); + + while (directoryPath.Length != 0 && directories.Add(directoryPath)) + { + // + // Directories are yielded in reverse order (deepest first). + // + // Note: We could use a Stack to control the order, + // but since order isn't guaranteed anyway and to avoid + // unnecessary memory allocation, we process them directly. + // + if (IsMatched(directoryPath.AsSpan(FullName.Length), patterns, excludes)) + yield return new S3Directory(_fs, VirtualPath.Normalize(directoryPath)); + + directoryPath = VirtualPath.GetDirectoryName(directoryPath); + } + } + + request.ContinuationToken = response.NextContinuationToken; + } + while (request.ContinuationToken is not null && !cancellationToken.IsCancellationRequested); + } + + /// + /// Creates a instance based on the specified object. + /// + /// The representing the file. + /// + /// A new instance representing the file. + /// + private S3File CreateVirtualFile(S3Object obj) + { + var properties = VirtualNodeProperties + .CreateFileProperties( + creationTime: default, + lastAccessTime: default, + lastWriteTime: obj.LastModified, + length: obj.Size); + + var path = VirtualPath.Normalize(obj.Key); + return new S3File(_fs, path, properties); + } + + /// + /// Determines whether the specified path matches any of the inclusion patterns and none of the exclusion patterns. + /// + /// The path to match. + /// The inclusion patterns to match against the path. + /// An optional array of exclusion patterns. If the path matches any of these, + /// the method returns . + /// + /// if the path matches at least one inclusion pattern and no exclusion patterns; + /// otherwise, . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsMatched(scoped ReadOnlySpan path, string[] patterns, string[]? excludes) + { + if (excludes is not null) + foreach (var pattern in excludes) + if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) + return false; + + foreach (var pattern in patterns) + if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) + return true; + + return false; + } + + /// + /// Returns a cloud storage compatible prefix for the specified directory path. + /// + /// The directory path. + /// + /// A string formatted for cloud storage prefix filtering: + /// + /// Empty string for the root directory ("/") + /// Path without leading slash but with trailing slash for subdirectories + /// + /// + /// + /// + /// GetPrefix("/") // returns "" + /// GetPrefix("/folder") // returns "folder/" + /// GetPrefix("/sub/folder") // returns "sub/folder/" + /// + /// + private static string GetPrefix(string path) => + path == "/" ? "" : $"{path[1..]}/"; } diff --git a/src/Ramstack.FileSystem.Amazon/S3File.cs b/src/Ramstack.FileSystem.Amazon/S3File.cs index 63792eb..cdca028 100644 --- a/src/Ramstack.FileSystem.Amazon/S3File.cs +++ b/src/Ramstack.FileSystem.Amazon/S3File.cs @@ -24,6 +24,15 @@ internal sealed class S3File : VirtualFile public S3File(AmazonS3FileSystem fileSystem, string path) : base(path) => (_fs, _key) = (fileSystem, path[1..]); + /// + /// Initializes a new instance of the class with the specified path and properties. + /// + /// The file system associated with this file. + /// The full path of the file. + /// The properties of the file. + public S3File(AmazonS3FileSystem fileSystem, string path, VirtualNodeProperties? properties) : base(path, properties) => + (_fs, _key) = (fileSystem, path[1..]); + /// protected override async ValueTask GetPropertiesCoreAsync(CancellationToken cancellationToken) { From 43c7cc06e33bd738f9c5cfc37ef16df6465f002c Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 01:20:21 +0500 Subject: [PATCH 13/19] Optimize Azure Blobs glob pattern matching with batch listing and local filtering --- .../AzureDirectory.cs | 175 +++++++++++++++++- 1 file changed, 168 insertions(+), 7 deletions(-) diff --git a/src/Ramstack.FileSystem.Azure/AzureDirectory.cs b/src/Ramstack.FileSystem.Azure/AzureDirectory.cs index 334382e..db9d180 100644 --- a/src/Ramstack.FileSystem.Azure/AzureDirectory.cs +++ b/src/Ramstack.FileSystem.Azure/AzureDirectory.cs @@ -5,6 +5,8 @@ using Azure.Storage.Blobs.Models; using Azure.Storage.Blobs.Specialized; +using Ramstack.Globbing; + namespace Ramstack.FileSystem.Azure; /// @@ -141,14 +143,137 @@ protected override async IAsyncEnumerable GetDirectoriesCoreAs yield return new AzureDirectory(_fs, VirtualPath.Normalize(item.Prefix)); } + /// + protected override async IAsyncEnumerable GetFileNodesCoreAsync(string[] patterns, string[]? excludes, [EnumeratorCancellation] CancellationToken cancellationToken) + { + // + // Cloud storage optimization strategy: + // 1. List all blobs in one batch using prefix filtering (matches our virtual directory). + // 2. Perform pattern matching and exclusion filters locally. + // + // Benefits: + // - Single API call instead of per-directory requests + // - Reduced network latency, especially for deep directory structures + // - More efficient than recursive directory scanning + // + + var prefix = GetPrefix(FullName); + var directories = new HashSet { FullName }; + + await foreach (var page in _fs.AzureClient + .GetBlobsAsync(prefix: prefix, cancellationToken: cancellationToken) + .AsPages() + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + foreach (var blob in page.Values) + { + var directoryPath = VirtualPath.GetDirectoryName( + VirtualPath.Join("/", blob.Name)); + + while (directoryPath.Length != 0 && directories.Add(directoryPath)) + { + // + // Directories are yielded in reverse order (deepest first). + // + // Note: We could use a Stack to control the order, + // but since order isn't guaranteed anyway and to avoid + // unnecessary memory allocation, we process them directly. + // + if (IsMatched(directoryPath.AsSpan(FullName.Length), patterns, excludes)) + yield return new AzureDirectory(_fs, VirtualPath.Normalize(directoryPath)); + + directoryPath = VirtualPath.GetDirectoryName(directoryPath); + } + + if (IsMatched(blob.Name.AsSpan(prefix.Length), patterns, excludes)) + yield return CreateVirtualFile(blob); + } + } + } + + /// + protected override async IAsyncEnumerable GetFilesCoreAsync(string[] patterns, string[]? excludes, [EnumeratorCancellation] CancellationToken cancellationToken) + { + // + // Cloud storage optimization strategy: + // 1. List all blobs in one batch using prefix filtering (matches our virtual directory). + // 2. Perform pattern matching and exclusion filters locally. + // + // Benefits: + // - Single API call instead of per-directory requests + // - Reduced network latency, especially for deep directory structures + // - More efficient than recursive directory scanning + // + + var prefix = GetPrefix(FullName); + + await foreach (var page in _fs.AzureClient + .GetBlobsAsync(prefix: prefix, cancellationToken: cancellationToken) + .AsPages() + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + foreach (var blob in page.Values) + if (IsMatched(blob.Name.AsSpan(prefix.Length), patterns, excludes)) + yield return CreateVirtualFile(blob); + } + } + + /// + protected override async IAsyncEnumerable GetDirectoriesCoreAsync(string[] patterns, string[]? excludes, [EnumeratorCancellation] CancellationToken cancellationToken) + { + // + // Cloud storage optimization strategy: + // 1. List all blobs in one batch using prefix filtering (matches our virtual directory). + // 2. Perform pattern matching and exclusion filters locally. + // + // Benefits: + // - Single API call instead of per-directory requests + // - Reduced network latency, especially for deep directory structures + // - More efficient than recursive directory scanning + // + + var prefix = GetPrefix(FullName); + var directories = new HashSet { FullName }; + + await foreach (var page in _fs.AzureClient + .GetBlobsAsync(prefix: prefix, cancellationToken: cancellationToken) + .AsPages() + .WithCancellation(cancellationToken) + .ConfigureAwait(false)) + { + foreach (var blob in page.Values) + { + var directoryPath = VirtualPath.GetDirectoryName( + VirtualPath.Join("/", blob.Name)); + + while (directoryPath.Length != 0 && directories.Add(directoryPath)) + { + // + // Directories are yielded in reverse order (deepest first). + // + // Note: We could use a Stack to control the order, + // but since order isn't guaranteed anyway and to avoid + // unnecessary memory allocation, we process them directly. + // + if (IsMatched(directoryPath.AsSpan(FullName.Length), patterns, excludes)) + yield return new AzureDirectory(_fs, VirtualPath.Normalize(directoryPath)); + + directoryPath = VirtualPath.GetDirectoryName(directoryPath); + } + } + } + } + /// - /// Creates a instance based on the specified blob item. + /// Creates a instance based on the specified blob item. /// /// The representing the file. /// /// A new instance representing the file. /// - private VirtualFile CreateVirtualFile(BlobItem blob) + private AzureFile CreateVirtualFile(BlobItem blob) { var info = blob.Properties; var properties = VirtualNodeProperties.CreateFileProperties( @@ -162,12 +287,48 @@ private VirtualFile CreateVirtualFile(BlobItem blob) } /// - /// Returns the blob prefix for the specified directory path. + /// Determines whether the specified path matches any of the inclusion patterns and none of the exclusion patterns. + /// + /// The path to match. + /// The inclusion patterns to match against the path. + /// An optional array of exclusion patterns. If the path matches any of these, + /// the method returns . + /// + /// if the path matches at least one inclusion pattern and no exclusion patterns; + /// otherwise, . + /// + private static bool IsMatched(scoped ReadOnlySpan path, string[] patterns, string[]? excludes) + { + if (excludes is not null) + foreach (var pattern in excludes) + if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) + return false; + + foreach (var pattern in patterns) + if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) + return true; + + return false; + } + + /// + /// Returns a cloud storage compatible prefix for the specified directory path. /// - /// The directory path for which to get the prefix. + /// The directory path. /// - /// The blob prefix associated with the directory. + /// A string formatted for cloud storage prefix filtering: + /// + /// Empty string for the root directory ("/") + /// Path without leading slash but with trailing slash for subdirectories + /// /// - private static string GetPrefix(string directoryPath) => - directoryPath == "/" ? "" : $"{directoryPath[1..]}/"; + /// + /// + /// GetPrefix("/") // returns "" + /// GetPrefix("/folder") // returns "folder/" + /// GetPrefix("/sub/folder") // returns "sub/folder/" + /// + /// + private static string GetPrefix(string path) => + path == "/" ? "" : $"{path[1..]}/"; } From 3703081eb4b80412be2c08e06ed6ce684b7be600 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 01:20:52 +0500 Subject: [PATCH 14/19] Optimize GCS glob pattern matching with batch listing and local filtering --- .../GcsDirectory.cs | 220 +++++++++++++++--- 1 file changed, 194 insertions(+), 26 deletions(-) diff --git a/src/Ramstack.FileSystem.Google/GcsDirectory.cs b/src/Ramstack.FileSystem.Google/GcsDirectory.cs index 348d3f8..80e8866 100644 --- a/src/Ramstack.FileSystem.Google/GcsDirectory.cs +++ b/src/Ramstack.FileSystem.Google/GcsDirectory.cs @@ -4,6 +4,10 @@ using Google; using Google.Cloud.Storage.V1; +using Ramstack.Globbing; + +using GcsObject = Google.Apis.Storage.v1.Data.Object; + namespace Ramstack.FileSystem.Google; /// @@ -13,7 +17,6 @@ namespace Ramstack.FileSystem.Google; internal sealed class GcsDirectory : VirtualDirectory { private readonly GoogleFileSystem _fs; - private readonly string _prefix; /// public override IVirtualFileSystem FileSystem => _fs; @@ -23,11 +26,8 @@ internal sealed class GcsDirectory : VirtualDirectory /// /// The file system associated with this directory. /// The path to the directory within the Google Cloud Storage bucket. - public GcsDirectory(GoogleFileSystem fileSystem, string path) : base(path) - { + public GcsDirectory(GoogleFileSystem fileSystem, string path) : base(path) => _fs = fileSystem; - _prefix = path == "/" ? "" : $"{path[1..]}/"; - } /// protected override ValueTask GetPropertiesCoreAsync(CancellationToken cancellationToken) => @@ -44,7 +44,7 @@ protected override ValueTask CreateCoreAsync(CancellationToken cancellationToken /// protected override async ValueTask DeleteCoreAsync(CancellationToken cancellationToken) { - await foreach (var obj in _fs.StorageClient.ListObjectsAsync(_fs.BucketName, _prefix).WithCancellation(cancellationToken).ConfigureAwait(false)) + await foreach (var obj in _fs.StorageClient.ListObjectsAsync(_fs.BucketName, GetPrefix(FullName)).WithCancellation(cancellationToken).ConfigureAwait(false)) { try { @@ -69,7 +69,7 @@ protected override async IAsyncEnumerable GetFileNodesCoreAsync([En }; var responses = _fs.StorageClient - .ListObjectsAsync(_fs.BucketName, _prefix, options) + .ListObjectsAsync(_fs.BucketName, GetPrefix(FullName), options) .AsRawResponses(); await foreach (var page in responses.WithCancellation(cancellationToken).ConfigureAwait(false)) @@ -82,14 +82,8 @@ protected override async IAsyncEnumerable GetFileNodesCoreAsync([En continue; foreach (var obj in page.Items) - { - var properties = VirtualNodeProperties.CreateFileProperties( - obj.TimeCreatedDateTimeOffset.GetValueOrDefault(), - DateTimeOffset.MinValue, - obj.UpdatedDateTimeOffset.GetValueOrDefault(), - (long)(obj.Size ?? 0)); - yield return new GcsFile(_fs, VirtualPath.Normalize(obj.Name), properties); - } + if (!obj.Name.EndsWith('/')) + yield return CreateVirtualFile(obj); } } @@ -103,18 +97,11 @@ protected override async IAsyncEnumerable GetFilesCoreAsync([Enumer IncludeTrailingDelimiter = true }; - var response = _fs.StorageClient.ListObjectsAsync(_fs.BucketName, _prefix, options); + var response = _fs.StorageClient.ListObjectsAsync(_fs.BucketName, GetPrefix(FullName), options); await foreach (var obj in response.WithCancellation(cancellationToken).ConfigureAwait(false)) - { - var properties = VirtualNodeProperties.CreateFileProperties( - obj.TimeCreatedDateTimeOffset.GetValueOrDefault(), - DateTimeOffset.MinValue, - obj.UpdatedDateTimeOffset.GetValueOrDefault(), - (long)(obj.Size ?? 0)); - - yield return new GcsFile(_fs, VirtualPath.Normalize(obj.Name), properties); - } + if (!obj.Name.EndsWith('/')) + yield return CreateVirtualFile(obj); } /// @@ -128,7 +115,7 @@ protected override async IAsyncEnumerable GetDirectoriesCoreAs }; var response = _fs.StorageClient - .ListObjectsAsync(_fs.BucketName, _prefix, options) + .ListObjectsAsync(_fs.BucketName, GetPrefix(FullName), options) .AsRawResponses(); await foreach (var page in response.WithCancellation(cancellationToken).ConfigureAwait(false)) @@ -140,4 +127,185 @@ protected override async IAsyncEnumerable GetDirectoriesCoreAs yield return new GcsDirectory(_fs, VirtualPath.Normalize(prefix)); } } + + /// + protected override async IAsyncEnumerable GetFileNodesCoreAsync(string[] patterns, string[]? excludes, [EnumeratorCancellation] CancellationToken cancellationToken) + { + // + // Cloud storage optimization strategy: + // 1. List all objects in one batch using prefix filtering (matches our virtual directory). + // 2. Perform pattern matching and exclusion filters locally. + // + // Benefits: + // - Single API call instead of per-directory requests + // - Reduced network latency, especially for deep directory structures + // - More efficient than recursive directory scanning + // + + var prefix = GetPrefix(FullName); + var directories = new HashSet { FullName }; + var response = _fs.StorageClient.ListObjectsAsync(_fs.BucketName, prefix); + + await foreach (var obj in response.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + var path = VirtualPath.Normalize(obj.Name); + var directoryPath = obj.Name.EndsWith('/') ? path : VirtualPath.GetDirectoryName(path); + + while (directoryPath.Length != 0 && directories.Add(directoryPath)) + { + // + // Directories are yielded in reverse order (deepest first). + // + // Note: We could use a Stack to control the order, + // but since order isn't guaranteed anyway and to avoid + // unnecessary memory allocation, we process them directly. + // + if (IsMatched(directoryPath.AsSpan(FullName.Length), patterns, excludes)) + yield return new GcsDirectory(_fs, VirtualPath.Normalize(directoryPath)); + + directoryPath = VirtualPath.GetDirectoryName(directoryPath); + } + + if (!obj.Name.EndsWith('/')) + if (IsMatched(obj.Name.AsSpan(prefix.Length), patterns, excludes)) + yield return CreateVirtualFile(obj, path); + } + } + + /// + protected override async IAsyncEnumerable GetFilesCoreAsync(string[] patterns, string[]? excludes, [EnumeratorCancellation] CancellationToken cancellationToken) + { + // + // Cloud storage optimization strategy: + // 1. List all objects in one batch using prefix filtering (matches our virtual directory). + // 2. Perform pattern matching and exclusion filters locally. + // + // Benefits: + // - Single API call instead of per-directory requests + // - Reduced network latency, especially for deep directory structures + // - More efficient than recursive directory scanning + // + + var prefix = GetPrefix(FullName); + + // + // Google Cloud Storage supports native glob matching in ListObjects, + // but we can't use this feature due to some limitation: + // GCS's MatchGlob doesn't support brace expansion in the middle of paths. + // For example, patterns like "project/{images,styles}/*.{svg,css}" won't work. + // + + var response = _fs.StorageClient.ListObjectsAsync(_fs.BucketName, prefix); + + await foreach (var obj in response.WithCancellation(cancellationToken).ConfigureAwait(false)) + if (!obj.Name.EndsWith('/')) + if (IsMatched(obj.Name.AsSpan(prefix.Length), patterns, excludes)) + yield return CreateVirtualFile(obj); + } + + /// + protected override async IAsyncEnumerable GetDirectoriesCoreAsync(string[] patterns, string[]? excludes, [EnumeratorCancellation] CancellationToken cancellationToken) + { + // + // Cloud storage optimization strategy: + // 1. List all objects in one batch using prefix filtering (matches our virtual directory). + // 2. Perform pattern matching and exclusion filters locally. + // + // Benefits: + // - Single API call instead of per-directory requests + // - Reduced network latency, especially for deep directory structures + // - More efficient than recursive directory scanning + // + + var response = _fs.StorageClient.ListObjectsAsync(_fs.BucketName, GetPrefix(FullName)); + var directories = new HashSet { FullName }; + + await foreach (var obj in response.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + var path = VirtualPath.Normalize(obj.Name); + var directoryPath = obj.Name.EndsWith('/') ? path : VirtualPath.GetDirectoryName(path); + + while (directoryPath.Length != 0 && directories.Add(directoryPath)) + { + // + // Directories are yielded in reverse order (deepest first). + // + // Note: We could use a Stack to control the order, + // but since order isn't guaranteed anyway and to avoid + // unnecessary memory allocation, we process them directly. + // + if (IsMatched(directoryPath.AsSpan(FullName.Length), patterns, excludes)) + yield return new GcsDirectory(_fs, VirtualPath.Normalize(directoryPath)); + + directoryPath = VirtualPath.GetDirectoryName(directoryPath); + } + } + } + + /// + /// Creates a instance based on the specified object item. + /// + /// The representing the file. + /// The normalized name of the object. + /// + /// A new instance representing the file. + /// + private GcsFile CreateVirtualFile(GcsObject obj, string? normalizedPath = null) + { + var properties = VirtualNodeProperties.CreateFileProperties( + obj.TimeCreatedDateTimeOffset.GetValueOrDefault(), + DateTimeOffset.MinValue, + obj.UpdatedDateTimeOffset.GetValueOrDefault(), + (long)(obj.Size ?? 0)); + + var path = normalizedPath ?? VirtualPath.Normalize(obj.Name); + return new GcsFile(_fs, path, properties); + } + + /// + /// Determines whether the specified path matches any of the inclusion patterns and none of the exclusion patterns. + /// + /// The path to match. + /// The inclusion patterns to match against the path. + /// An optional array of exclusion patterns. If the path matches any of these, + /// the method returns . + /// + /// if the path matches at least one inclusion pattern and no exclusion patterns; + /// otherwise, . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool IsMatched(scoped ReadOnlySpan path, string[] patterns, string[]? excludes) + { + if (excludes is not null) + foreach (var pattern in excludes) + if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) + return false; + + foreach (var pattern in patterns) + if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) + return true; + + return false; + } + + /// + /// Returns a cloud storage compatible prefix for the specified directory path. + /// + /// The directory path. + /// + /// A string formatted for cloud storage prefix filtering: + /// + /// Empty string for the root directory ("/") + /// Path without leading slash but with trailing slash for subdirectories + /// + /// + /// + /// + /// GetPrefix("/") // returns "" + /// GetPrefix("/folder") // returns "folder/" + /// GetPrefix("/sub/folder") // returns "sub/folder/" + /// + /// + private static string GetPrefix(string path) => + path == "/" ? "" : $"{path[1..]}/"; } From 7cdb9f442a1cb5d73b595ee12f0acd6f96b83ffb Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 01:23:36 +0500 Subject: [PATCH 15/19] Throw UnauthorizedAccessException when deleting artificial read-only directories --- .../PrefixedFileSystem.cs | 2 +- .../PrefixedFileSystemTests.cs | 48 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Ramstack.FileSystem.Prefixed/PrefixedFileSystem.cs b/src/Ramstack.FileSystem.Prefixed/PrefixedFileSystem.cs index 291073a..d720901 100644 --- a/src/Ramstack.FileSystem.Prefixed/PrefixedFileSystem.cs +++ b/src/Ramstack.FileSystem.Prefixed/PrefixedFileSystem.cs @@ -139,7 +139,7 @@ protected override ValueTask CreateCoreAsync(CancellationToken cancellationToken /// protected override ValueTask DeleteCoreAsync(CancellationToken cancellationToken) => - _fs.GetDirectory(_fs._prefix).DeleteAsync(cancellationToken); + throw new UnauthorizedAccessException($"Access to the path '{FullName}' is denied."); /// protected override IAsyncEnumerable GetFileNodesCoreAsync(CancellationToken cancellationToken) => diff --git a/tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemTests.cs b/tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemTests.cs index 3f46efb..3b240b7 100644 --- a/tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemTests.cs +++ b/tests/Ramstack.FileSystem.Prefixed.Tests/PrefixedFileSystemTests.cs @@ -9,6 +9,54 @@ public class PrefixedFileSystemTests : VirtualFileSystemSpecificationTests { private readonly TempFileStorage _storage = new TempFileStorage(); + [Test] + public async Task File_Create_InsideArtificialDirectory_ThrowsException() + { + using var fs = GetFileSystem(); + + await Assert.ThatAsync( + async () => await fs.WriteAsync("/ea5fd219.txt", Stream.Null), + Throws.Exception); + + Assert.That( + await fs.FileExistsAsync("/ea5fd219.txt"), + Is.False); + } + + [Test] + public async Task Directory_Delete_ArtificialDirectory_ThrowsException() + { + using var storage = new TempFileStorage(); + using var fs = new PrefixedFileSystem("/bin/apps/myapp1", new PhysicalFileSystem(storage.Root));; + + await Assert.ThatAsync( + async () => await fs.DeleteDirectoryAsync("/"), + Throws.Exception); + + await Assert.ThatAsync( + async () => await fs.DeleteDirectoryAsync("/bin"), + Throws.Exception); + + await Assert.ThatAsync( + async () => await fs.DeleteDirectoryAsync("/bin/apps"), + Throws.Exception); + + Assert.That( + await fs.FileExistsAsync("/bin/apps/myapp1/project/README.md"), + Is.True); + + await Assert.ThatAsync( + async () => await fs.DeleteDirectoryAsync("/bin/apps/myapp1"), + Throws.Nothing); + + Assert.That( + await fs.FileExistsAsync("/bin/apps/myapp1/project/README.md"), + Is.False); + } + + [OneTimeTearDown] + public void Cleanup() => + _storage.Dispose(); protected override IVirtualFileSystem GetFileSystem() => new PrefixedFileSystem("/project", From e7335ca87647a15b11ab9dbf3a4bca10f1da09c0 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 01:25:08 +0500 Subject: [PATCH 16/19] GlobbingFileSystem: Update tests --- .../GlobingFileSystemTests.cs | 94 ++++++++++++++----- 1 file changed, 71 insertions(+), 23 deletions(-) diff --git a/tests/Ramstack.FileSystem.Globbing.Tests/GlobingFileSystemTests.cs b/tests/Ramstack.FileSystem.Globbing.Tests/GlobingFileSystemTests.cs index b12855d..0fed676 100644 --- a/tests/Ramstack.FileSystem.Globbing.Tests/GlobingFileSystemTests.cs +++ b/tests/Ramstack.FileSystem.Globbing.Tests/GlobingFileSystemTests.cs @@ -7,33 +7,84 @@ namespace Ramstack.FileSystem.Globbing; [TestFixture] public class GlobingFileSystemTests : VirtualFileSystemSpecificationTests { - private readonly TempFileStorage _storage = new TempFileStorage(); + private readonly TempFileStorage _storage1; + private readonly TempFileStorage _storage2; + + public GlobingFileSystemTests() + { + _storage1 = new TempFileStorage(); + _storage2 = new TempFileStorage(_storage1); + } [OneTimeSetUp] public void Setup() { - var path = Path.Join(_storage.Root, "project"); - var directory = new DirectoryInfo(path); + var directory = new DirectoryInfo(Path.Join(_storage1.Root, "project")); foreach (var di in directory.GetDirectories("*", SearchOption.TopDirectoryOnly)) - if (di.Name != "docs") + if (di.Name != "assets") di.Delete(recursive: true); foreach (var fi in directory.GetFiles("*", SearchOption.TopDirectoryOnly)) - fi.Delete(); - - File.Delete(Path.Join(_storage.Root, "project/docs/troubleshooting/common_issues.txt")); + if (fi.Name != "README.md") + fi.Delete(); } [OneTimeTearDown] - public void Cleanup() => - _storage.Dispose(); + public void Cleanup() + { + _storage1.Dispose(); + _storage2.Dispose(); + } + + [Test] + public async Task Glob_MatchStructures() + { + using var fs = GetFileSystem(); + + Assert.That( + await fs + .GetDirectoriesAsync("/", "**") + .OrderBy(f => f.FullName) + .Select(f => f.FullName) + .ToArrayAsync(), + Is.EquivalentTo( + [ + "/project", + "/project/assets", + "/project/assets/fonts", + "/project/assets/images", + "/project/assets/images/backgrounds", + "/project/assets/styles" + ])); + + Assert.That( + await fs + .GetFilesAsync("/", "**") + .OrderBy(f => f.FullName) + .Select(f => f.FullName) + .ToArrayAsync(), + Is.EquivalentTo( + [ + "/project/assets/fonts/Arial.ttf", + "/project/assets/fonts/Roboto.ttf", + "/project/assets/images/backgrounds/dark.jpeg", + "/project/assets/images/backgrounds/light.jpg", + "/project/assets/images/icon.svg", + "/project/assets/images/logo.png", + "/project/assets/styles/main.css", + "/project/assets/styles/print.css", + "/project/README.md" + ])); + } [Test] public async Task ExcludedDirectory_HasNoFileNodes() { - using var storage = new TempFileStorage(); - using var fs = new GlobbingFileSystem(new PhysicalFileSystem(storage.Root), "**", exclude: "/project/src/**"); + using var fs = new GlobbingFileSystem( + new PhysicalFileSystem(_storage2.Root), + pattern: "**", + exclude: "/project/src/**"); var directories = new[] { @@ -47,15 +98,13 @@ public async Task ExcludedDirectory_HasNoFileNodes() foreach (var path in directories) { - var directory = fs.GetDirectory(path); - Assert.That( - await directory.ExistsAsync(), + await fs.DirectoryExistsAsync(path), Is.False); Assert.That( - await directory.GetFileNodesAsync("**").CountAsync(), - Is.Zero); + await fs.GetFileNodesAsync(path, "**").AnyAsync(), + Is.False); } var files = new[] @@ -74,18 +123,17 @@ await directory.GetFileNodesAsync("**").CountAsync(), }; foreach (var path in files) - { - var file = fs.GetFile(path); - Assert.That(await file.ExistsAsync(), Is.False); - } + Assert.That(await fs.FileExistsAsync(path), Is.False); } protected override IVirtualFileSystem GetFileSystem() { - var fs = new PhysicalFileSystem(_storage.Root); - return new GlobbingFileSystem(fs, "project/docs/**", exclude: "**/*.txt"); + var fs = new PhysicalFileSystem(_storage2.Root); + return new GlobbingFileSystem(fs, + patterns: ["project/assets/**", "project/README.md", "project/global.json"], + excludes: ["project/*.json"]); } protected override DirectoryInfo GetDirectoryInfo() => - new(_storage.Root); + new(_storage1.Root); } From 98f919ecb4171d5d11ee836b28b2a0433f9b93a8 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 01:25:50 +0500 Subject: [PATCH 17/19] Improve VirtualPath.GetDirectoryName XML documentation --- src/Ramstack.FileSystem.Abstractions/VirtualPath.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Ramstack.FileSystem.Abstractions/VirtualPath.cs b/src/Ramstack.FileSystem.Abstractions/VirtualPath.cs index d2a700e..ef964e0 100644 --- a/src/Ramstack.FileSystem.Abstractions/VirtualPath.cs +++ b/src/Ramstack.FileSystem.Abstractions/VirtualPath.cs @@ -118,7 +118,8 @@ public static ReadOnlySpan GetFileName(ReadOnlySpan path) /// /// The path to retrieve the directory portion from. /// - /// Directory portion for , or an empty string if the path denotes a root directory. + /// The directory portion of , or an empty span if + /// is empty or denotes a root directory. /// public static string GetDirectoryName(string path) { @@ -140,7 +141,8 @@ public static string GetDirectoryName(string path) /// /// The path to retrieve the directory portion from. /// - /// Directory portion for , or an empty string if path denotes a root directory. + /// The directory portion of , or an empty span if + /// is empty or denotes a root directory. /// public static ReadOnlySpan GetDirectoryName(ReadOnlySpan path) { From ef1b1283e9dad5ec1c2309d81f1cae4985a0bb61 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 01:28:06 +0500 Subject: [PATCH 18/19] Clean up test resources --- .../WritableAmazonFileSystemTests.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs b/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs index 911dd27..f009143 100644 --- a/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs +++ b/tests/Ramstack.FileSystem.Amazon.Tests/WritableAmazonFileSystemTests.cs @@ -159,6 +159,9 @@ public async Task File_CopyTo_File_DifferentFileSystems() Assert.That( await reader.ReadToEndAsync(), Is.EqualTo(content)); + + await source.DeleteAsync(); + await destination.DeleteAsync(); } [Test] @@ -187,6 +190,9 @@ public async Task File_CopyTo_File_DifferentStorages() Assert.That( await reader.ReadToEndAsync(), Is.EqualTo(content)); + + await source.DeleteAsync(); + await destination.DeleteAsync(); } From fe12a1c6be10c99f11ef1289bb101993b3b8fda8 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 11 Aug 2025 01:28:29 +0500 Subject: [PATCH 19/19] CompositeFileSystem: Update tests --- .../CompositeFileSystemTests.cs | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/tests/Ramstack.FileSystem.Composite.Tests/CompositeFileSystemTests.cs b/tests/Ramstack.FileSystem.Composite.Tests/CompositeFileSystemTests.cs index 312f365..17fc98e 100644 --- a/tests/Ramstack.FileSystem.Composite.Tests/CompositeFileSystemTests.cs +++ b/tests/Ramstack.FileSystem.Composite.Tests/CompositeFileSystemTests.cs @@ -8,23 +8,34 @@ namespace Ramstack.FileSystem.Composite; [TestFixture] public class CompositeFileSystemTests : VirtualFileSystemSpecificationTests { - private readonly TempFileStorage _storage = new TempFileStorage(); + private readonly TempFileStorage _storage1; + private readonly TempFileStorage _storage2; + private readonly TempFileStorage _storage3; private readonly CompositeFileSystem _fs; public CompositeFileSystemTests() { - var root = Path.Join(_storage.Root, "project"); + _storage1 = new TempFileStorage(); + _storage2 = new TempFileStorage(_storage1); + _storage3 = new TempFileStorage(_storage1); - foreach (var file in Directory.GetFiles(root)) + foreach (var directory in Directory.GetDirectories(Path.Join(_storage2.Root, "project"))) + Directory.Delete(directory, recursive: true); + + foreach (var file in Directory.GetFiles(Path.Join(_storage3.Root, "project"))) File.Delete(file); - var list = new List(); - foreach (var directory in Directory.GetDirectories(root)) + var list = new List { - var fileName = Path.GetFileName(directory); - Console.WriteLine(fileName); + new PhysicalFileSystem(_storage2.Root) + }; - var fs = new PrefixedFileSystem(fileName, new PhysicalFileSystem(directory)); + foreach (var directory in Directory.GetDirectories(Path.Join(_storage3.Root, "project"))) + { + var fs = new PrefixedFileSystem( + $"/project/{Path.GetFileName(directory)}", + new PhysicalFileSystem(directory) + ); list.Add(fs); } @@ -35,11 +46,14 @@ public CompositeFileSystemTests() public void Cleanup() { _fs.Dispose(); + _storage1.Dispose(); + _storage2.Dispose(); + _storage3.Dispose(); } protected override IVirtualFileSystem GetFileSystem() => _fs; protected override DirectoryInfo GetDirectoryInfo() => - new DirectoryInfo(Path.Join(_storage.Root, "project")); + new DirectoryInfo(_storage1.Root); }