Skip to content

Commit e05ea39

Browse files
JamieMageeCopilot
andauthored
Replace DotNet.Glob with Microsoft.Extensions.FileSystemGlobbing (#1767)
* Replace DotNet.Glob with Microsoft.Extensions.FileSystemGlobbing Fixes #201. DotNet.Glob v2.1.1 throws IndexOutOfRangeException on ** patterns (e.g. **/samples/**). Replace it with Microsoft.Extensions.FileSystemGlobbing in all three call sites: - DetectorProcessingService: directory exclusion via --DirectoryExclusionList - YarnLockComponentDetector: workspace pattern matching - RustSbomDetector: Cargo workspace include/exclude rules FileSystemGlobbing's ** does not match zero trailing segments, so **/dir/** patterns get a companion **/dir pattern added in the directory exclusion predicate. Paths are normalized to forward slashes before matching, which replaces the DotNet.Glob-specific backslash escaping workaround. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback - Respect allowWindowsPaths flag: skip patterns containing backslashes when the flag is false, restoring the original behavior where backslash-based patterns don't match on non-Windows platforms - Remove unused rootPath variable in YarnLockComponentDetector - Fix stale XML doc on AddGlobRule (was claiming OS-dependent case sensitivity, but the code always uses OrdinalIgnoreCase) - Add test for trailing ** companion pattern workaround - Fix absolute path handling in directory exclusion predicate by stripping the root prefix before matching Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 97f3d73 commit e05ea39

7 files changed

Lines changed: 98 additions & 57 deletions

File tree

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.1" />
1818
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
1919
<PackageVersion Include="Microsoft.VisualStudio.Threading.Analyzers" Version="17.14.15" />
20-
<PackageVersion Include="DotNet.Glob" Version="2.1.1" />
20+
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="8.0.0" />
2121
<PackageVersion Include="MinVer" Version="7.0.0" />
2222
<PackageVersion Include="Moq" Version="4.18.4" />
2323
<PackageVersion Include="morelinq" Version="4.4.0" />

src/Microsoft.ComponentDetection.Detectors/Microsoft.ComponentDetection.Detectors.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
</ItemGroup>
1010

1111
<ItemGroup>
12-
<PackageReference Include="DotNet.Glob" />
12+
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" />
1313
<PackageReference Include="Microsoft.Extensions.Logging" />
1414
<PackageReference Include="morelinq" />
1515
<PackageReference Include="NuGet.ProjectModel" />

src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs

Lines changed: 15 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ namespace Microsoft.ComponentDetection.Detectors.Rust;
99
using System.Reactive.Threading.Tasks;
1010
using System.Threading;
1111
using System.Threading.Tasks;
12-
using global::DotNet.Globbing;
1312
using Microsoft.ComponentDetection.Common.Telemetry.Records;
1413
using Microsoft.ComponentDetection.Contracts;
1514
using Microsoft.ComponentDetection.Contracts.Internal;
1615
using Microsoft.ComponentDetection.Contracts.TypedComponent;
16+
using Microsoft.Extensions.FileSystemGlobbing;
1717
using Microsoft.Extensions.Logging;
1818
using Tomlyn;
1919
using Tomlyn.Model;
@@ -408,18 +408,18 @@ private bool ShouldSkip(string directory, FileKind fileKind, string fullPath)
408408

409409
var relativePath = this.GetRelativePath(rule.Root, normalizedDir);
410410

411-
// Match against include globs
412-
var matchesInclude = rule.IncludeGlobs.Any(g =>
413-
g.IsMatch(relativePath) || g.IsMatch(normalizedFullPath));
411+
// Match against include globs (try both relative and full path)
412+
var matchesInclude = rule.IncludeMatcher.Match(relativePath).HasMatches
413+
|| rule.IncludeMatcher.Match(normalizedFullPath).HasMatches;
414414

415415
if (!matchesInclude)
416416
{
417417
continue;
418418
}
419419

420420
// Match against exclude globs
421-
var matchesExclude = rule.ExcludeGlobs.Any(g =>
422-
g.IsMatch(relativePath) || g.IsMatch(normalizedFullPath));
421+
var matchesExclude = rule.ExcludeMatcher.Match(relativePath).HasMatches
422+
|| rule.ExcludeMatcher.Match(normalizedFullPath).HasMatches;
423423

424424
if (matchesExclude)
425425
{
@@ -441,7 +441,7 @@ private bool ShouldSkip(string directory, FileKind fileKind, string fullPath)
441441
/// <param name="excludes">Collection of glob patterns to exclude workspace members (e.g., "examples/*", "tests/*").</param>
442442
/// <remarks>
443443
/// This method normalizes all paths and patterns for cross-platform compatibility.
444-
/// On Windows, patterns are evaluated case-insensitively, while on other platforms they are case-sensitive.
444+
/// Patterns are always evaluated case-insensitively.
445445
/// The glob rule is used to determine whether files in descendant directories should be skipped during detection.
446446
/// </remarks>
447447
private void AddGlobRule(string root, IEnumerable<string> includes, IEnumerable<string> excludes)
@@ -450,35 +450,27 @@ private void AddGlobRule(string root, IEnumerable<string> includes, IEnumerable<
450450
var includesList = includes?.ToList() ?? [];
451451
var excludesList = excludes?.ToList() ?? [];
452452

453-
var globOptions = new GlobOptions
454-
{
455-
Evaluation = new EvaluationOptions
456-
{
457-
CaseInsensitive = true,
458-
},
459-
};
460-
461-
var includeGlobs = new List<Glob>();
453+
var includeMatcher = new Matcher(StringComparison.OrdinalIgnoreCase);
462454
foreach (var pattern in includesList)
463455
{
464456
var normalizedPattern = this.pathUtilityService.NormalizePath(pattern);
465-
includeGlobs.Add(Glob.Parse(normalizedPattern, globOptions));
457+
includeMatcher.AddInclude(normalizedPattern);
466458
}
467459

468-
var excludeGlobs = new List<Glob>();
460+
var excludeMatcher = new Matcher(StringComparison.OrdinalIgnoreCase);
469461
foreach (var pattern in excludesList)
470462
{
471463
var normalizedPattern = this.pathUtilityService.NormalizePath(pattern);
472-
excludeGlobs.Add(Glob.Parse(normalizedPattern, globOptions));
464+
excludeMatcher.AddInclude(normalizedPattern);
473465
}
474466

475467
var rule = new GlobRule
476468
{
477469
Root = normalizedRoot,
478470
Includes = includesList,
479471
Excludes = excludesList,
480-
IncludeGlobs = includeGlobs,
481-
ExcludeGlobs = excludeGlobs,
472+
IncludeMatcher = includeMatcher,
473+
ExcludeMatcher = excludeMatcher,
482474
};
483475

484476
this.visitedGlobRules.Add(rule);
@@ -774,8 +766,8 @@ private class GlobRule
774766

775767
public List<string> Excludes { get; set; }
776768

777-
public List<Glob> IncludeGlobs { get; set; }
769+
public Matcher IncludeMatcher { get; set; }
778770

779-
public List<Glob> ExcludeGlobs { get; set; }
771+
public Matcher ExcludeMatcher { get; set; }
780772
}
781773
}

src/Microsoft.ComponentDetection.Detectors/yarn/YarnLockComponentDetector.cs

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ namespace Microsoft.ComponentDetection.Detectors.Yarn;
88
using System.Runtime.InteropServices;
99
using System.Threading;
1010
using System.Threading.Tasks;
11-
using global::DotNet.Globbing;
1211
using Microsoft.ComponentDetection.Contracts;
1312
using Microsoft.ComponentDetection.Contracts.Internal;
1413
using Microsoft.ComponentDetection.Contracts.TypedComponent;
1514
using Microsoft.ComponentDetection.Detectors.Npm;
15+
using Microsoft.Extensions.FileSystemGlobbing;
1616
using Microsoft.Extensions.Logging;
1717

1818
public class YarnLockComponentDetector : FileComponentDetector
@@ -259,21 +259,24 @@ private bool TryReadPeerPackageJsonRequestsAsYarnEntries(ISingleFileComponentRec
259259

260260
private void GetWorkspaceDependencies(IList<string> yarnWorkspaces, DirectoryInfo root, IDictionary<string, IDictionary<string, bool>> dependencies, IDictionary<string, string> workspaceDependencyVsLocationMap)
261261
{
262-
var ignoreCase = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
263-
264-
var globOptions = new GlobOptions()
265-
{
266-
Evaluation = new EvaluationOptions()
267-
{
268-
CaseInsensitive = ignoreCase,
269-
},
270-
};
262+
var comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
263+
? StringComparison.OrdinalIgnoreCase
264+
: StringComparison.Ordinal;
271265

272266
foreach (var workspacePattern in yarnWorkspaces)
273267
{
274-
var glob = Glob.Parse($"{root.FullName.Replace('\\', '/')}/{workspacePattern}/package.json", globOptions);
268+
var matcher = new Matcher(comparison);
269+
matcher.AddInclude($"{workspacePattern}/package.json");
275270

276-
var componentStreams = this.ComponentStreamEnumerableFactory.GetComponentStreams(root, (file) => glob.IsMatch(file.FullName.Replace('\\', '/')), null, true);
271+
var componentStreams = this.ComponentStreamEnumerableFactory.GetComponentStreams(
272+
root,
273+
(file) =>
274+
{
275+
var relativePath = Path.GetRelativePath(root.FullName, file.FullName).Replace('\\', '/');
276+
return matcher.Match(relativePath).HasMatches;
277+
},
278+
null,
279+
true);
277280

278281
foreach (var stream in componentStreams)
279282
{

src/Microsoft.ComponentDetection.Orchestrator/Microsoft.ComponentDetection.Orchestrator.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
<ItemGroup>
1111
<PackageReference Include="CommandLineParser" />
12-
<PackageReference Include="DotNet.Glob" />
12+
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" />
1313
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
1414
<PackageReference Include="Microsoft.Extensions.Logging" />
1515
<PackageReference Include="Newtonsoft.Json" />

src/Microsoft.ComponentDetection.Orchestrator/Services/DetectorProcessingService.cs

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,14 @@ namespace Microsoft.ComponentDetection.Orchestrator.Services;
1010
using System.Text.Json;
1111
using System.Threading;
1212
using System.Threading.Tasks;
13-
using DotNet.Globbing;
1413
using Microsoft.ComponentDetection.Common;
1514
using Microsoft.ComponentDetection.Common.DependencyGraph;
1615
using Microsoft.ComponentDetection.Common.Telemetry.Records;
1716
using Microsoft.ComponentDetection.Contracts;
1817
using Microsoft.ComponentDetection.Contracts.BcdeModels;
1918
using Microsoft.ComponentDetection.Orchestrator.Commands;
2019
using Microsoft.ComponentDetection.Orchestrator.Experiments;
20+
using Microsoft.Extensions.FileSystemGlobbing;
2121
using Microsoft.Extensions.Logging;
2222
using Spectre.Console;
2323
using static System.Environment;
@@ -249,35 +249,49 @@ public ExcludeDirectoryPredicate GenerateDirectoryExclusionPredicate(string orig
249249
};
250250
}
251251

252-
var minimatchers = new Dictionary<string, Glob>();
252+
var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
253+
var matcher = new Matcher(comparison);
253254

254-
var globOptions = new GlobOptions()
255+
foreach (var directoryExclusion in directoryExclusionList)
255256
{
256-
Evaluation = new EvaluationOptions()
257+
if (!allowWindowsPaths && directoryExclusion.Contains('\\'))
257258
{
258-
CaseInsensitive = ignoreCase,
259-
},
260-
};
259+
this.logger.LogDebug("Skipping directory exclusion pattern {Pattern} because it contains backslashes and Windows-style paths are not enabled.", directoryExclusion);
260+
continue;
261+
}
261262

262-
foreach (var directoryExclusion in directoryExclusionList)
263-
{
264-
minimatchers.Add(directoryExclusion, Glob.Parse(allowWindowsPaths ? directoryExclusion : /* [] escapes special chars */ directoryExclusion.Replace("\\", "[\\]"), globOptions));
263+
var pattern = directoryExclusion.Replace('\\', '/');
264+
matcher.AddInclude(pattern);
265+
266+
// FileSystemGlobbing's ** does not match zero trailing segments,
267+
// so **/dir/** won't match "dir" itself. Add **/dir to cover that case.
268+
if (pattern.EndsWith("/**"))
269+
{
270+
matcher.AddInclude(pattern[..^3]);
271+
}
265272
}
266273

267274
return (name, directoryName) =>
268275
{
269-
var path = Path.Combine(directoryName.ToString(), name.ToString());
276+
var path = Path.Combine(directoryName.ToString(), name.ToString()).Replace('\\', '/');
270277

271-
return minimatchers.Any(minimatcherKeyValue =>
278+
// FileSystemGlobbing requires relative paths for matching.
279+
// Strip the leading slash (or drive letter on Windows) so that
280+
// patterns like **/dir/** can match against the full directory path.
281+
var relativePath = path.StartsWith('/') ? path[1..] : path;
282+
if (relativePath.Length > 1 && relativePath[1] == ':')
272283
{
273-
if (minimatcherKeyValue.Value.IsMatch(path))
274-
{
275-
this.logger.LogDebug("Excluding folder {Path} because it matched glob {Glob}.", path, minimatcherKeyValue.Key);
276-
return true;
277-
}
284+
// Windows drive letter, e.g. "C:/foo" → "foo"
285+
relativePath = relativePath[3..];
286+
}
278287

279-
return false;
280-
});
288+
if (matcher.Match(relativePath).HasMatches)
289+
{
290+
this.logger.LogDebug("Excluding folder {Path} because it matched a directory exclusion glob.", path);
291+
return true;
292+
}
293+
294+
return false;
281295
};
282296
}
283297

test/Microsoft.ComponentDetection.Orchestrator.Tests/Services/DetectorProcessingServiceTests.cs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,38 @@ public void GenerateDirectoryExclusionPredicate_IgnoreCaseAndAllowWindowsPathsWo
419419
exclusionPredicate(dn, dp).Should().BeTrue();
420420
}
421421

