diff --git a/src/Ramstack.FileProviders/PrefixedFileProvider.cs b/src/Ramstack.FileProviders/PrefixedFileProvider.cs index 9609e46..f41edf8 100644 --- a/src/Ramstack.FileProviders/PrefixedFileProvider.cs +++ b/src/Ramstack.FileProviders/PrefixedFileProvider.cs @@ -135,24 +135,77 @@ 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; - // 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()); - - return string.Join("/", list); + // 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() + while (filterSegments.MoveNext()) + fs = filterSegments.Current; + + return fs is not "**" + ? "**/" + fs.ToString() + : "**"; } if (fs is "*") @@ -174,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()) 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")]