From 21ce1b549a1ee8919d334c8cf49a081012730e88 Mon Sep 17 00:00:00 2001 From: rameel Date: Sun, 29 Mar 2026 23:57:07 +0500 Subject: [PATCH 1/4] Avoid false negatives when resolving '**' in glob filters --- src/Ramstack.FileProviders/PrefixedFileProvider.cs | 14 +++++++------- .../PrefixedFileProviderTests.cs | 6 ++++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/Ramstack.FileProviders/PrefixedFileProvider.cs b/src/Ramstack.FileProviders/PrefixedFileProvider.cs index 9609e46..bb6ea11 100644 --- a/src/Ramstack.FileProviders/PrefixedFileProvider.cs +++ b/src/Ramstack.FileProviders/PrefixedFileProvider.cs @@ -144,13 +144,13 @@ public void Dispose() => // The globstar '**' matches any number of remaining segments, including none if (fs is "**") { - // Add '**' and all remaining filter segments to the result. - do - { - var segment = filterSegments.Current.ToString(); - list.Add(segment); - } - while (filterSegments.MoveNext()); + var lastSegment = fs; + while (filterSegments.MoveNext()) + lastSegment = filterSegments.Current; + + list.Add("**"); + if (lastSegment is not "**") + list.Add(lastSegment.ToString()); return string.Join("/", list); } diff --git a/tests/Ramstack.FileProviders.Tests/PrefixedFileProviderTests.cs b/tests/Ramstack.FileProviders.Tests/PrefixedFileProviderTests.cs index a88668b..da0591b 100644 --- a/tests/Ramstack.FileProviders.Tests/PrefixedFileProviderTests.cs +++ b/tests/Ramstack.FileProviders.Tests/PrefixedFileProviderTests.cs @@ -29,6 +29,12 @@ protected override DirectoryInfo GetDirectoryInfo() => [TestCase("/modules/profile/assets", "/modules/**", ExpectedResult = "**")] [TestCase("/modules/profile/assets", "/modules/**/*.js", ExpectedResult = "**/*.js")] + [TestCase("/modules/profile/assets", "/modules/**/assets/*.js", ExpectedResult = "**/*.js")] + [TestCase("/modules/profile/assets", "/modules/**/profile/assets/*.{js,css}", ExpectedResult = "**/*.{js,css}")] + [TestCase("/modules/profile/assets", "/modules/**/profile/assets/**", ExpectedResult = "**")] + [TestCase("/modules/profile/assets", "/modules/**/profile/assets/**/*", ExpectedResult = "**/*")] + [TestCase("/modules/profile/assets", "/**/*.js", ExpectedResult = "**/*.js")] + [TestCase("/modules/profile/assets", "/**", ExpectedResult = "**")] [TestCase("/modules/profile/assets", "/modules/profile/*/*.js", ExpectedResult = "*.js")] [TestCase("/modules/profile/assets", "/modules/profile/{js,css,assets}/*.js", ExpectedResult = "*.js")] [TestCase("/modules/profile/assets", "/modules/{settings,profile}/{js,css,assets}/*.js", ExpectedResult = "*.js")] From a07378495d7cbc49474e3b6337e77a03a09c160a Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 30 Mar 2026 00:31:06 +0500 Subject: [PATCH 2/4] Add explanation comment --- .../PrefixedFileProvider.cs | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/src/Ramstack.FileProviders/PrefixedFileProvider.cs b/src/Ramstack.FileProviders/PrefixedFileProvider.cs index bb6ea11..b6652b8 100644 --- a/src/Ramstack.FileProviders/PrefixedFileProvider.cs +++ b/src/Ramstack.FileProviders/PrefixedFileProvider.cs @@ -141,9 +141,67 @@ public void Dispose() => { var fs = filterSegments.Current; - // The globstar '**' matches any number of remaining segments, including none if (fs is "**") { + // The globstar '**' matches zero or more path segments. + // Once we encounter '**', we lose the ability to deterministically align + // the remaining filter segments with the remaining prefix segments. + // + // Why this matters: + // We are transforming a filter defined over the 'outer' virtual path + // into a filter for the 'inner' provider (mounted at 'prefix'). + // To do that precisely, we would need to know how many segments '**' consumes. + // + // However, this is fundamentally ambiguous: + // - '**' may consume 0 segments + // - '**' may consume N segments (including prefix tail segments) + // - or it may match entirely within the underlying provider + // + // Example (false negative if we over-reduce): + // prefix: /modules/profile/assets + // filter: /modules/**/assets/*.js + // + // Underlying provider may contain: + // /src/_build/assets/main.js + // + // Which corresponds to: + // /modules/profile/assets/src/_build/assets/main.js + // + // In this case: + // '**/assets/*.js' --> MUST match + // '*.js' --> would NOT match + // + // Counter-example (false negative if we try to keep prefix tail): + // prefix: /modules/profile/assets + // filter: /modules/**/assets/*.js + // + // Suppose 'assets' in the filter refers to the *prefix itself*, + // and the underlying provider contains only flat files: + // /main.js + // + // (i.e. no nested 'assets/' directory inside the provider) + // + // Then: + // '*.js' --> MUST match + // '**/assets/*.js' --> would NOT match + // + // Conclusion: + // After '**', we cannot know whether subsequent segments belong + // to the prefix or to the underlying provider. + // + // Therefore, any attempt to: + // - consume prefix segments (--> '*.js') + // - or preserve intermediate literals (--> '**/assets/*.js') + // will break valid scenarios. + // + // Strategy: + // - Preserve '**' to allow arbitrary depth + // - Drop ambiguous intermediate segments + // - Keep only the final segment if it is a file pattern (e.g. '*.js') + // + // This guarantees: + // - No false negatives caused by prefix misalignment + // - Possible false positives, which are acceptable for Watch() var lastSegment = fs; while (filterSegments.MoveNext()) lastSegment = filterSegments.Current; From 5b030e4a92fa5fabfdc0614c8a7531aff81d2bb6 Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 30 Mar 2026 00:38:24 +0500 Subject: [PATCH 3/4] Clean up --- src/Ramstack.FileProviders/PrefixedFileProvider.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/Ramstack.FileProviders/PrefixedFileProvider.cs b/src/Ramstack.FileProviders/PrefixedFileProvider.cs index b6652b8..a7d36ec 100644 --- a/src/Ramstack.FileProviders/PrefixedFileProvider.cs +++ b/src/Ramstack.FileProviders/PrefixedFileProvider.cs @@ -202,13 +202,12 @@ public void Dispose() => // This guarantees: // - No false negatives caused by prefix misalignment // - Possible false positives, which are acceptable for Watch() - var lastSegment = fs; while (filterSegments.MoveNext()) - lastSegment = filterSegments.Current; + fs = filterSegments.Current; list.Add("**"); - if (lastSegment is not "**") - list.Add(lastSegment.ToString()); + if (fs is not "**") + list.Add(fs.ToString()); return string.Join("/", list); } From c6d295133129d4c61bdbb286b99c695a046852ca Mon Sep 17 00:00:00 2001 From: rameel Date: Mon, 30 Mar 2026 00:49:54 +0500 Subject: [PATCH 4/4] Simplify path segment joining --- src/Ramstack.FileProviders/PrefixedFileProvider.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Ramstack.FileProviders/PrefixedFileProvider.cs b/src/Ramstack.FileProviders/PrefixedFileProvider.cs index a7d36ec..f41edf8 100644 --- a/src/Ramstack.FileProviders/PrefixedFileProvider.cs +++ b/src/Ramstack.FileProviders/PrefixedFileProvider.cs @@ -135,8 +135,6 @@ public void Dispose() => var prefixSegments = new PathTokenizer(prefix).GetEnumerator(); var filterSegments = new PathTokenizer(filter).GetEnumerator(); - var list = new List(); - while (prefixSegments.MoveNext() && filterSegments.MoveNext()) { var fs = filterSegments.Current; @@ -205,11 +203,9 @@ public void Dispose() => while (filterSegments.MoveNext()) fs = filterSegments.Current; - list.Add("**"); - if (fs is not "**") - list.Add(fs.ToString()); - - return string.Join("/", list); + return fs is not "**" + ? "**/" + fs.ToString() + : "**"; } if (fs is "*") @@ -231,6 +227,8 @@ public void Dispose() => if (!prefixSegments.MoveNext()) { + var list = new List(); + // All prefix segments have been matched and consumed successfully. // Append all remaining filter segments. while (filterSegments.MoveNext())