From d5c8a7e171e48a7477a6c19b828bb4e91596ec93 Mon Sep 17 00:00:00 2001 From: rameel Date: Thu, 14 Aug 2025 04:54:09 +0500 Subject: [PATCH 1/5] Clean up and simplify tests --- .../AbstractFileProviderTests.cs | 10 ++-- .../GlobbingFileProviderTests.cs | 49 ++++++++++++------- .../PrefixedFileProviderTests.cs | 8 +-- .../SubFileProviderTests.cs | 6 ++- .../Utilities/TempFileStorage.cs | 49 ++++++++++--------- .../ZipFileProviderTests.cs | 6 ++- 6 files changed, 74 insertions(+), 54 deletions(-) diff --git a/tests/Ramstack.FileProviders.Tests/AbstractFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/AbstractFileProviderTests.cs index 68a80a4..d74c2ef 100644 --- a/tests/Ramstack.FileProviders.Tests/AbstractFileProviderTests.cs +++ b/tests/Ramstack.FileProviders.Tests/AbstractFileProviderTests.cs @@ -61,12 +61,9 @@ public void GetFileInfo_NonExistingFile() { using var provider = CreateFileProvider(); - var name = $"{Guid.NewGuid()}.txt"; - var info = provider.GetFileInfo(name); + var info = provider.GetFileInfo("/project/8f723faf0ee0.txt"); - Assert.That( - FilePath.GetFileName(info.Name), - Is.EqualTo(name)); + Assert.That(info.Name, Is.EqualTo("8f723faf0ee0.txt").Or.EqualTo("/project/8f723faf0ee0.txt")); Assert.That(info.IsDirectory, Is.False); Assert.That(info.Exists, Is.False); } @@ -76,8 +73,7 @@ public void GetDirectoryContents_NonExistingDirectory() { using var provider = CreateFileProvider(); - var name = Guid.NewGuid().ToString(); - var info = provider.GetDirectoryContents($"/{name}"); + var info = provider.GetDirectoryContents("/project/c800f57d/7c71"); Assert.That(info.Exists, Is.False); } diff --git a/tests/Ramstack.FileProviders.Tests/GlobbingFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/GlobbingFileProviderTests.cs index 1c6f7fd..ce4ddb2 100644 --- a/tests/Ramstack.FileProviders.Tests/GlobbingFileProviderTests.cs +++ b/tests/Ramstack.FileProviders.Tests/GlobbingFileProviderTests.cs @@ -5,33 +5,44 @@ namespace Ramstack.FileProviders; [TestFixture] public class GlobbingFileProviderTests : AbstractFileProviderTests { - private readonly TempFileStorage _storage = new TempFileStorage(); + private readonly TempFileStorage _storage1; + private readonly TempFileStorage _storage2; + + public GlobbingFileProviderTests() + { + _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 void ExcludedDirectory_HasNoFileNodes() { - using var storage = new TempFileStorage(); - var fs = new GlobbingFileProvider(new PhysicalFileProvider(storage.Root), "**", exclude: "/project/src/**"); + var provider = new GlobbingFileProvider( + new PhysicalFileProvider(_storage2.Root), + pattern: "**", + exclude: "/project/src/**"); var directories = new[] { @@ -45,15 +56,15 @@ public void ExcludedDirectory_HasNoFileNodes() foreach (var path in directories) { - var directory = fs.GetDirectory(path); + var directory = provider.GetDirectoryContents(path); Assert.That( directory.Exists, Is.False); Assert.That( - directory.EnumerateFileNodes("**").Count(), - Is.Zero); + directory.Any(), + Is.False); } var files = new[] @@ -73,17 +84,19 @@ public void ExcludedDirectory_HasNoFileNodes() foreach (var path in files) { - var file = fs.GetFile(path); + var file = provider.GetFileInfo(path); Assert.That(file.Exists, Is.False); } } protected override IFileProvider GetFileProvider() { - var provider = new PhysicalFileProvider(_storage.Root); - return new GlobbingFileProvider(provider, "project/docs/**", exclude: "**/*.txt"); + var provider = new PhysicalFileProvider(_storage2.Root); + return new GlobbingFileProvider(provider, + patterns: ["project/assets/**", "project/README.md", "project/global.json"], + excludes: ["project/*.json"]); } protected override DirectoryInfo GetDirectoryInfo() => - new DirectoryInfo(_storage.Root); + new DirectoryInfo(_storage1.Root); } diff --git a/tests/Ramstack.FileProviders.Tests/PrefixedFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/PrefixedFileProviderTests.cs index 6e9f741..f18f2f8 100644 --- a/tests/Ramstack.FileProviders.Tests/PrefixedFileProviderTests.cs +++ b/tests/Ramstack.FileProviders.Tests/PrefixedFileProviderTests.cs @@ -12,12 +12,12 @@ public sealed class PrefixedFileProviderTests : AbstractFileProviderTests .GetMethod("ResolveGlobFilter", BindingFlags.Static | BindingFlags.NonPublic)! .CreateDelegate>(); - private const string Prefix = "solution/app"; - - private readonly TempFileStorage _storage = new TempFileStorage(Prefix); + private readonly TempFileStorage _storage = new TempFileStorage(); protected override IFileProvider GetFileProvider() => - new PrefixedFileProvider(Prefix, new PhysicalFileProvider(_storage.PrefixedPath, ExclusionFilters.None)); + new PrefixedFileProvider("/project", + new PhysicalFileProvider( + Path.Join(_storage.Root, "project"))); protected override DirectoryInfo GetDirectoryInfo() => new DirectoryInfo(_storage.Root); diff --git a/tests/Ramstack.FileProviders.Tests/SubFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/SubFileProviderTests.cs index a551628..270f0a0 100644 --- a/tests/Ramstack.FileProviders.Tests/SubFileProviderTests.cs +++ b/tests/Ramstack.FileProviders.Tests/SubFileProviderTests.cs @@ -12,8 +12,10 @@ public void Cleanup() => _storage.Dispose(); protected override IFileProvider GetFileProvider() => - new SubFileProvider("/project/docs", new PhysicalFileProvider(_storage.Root)); + new SubFileProvider("/bin/app", + new PrefixedFileProvider("/bin/app", + new PhysicalFileProvider(_storage.Root))); protected override DirectoryInfo GetDirectoryInfo() => - new DirectoryInfo(Path.Join(_storage.Root, "project", "docs")); + new DirectoryInfo(Path.Join(_storage.Root)); } diff --git a/tests/Ramstack.FileProviders.Tests/Utilities/TempFileStorage.cs b/tests/Ramstack.FileProviders.Tests/Utilities/TempFileStorage.cs index ddf3001..574736c 100644 --- a/tests/Ramstack.FileProviders.Tests/Utilities/TempFileStorage.cs +++ b/tests/Ramstack.FileProviders.Tests/Utilities/TempFileStorage.cs @@ -2,19 +2,11 @@ namespace Ramstack.FileProviders.Utilities; public sealed class TempFileStorage : IDisposable { - public string Root { get; } - public string PrefixedPath { get; } + public string Root { get; } = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - public TempFileStorage(string prefix = "") + public TempFileStorage() { - var root = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - var path = prefix.Length != 0 - ? Path.GetFullPath(Path.Join(root, prefix)) - : root; - - Root = root; - PrefixedPath = path; - + var path = Root; var list = new[] { "project/docs/user_manual.pdf", @@ -53,10 +45,6 @@ public TempFileStorage(string prefix = "") "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", @@ -78,13 +66,13 @@ public TempFileStorage(string prefix = "") "project/assets/images/backgrounds/light.jpg", "project/assets/images/backgrounds/dark.jpeg", - "project/assets/fonts/opensans.ttf", - "project/assets/fonts/roboto.ttf", + "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.nupkg", + "project/packages/Ramstack.Globbing.2.1.0/Ramstack.Globbing.2.1.0.zip", "project/.gitignore", "project/.editorconfig", @@ -106,12 +94,29 @@ 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() => diff --git a/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs index 9fed1d7..eee0c4e 100644 --- a/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs +++ b/tests/Ramstack.FileProviders.Tests/ZipFileProviderTests.cs @@ -8,7 +8,11 @@ namespace Ramstack.FileProviders; public class ZipFileProviderTests : AbstractFileProviderTests { private readonly TempFileStorage _storage = new TempFileStorage(); - private readonly string _path = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + private readonly string _path = + Path.Combine( + Path.GetTempPath(), + Path.GetRandomFileName() + ) + ".zip"; [OneTimeSetUp] public void Setup() From 2c567a1e20ad378d43e7cdc901f35bd346207b65 Mon Sep 17 00:00:00 2001 From: rameel Date: Thu, 14 Aug 2025 04:55:03 +0500 Subject: [PATCH 2/5] Expand FilePath.GetDirectoryName test coverage --- tests/Ramstack.FileProviders.Tests/FilePathTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Ramstack.FileProviders.Tests/FilePathTests.cs b/tests/Ramstack.FileProviders.Tests/FilePathTests.cs index fd274f3..25febd8 100644 --- a/tests/Ramstack.FileProviders.Tests/FilePathTests.cs +++ b/tests/Ramstack.FileProviders.Tests/FilePathTests.cs @@ -45,6 +45,7 @@ public void GetFileName(string path, string expected) [TestCase("", "")] [TestCase("/", "")] + [TestCase("dir", "")] [TestCase("/dir", "/")] [TestCase("/dir/file", "/dir")] [TestCase("/dir/dir/", "/dir/dir")] From a190d43dc8275494d6879a4f5881dc20412c3aba Mon Sep 17 00:00:00 2001 From: rameel Date: Thu, 14 Aug 2025 04:56:46 +0500 Subject: [PATCH 3/5] GlobbingFileProvider: 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). --- .../GlobbingFileProvider.cs | 51 +-- .../Internal/PathHelper.cs | 339 ++++++++++++++++++ .../GlobbingFileProviderTests.cs | 41 +++ 3 files changed, 406 insertions(+), 25 deletions(-) create mode 100644 src/Ramstack.FileProviders.Globbing/Internal/PathHelper.cs diff --git a/src/Ramstack.FileProviders.Globbing/GlobbingFileProvider.cs b/src/Ramstack.FileProviders.Globbing/GlobbingFileProvider.cs index 6021836..25ad525 100644 --- a/src/Ramstack.FileProviders.Globbing/GlobbingFileProvider.cs +++ b/src/Ramstack.FileProviders.Globbing/GlobbingFileProvider.cs @@ -1,4 +1,4 @@ -using Ramstack.Globbing; +using Ramstack.FileProviders.Internal; namespace Ramstack.FileProviders; @@ -60,10 +60,9 @@ public GlobbingFileProvider(IFileProvider provider, string[] patterns, string[]? public IFileInfo GetFileInfo(string subpath) { subpath = FilePath.Normalize(subpath); - if (!IsExcluded(subpath) && IsIncluded(subpath)) - return _provider.GetFileInfo(subpath); - - return new NotFoundFileInfo(subpath); + return IsFileIncluded(subpath) + ? _provider.GetFileInfo(subpath) + : new NotFoundFileInfo(subpath); } /// @@ -71,7 +70,7 @@ public IDirectoryContents GetDirectoryContents(string subpath) { subpath = FilePath.Normalize(subpath); - if (!IsExcluded(subpath)) + if (IsDirectoryIncluded(subpath)) { var directory = _provider.GetDirectoryContents(subpath); if (directory is not NotFoundDirectoryContents) @@ -85,23 +84,26 @@ public IDirectoryContents GetDirectoryContents(string subpath) public IChangeToken Watch(string filter) => _provider.Watch(filter); - private bool IsIncluded(string path) - { - foreach (var pattern in _patterns) - if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) - return true; - - return false; - } - - private bool IsExcluded(string path) - { - foreach (var pattern in _excludes) - if (Matcher.IsMatch(path, pattern, MatchFlags.Unix)) - return true; + /// + /// Determines if a file is included based on the specified patterns and exclusions. + /// + /// The path of the file. + /// + /// if the file is included; + /// otherwise, . + /// + internal bool IsFileIncluded(string path) => + !PathHelper.IsMatch(path, _excludes) && PathHelper.IsMatch(path, _patterns); - return false; - } + /// + /// Determines if a directory is included based on the specified exclusions. + /// + /// The path of the directory. + /// + /// if the directory is included; otherwise, . + /// + private bool IsDirectoryIncluded(string path) => + path == "/" || !PathHelper.IsMatch(path, _excludes) && PathHelper.IsPartialMatch(path, _patterns); #region Inner type: GlobbingDirectoryContents @@ -136,9 +138,8 @@ public IEnumerator GetEnumerator() foreach (var file in _directory) { var path = FilePath.Join(_directoryPath, file.Name); - if (!_provider.IsExcluded(path)) - if (file.IsDirectory || _provider.IsIncluded(path)) - yield return file; + if (file.IsDirectory ? _provider.IsDirectoryIncluded(path) : _provider.IsFileIncluded(path)) + yield return file; } } diff --git a/src/Ramstack.FileProviders.Globbing/Internal/PathHelper.cs b/src/Ramstack.FileProviders.Globbing/Internal/PathHelper.cs new file mode 100644 index 0000000..165a5fd --- /dev/null +++ b/src/Ramstack.FileProviders.Globbing/Internal/PathHelper.cs @@ -0,0 +1,339 @@ +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.FileProviders.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 +} diff --git a/tests/Ramstack.FileProviders.Tests/GlobbingFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/GlobbingFileProviderTests.cs index ce4ddb2..b605cde 100644 --- a/tests/Ramstack.FileProviders.Tests/GlobbingFileProviderTests.cs +++ b/tests/Ramstack.FileProviders.Tests/GlobbingFileProviderTests.cs @@ -36,6 +36,47 @@ public void Cleanup() _storage2.Dispose(); } + [Test] + public void Glob_MatchStructures() + { + using var provider = CreateFileProvider(); + + Assert.That( + provider + .EnumerateDirectories("/", "**") + .OrderBy(f => f.FullName) + .Select(f => f.FullName) + .ToArray(), + Is.EquivalentTo( + [ + "/project", + "/project/assets", + "/project/assets/fonts", + "/project/assets/images", + "/project/assets/images/backgrounds", + "/project/assets/styles" + ])); + + Assert.That( + provider + .EnumerateFiles("/", "**") + .OrderBy(f => f.FullName) + .Select(f => f.FullName) + .ToArray(), + 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 void ExcludedDirectory_HasNoFileNodes() { From 14d3209380ada5617d08a26e0b9a8e05275a3be2 Mon Sep 17 00:00:00 2001 From: rameel Date: Thu, 14 Aug 2025 04:57:13 +0500 Subject: [PATCH 4/5] Update NuGet packages --- Directory.Packages.props | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index fe3463c..9e44184 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,8 +10,8 @@ - - + + - \ No newline at end of file + From 93ba56aa94c9b8388480af7d20c96aad0457d17f Mon Sep 17 00:00:00 2001 From: rameel Date: Thu, 14 Aug 2025 05:06:17 +0500 Subject: [PATCH 5/5] Make internal helpers private --- src/Ramstack.FileProviders.Globbing/GlobbingFileProvider.cs | 2 +- src/Ramstack.FileProviders.Globbing/Internal/PathHelper.cs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Ramstack.FileProviders.Globbing/GlobbingFileProvider.cs b/src/Ramstack.FileProviders.Globbing/GlobbingFileProvider.cs index 25ad525..89f8986 100644 --- a/src/Ramstack.FileProviders.Globbing/GlobbingFileProvider.cs +++ b/src/Ramstack.FileProviders.Globbing/GlobbingFileProvider.cs @@ -92,7 +92,7 @@ public IChangeToken Watch(string filter) => /// if the file is included; /// otherwise, . /// - internal bool IsFileIncluded(string path) => + private bool IsFileIncluded(string path) => !PathHelper.IsMatch(path, _excludes) && PathHelper.IsMatch(path, _patterns); /// diff --git a/src/Ramstack.FileProviders.Globbing/Internal/PathHelper.cs b/src/Ramstack.FileProviders.Globbing/Internal/PathHelper.cs index 165a5fd..ea0a405 100644 --- a/src/Ramstack.FileProviders.Globbing/Internal/PathHelper.cs +++ b/src/Ramstack.FileProviders.Globbing/Internal/PathHelper.cs @@ -61,7 +61,7 @@ public static bool IsPartialMatch(scoped ReadOnlySpan path, string[] patte /// /// The number of segments in the path. /// - public static int CountPathSegments(scoped ReadOnlySpan path) + private static int CountPathSegments(scoped ReadOnlySpan path) { var count = 0; var iterator = new PathSegmentIterator(); @@ -93,7 +93,7 @@ public static int CountPathSegments(scoped ReadOnlySpan path) /// /// A representing the partial pattern. /// - public static ReadOnlySpan GetPartialPattern(string pattern, int depth) + private static ReadOnlySpan GetPartialPattern(string pattern, int depth) { Debug.Assert(depth >= 1);