Skip to content

Commit 8fc3ce8

Browse files
authored
Merge pull request #30 from rameel/prefixed-fileprovider
Improve glob filter handling in PrefixedFileProvider
2 parents 3036d8b + f6950ab commit 8fc3ce8

File tree

4 files changed

+118
-10
lines changed

4 files changed

+118
-10
lines changed

src/Ramstack.FileProviders.Composition/Ramstack.FileProviders.Composition.csproj

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,6 @@
3535
<GenerateDocumentationFile>true</GenerateDocumentationFile>
3636
</PropertyGroup>
3737

38-
<ItemGroup>
39-
<InternalsVisibleTo Include="Ramstack.FileProviders.Tests" />
40-
</ItemGroup>
41-
4238
<ItemGroup>
4339
<PackageReference Include="Microsoft.Extensions.FileProviders.Abstractions" />
4440
<PackageReference Include="Microsoft.Extensions.FileProviders.Composite" />

src/Ramstack.FileProviders/PrefixedFileProvider.cs

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
using Ramstack.Globbing;
2+
13
namespace Ramstack.FileProviders;
24

35
/// <summary>
@@ -22,6 +24,7 @@ public sealed class PrefixedFileProvider : IFileProvider, IDisposable
2224
/// to which the prefix will be applied.</param>
2325
public PrefixedFileProvider(string prefix, IFileProvider provider)
2426
{
27+
ArgumentNullException.ThrowIfNull(prefix);
2528
ArgumentNullException.ThrowIfNull(provider);
2629

2730
prefix = FilePath.Normalize(prefix);
@@ -45,7 +48,9 @@ public PrefixedFileProvider(string prefix, IFileProvider provider)
4548
/// <inheritdoc />
4649
public IFileInfo GetFileInfo(string subpath)
4750
{
48-
var path = ResolvePath(FilePath.Normalize(subpath), _prefix);
51+
subpath = FilePath.Normalize(subpath);
52+
53+
var path = ResolvePath(_prefix, subpath);
4954
if (path is not null)
5055
return _provider.GetFileInfo(path);
5156

@@ -62,7 +67,7 @@ public IDirectoryContents GetDirectoryContents(string subpath)
6267
if (entry.Path == subpath)
6368
return new ArtificialDirectoryContents(entry.DirectoryName);
6469

65-
var path = ResolvePath(subpath, _prefix);
70+
var path = ResolvePath(_prefix, subpath);
6671
if (path is not null)
6772
return _provider.GetDirectoryContents(path);
6873

@@ -72,27 +77,110 @@ public IDirectoryContents GetDirectoryContents(string subpath)
7277
/// <inheritdoc />
7378
public IChangeToken Watch(string filter)
7479
{
75-
var path = ResolvePath(FilePath.Normalize(filter), _prefix);
80+
filter = FilePath.Normalize(filter);
81+
82+
var path = ResolvePath(_prefix, filter);
7683
if (path is not null)
7784
return _provider.Watch(path);
7885

86+
var pattern = ResolveGlobFilter(_prefix, filter);
87+
if (pattern is not null)
88+
return _provider.Watch(pattern);
89+
7990
return NullChangeToken.Singleton;
8091
}
8192

8293
/// <inheritdoc />
8394
public void Dispose() =>
8495
(_provider as IDisposable)?.Dispose();
8596

86-
private static string? ResolvePath(string path, string prefix)
97+
private static string? ResolvePath(string prefix, string path)
8798
{
8899
Debug.Assert(path == FilePath.Normalize(path));
89100

90101
if (path == prefix)
91102
return "/";
92103

93-
if (path.StartsWith(prefix, StringComparison.Ordinal) && path[prefix.Length] == '/')
94-
return path[prefix.Length..];
104+
if (path.StartsWith(prefix, StringComparison.Ordinal))
105+
if ((uint)prefix.Length < (uint)path.Length)
106+
if (path[prefix.Length] == '/')
107+
return path[prefix.Length..];
108+
109+
return null;
110+
}
111+
112+
/// <summary>
113+
/// Attempts to resolve a glob filter relative to a virtual path prefix,
114+
/// removing any prefix segments that match corresponding parts of the filter.
115+
/// </summary>
116+
/// <param name="prefix">The virtual path prefix representing the base of the current provider.</param>
117+
/// <param name="filter">The incoming glob filter that may include glob patterns.</param>
118+
/// <returns>
119+
/// A normalized filter value that can be safely passed to the wrapped file provider
120+
/// or <see langword="null" /> if the filter cannot be applied.
121+
/// </returns>
122+
/// <remarks>
123+
/// The goal is to determine whether a specified glob filter
124+
/// (e.g., "/modules/*/{assets,css,js}/**/*.{css,js}") applies to this provider, which is
125+
/// virtually mounted at a specific prefix path (e.g., "/modules/profile/assets").
126+
/// </remarks>
127+
private static string? ResolveGlobFilter(string prefix, string filter)
128+
{
129+
Debug.Assert(prefix == FilePath.Normalize(prefix));
130+
Debug.Assert(filter == FilePath.Normalize(filter));
131+
132+
var prefixSegments = new PathTokenizer(prefix).GetEnumerator();
133+
var filterSegments = new PathTokenizer(filter).GetEnumerator();
134+
135+
var list = new List<string>();
136+
137+
while (prefixSegments.MoveNext() && filterSegments.MoveNext())
138+
{
139+
var fs = filterSegments.Current;
140+
141+
// The globstar '**' matches any number of remaining segments, including none
142+
if (fs is "**")
143+
{
144+
// Add '**' and all remaining filter segments to the result.
145+
do
146+
{
147+
var segment = filterSegments.Current.ToString();
148+
list.Add(segment);
149+
}
150+
while (filterSegments.MoveNext());
151+
152+
return string.Join("/", list);
153+
}
154+
155+
if (fs is "*")
156+
{
157+
// '*' matches any prefix segment, continue matching.
158+
continue;
159+
}
160+
161+
if (Matcher.IsMatch(prefixSegments.Current, fs, MatchFlags.Unix))
162+
{
163+
// Segment matches the prefix segment, continue matching.
164+
continue;
165+
}
166+
167+
// Segment doesn't match the prefix at all.
168+
// This means the filter cannot be applied to the underlying provider.
169+
return null;
170+
}
171+
172+
if (!prefixSegments.MoveNext())
173+
{
174+
// All prefix segments have been matched and consumed successfully.
175+
// Append all remaining filter segments.
176+
while (filterSegments.MoveNext())
177+
list.Add(filterSegments.Current.ToString());
178+
179+
return string.Join("/", list);
180+
}
95181

182+
// Not all prefix segments were matched.
183+
// This means the filter cannot be applied to the underlying provider.
96184
return null;
97185
}
98186

src/Ramstack.FileProviders/Ramstack.FileProviders.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
<PrivateAssets>all</PrivateAssets>
6262
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
6363
</PackageReference>
64+
<PackageReference Include="Ramstack.Globbing" />
6465
</ItemGroup>
6566

6667
<ItemGroup>

tests/Ramstack.FileProviders.Tests/PrefixedFileProviderTests.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1+
using System.Reflection;
2+
13
using Ramstack.FileProviders.Utilities;
24

35
namespace Ramstack.FileProviders;
46

57
[TestFixture]
68
public sealed class PrefixedFileProviderTests : AbstractFileProviderTests
79
{
10+
private static readonly Func<string, string, string?> s_resolveGlobFilter =
11+
typeof(PrefixedFileProvider)
12+
.GetMethod("ResolveGlobFilter", BindingFlags.Static | BindingFlags.NonPublic)!
13+
.CreateDelegate<Func<string, string, string?>>();
14+
815
private const string Prefix = "solution/app";
916

1017
private readonly TempFileStorage _storage = new TempFileStorage(Prefix);
@@ -14,4 +21,20 @@ protected override IFileProvider GetFileProvider() =>
1421

1522
protected override DirectoryInfo GetDirectoryInfo() =>
1623
new DirectoryInfo(_storage.Root);
24+
25+
[TestCase("/modules/profile/assets", "/modules/**", ExpectedResult = "**")]
26+
[TestCase("/modules/profile/assets", "/modules/**/*.js", ExpectedResult = "**/*.js")]
27+
[TestCase("/modules/profile/assets", "/modules/profile/*/*.js", ExpectedResult = "*.js")]
28+
[TestCase("/modules/profile/assets", "/modules/profile/{js,css,assets}/*.js", ExpectedResult = "*.js")]
29+
[TestCase("/modules/profile/assets", "/modules/{settings,profile}/{js,css,assets}/*.js", ExpectedResult = "*.js")]
30+
[TestCase("/modules/profile/assets", "/modules/profile/assets/*.js", ExpectedResult = "*.js")]
31+
[TestCase("/modules/profile/assets", "/modules/profile/assets/js/jquery/*.js", ExpectedResult = "js/jquery/*.js")]
32+
[TestCase("/modules/profile/assets", "/modules/profiles/assets/*.js", ExpectedResult = null)]
33+
[TestCase("/modules/profile/assets", "/modules/profile/js/*.js", ExpectedResult = null)]
34+
[TestCase("/modules/profile/assets", "/module/profile/assets/*.js", ExpectedResult = null)]
35+
[TestCase("/modules/profile/assets", "/modules/profile/*.js", ExpectedResult = null)]
36+
[TestCase("/modules/profile/assets", "/modules/*.js", ExpectedResult = null)]
37+
[TestCase("/modules/profile/assets", "/*.js", ExpectedResult = null)]
38+
public string? ResolveGlobFilter(string prefix, string filter) =>
39+
s_resolveGlobFilter(prefix, filter);
1740
}

0 commit comments

Comments
 (0)