Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 67 additions & 12 deletions src/Ramstack.FileProviders/PrefixedFileProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,24 +135,77 @@ public void Dispose() =>
var prefixSegments = new PathTokenizer(prefix).GetEnumerator();
var filterSegments = new PathTokenizer(filter).GetEnumerator();

var list = new List<string>();

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 "*")
Expand All @@ -174,6 +227,8 @@ public void Dispose() =>

if (!prefixSegments.MoveNext())
{
var list = new List<string>();

// All prefix segments have been matched and consumed successfully.
// Append all remaining filter segments.
while (filterSegments.MoveNext())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
Loading