422+
[TestMethod]
423+
public void GenerateDirectoryExclusionPredicate_TrailingDoubleStarMatchesDirectoryItself()
424+
{
425+
// FileSystemGlobbing's ** does not match zero trailing segments,
426+
// so the implementation adds a companion pattern (**/dir) alongside **/dir/**.
427+
// This test verifies that the directory itself is excluded, not just its children.
428+
var args = new ScanSettings
429+
{
430+
SourceDirectory = new DirectoryInfo(this.isWin ? @"C:\project" : "/tmp/project"),
431+
DetectorArgs = new Dictionary<string, string>(),
432+
DirectoryExclusionList = ["**/Source/**"],
433+
};
434+
435+
var exclusionPredicate = this.serviceUnderTest.GenerateDirectoryExclusionPredicate(
436+
args.SourceDirectory.FullName,
437+
args.DirectoryExclusionList,
438+
args.DirectoryExclusionListObsolete,
439+
allowWindowsPaths: true,
440+
ignoreCase: true);
441+
442+
// The directory itself (no trailing segment) should be excluded
443+
var projectPath = this.isWin ? @"C:\project" : "/tmp/project";
444+
exclusionPredicate("Source", projectPath).Should().BeTrue();
445+
446+
// A child under the directory should also be excluded
447+
var sourcePath = this.isWin ? @"C:\project\Source" : "/tmp/project/Source";
448+
exclusionPredicate("child", sourcePath).Should().BeTrue();
449+
450+
// An unrelated directory should not be excluded
451+
exclusionPredicate("Other", projectPath).Should().BeFalse();
452+
}
453+
422454
[TestMethod]
423455
public async Task ProcessDetectorsAsync_DirectoryExclusionPredicateWorksAsExpectedForObsoleteAsync()
424456
{

0 commit comments

Comments
 (0)