diff --git a/Directory.Packages.props b/Directory.Packages.props
index 267fd58de..2f684b150 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -24,6 +24,7 @@
+
diff --git a/src/Microsoft.Sbom.Api/Config/Args/ValidationArgs.cs b/src/Microsoft.Sbom.Api/Config/Args/ValidationArgs.cs
index 52d25603b..742c19856 100644
--- a/src/Microsoft.Sbom.Api/Config/Args/ValidationArgs.cs
+++ b/src/Microsoft.Sbom.Api/Config/Args/ValidationArgs.cs
@@ -71,8 +71,9 @@ public class ValidationArgs : GenerationAndValidationCommonArgs
/// Gets or sets if you're downloading only a part of the drop using the '-r' or 'root' parameter
/// in the drop client, specify the same string value here in order to skip
/// validating paths that are not downloaded.
+ /// Supports both path prefixes (legacy) and glob-style patterns (* and **) for flexible file path matching.
///
- [ArgDescription(@"If you're downloading only a part of the drop using the '-r' or 'root' parameter in the drop client, specify the same string value here in order to skip validating paths that are not downloaded.")]
+ [ArgDescription(@"If you're downloading only a part of the drop using the '-r' or 'root' parameter in the drop client, specify the same string value here in order to skip validating paths that are not downloaded. Supports both path prefixes (legacy) and glob-style patterns (* and **) for flexible file path matching.")]
[ArgShortcut("r")]
public string RootPathFilter { get; set; }
diff --git a/src/Microsoft.Sbom.Api/Filters/DownloadedRootPathFilter.cs b/src/Microsoft.Sbom.Api/Filters/DownloadedRootPathFilter.cs
index 34f944242..d10ae117e 100644
--- a/src/Microsoft.Sbom.Api/Filters/DownloadedRootPathFilter.cs
+++ b/src/Microsoft.Sbom.Api/Filters/DownloadedRootPathFilter.cs
@@ -4,7 +4,7 @@
using System;
using System.Collections.Generic;
using System.IO;
-using System.Linq;
+using Microsoft.Sbom.Api.Utils;
using Microsoft.Sbom.Common;
using Microsoft.Sbom.Common.Config;
using Serilog;
@@ -23,6 +23,7 @@ public class DownloadedRootPathFilter : IFilter
private bool skipValidation;
private HashSet validPaths;
+ private List patterns;
public DownloadedRootPathFilter(
IConfiguration configuration,
@@ -37,12 +38,13 @@ public DownloadedRootPathFilter(
}
///
- /// Returns true if filePath is present in root path filters.
+ /// Returns true if filePath is present in root path filters or matches root path patterns.
///
/// For example, say filePath is /root/parent1/parent2/child1/child2.txt, then if the root path
/// filters contains /root/parent1/ or /root/parent1/parent2/ in it, this filePath with return true,
/// but if the root path contains /root/parent3/, this filePath will return false.
///
+ /// If glob patterns are detected in RootPathFilter, the filePath will be matched against glob-style patterns instead.
///
/// The file path to validate.
public bool IsValid(string filePath)
@@ -57,6 +59,48 @@ public bool IsValid(string filePath)
return false;
}
+ // If patterns are configured, use pattern matching
+ if (patterns != null && patterns.Count > 0)
+ {
+ return IsValidWithPatterns(filePath);
+ }
+
+ // Fall back to legacy path prefix matching
+ return IsValidWithPathPrefix(filePath);
+ }
+
+ ///
+ /// Validates file path using glob-style patterns.
+ ///
+ /// The file path to validate.
+ /// True if the path matches any pattern, false otherwise.
+ private bool IsValidWithPatterns(string filePath)
+ {
+ var buildDropPath = configuration.BuildDropPath?.Value;
+
+ foreach (var pattern in patterns)
+ {
+ if (PathPatternMatcher.IsMatch(filePath, pattern, buildDropPath))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Validates file path using the legacy path prefix approach.
+ ///
+ /// The file path to validate.
+ /// True if the path starts with any valid path prefix, false otherwise.
+ private bool IsValidWithPathPrefix(string filePath)
+ {
+ if (validPaths == null || validPaths.Count == 0)
+ {
+ return false;
+ }
+
var isValid = false;
var normalizedPath = new FileInfo(filePath).FullName;
@@ -69,7 +113,23 @@ public bool IsValid(string filePath)
}
///
- /// Initializes the root path filters list.
+ /// Checks if a string contains glob patterns.
+ /// Only checks for patterns supported by .NET FileSystemGlobbing: * and **.
+ ///
+ /// The string to check.
+ /// True if the string contains glob patterns, false otherwise.
+ private static bool ContainsGlobPatterns(string value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return false;
+ }
+
+ return value.Contains('*');
+ }
+
+ ///
+ /// Initializes the root path filters list or patterns.
///
public void Init()
{
@@ -78,17 +138,51 @@ public void Init()
if (configuration.RootPathFilter != null && !string.IsNullOrWhiteSpace(configuration.RootPathFilter.Value))
{
- skipValidation = false;
- validPaths = new HashSet();
- var relativeRootPaths = configuration.RootPathFilter.Value.Split(';');
+ var rootFilterValue = configuration.RootPathFilter.Value;
- validPaths.UnionWith(relativeRootPaths.Select(r =>
- new FileInfo(fileSystemUtils.JoinPaths(configuration.BuildDropPath.Value, r))
- .FullName));
+ // Check if the RootPathFilter contains glob patterns
+ if (ContainsGlobPatterns(rootFilterValue))
+ {
+ // Use pattern matching
+ patterns = new List();
+ var patternStrings = rootFilterValue.Split(';');
- foreach (var validPath in validPaths)
+ foreach (var pattern in patternStrings)
+ {
+ var trimmedPattern = pattern.Trim();
+ if (!string.IsNullOrEmpty(trimmedPattern))
+ {
+ patterns.Add(trimmedPattern);
+ logger.Verbose($"Added pattern {trimmedPattern}");
+ }
+ }
+
+ // Enable validation if we have valid patterns
+ skipValidation = patterns.Count == 0;
+ }
+ else
{
- logger.Verbose($"Added valid path {validPath}");
+ // Use legacy path prefix matching
+ validPaths = new HashSet();
+ var relativeRootPaths = rootFilterValue.Split(';');
+
+ foreach (var relativePath in relativeRootPaths)
+ {
+ var trimmedPath = relativePath.Trim();
+ if (!string.IsNullOrEmpty(trimmedPath))
+ {
+ // Check if BuildDropPath is available for legacy path prefix matching
+ if (configuration.BuildDropPath?.Value != null)
+ {
+ var fullPath = new FileInfo(fileSystemUtils.JoinPaths(configuration.BuildDropPath.Value, trimmedPath)).FullName;
+ validPaths.Add(fullPath);
+ logger.Verbose($"Added valid path {fullPath}");
+ }
+ }
+ }
+
+ // Enable validation if we have valid paths
+ skipValidation = validPaths.Count == 0;
}
}
}
diff --git a/src/Microsoft.Sbom.Api/Microsoft.Sbom.Api.csproj b/src/Microsoft.Sbom.Api/Microsoft.Sbom.Api.csproj
index 4adac9af5..c1f94d7d1 100644
--- a/src/Microsoft.Sbom.Api/Microsoft.Sbom.Api.csproj
+++ b/src/Microsoft.Sbom.Api/Microsoft.Sbom.Api.csproj
@@ -15,6 +15,7 @@
+
diff --git a/src/Microsoft.Sbom.Api/Utils/PathPatternMatcher.cs b/src/Microsoft.Sbom.Api/Utils/PathPatternMatcher.cs
new file mode 100644
index 000000000..cedb65518
--- /dev/null
+++ b/src/Microsoft.Sbom.Api/Utils/PathPatternMatcher.cs
@@ -0,0 +1,202 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System;
+using System.IO;
+using Microsoft.Extensions.FileSystemGlobbing;
+
+namespace Microsoft.Sbom.Api.Utils;
+
+///
+/// Provides pattern matching functionality for file paths using glob-style patterns.
+/// Leverages .NET's built-in Microsoft.Extensions.FileSystemGlobbing for robust cross-platform support.
+///
+/// Supported patterns:
+/// - * matches zero or more characters (excluding directory separators)
+/// - ** matches zero or more characters (including directory separators)
+///
+/// Note: The ? wildcard for single character matching is not supported by the underlying .NET implementation.
+///
+public static class PathPatternMatcher
+{
+ ///
+ /// Checks if a file path matches a glob-style pattern.
+ /// Uses .NET's built-in globbing which supports * and ** patterns but not ? for single character matching.
+ ///
+ /// The file path to check.
+ /// The glob pattern to match against.
+ /// The base path to resolve relative patterns.
+ /// True if the path matches the pattern, false otherwise.
+ public static bool IsMatch(string filePath, string pattern, string basePath = null)
+ {
+ if (string.IsNullOrEmpty(filePath) || string.IsNullOrEmpty(pattern))
+ {
+ return false;
+ }
+
+ try
+ {
+ // Normalize paths for cross-platform compatibility
+ var normalizedFilePath = NormalizePath(filePath);
+ var normalizedPattern = NormalizePath(pattern);
+
+ // Use case-insensitive matching for cross-platform compatibility
+ var matcher = new Matcher(StringComparison.OrdinalIgnoreCase);
+ matcher.AddInclude(normalizedPattern);
+
+ // Determine the path to match against
+ string pathToMatch;
+ if (!string.IsNullOrEmpty(basePath) && !Path.IsPathRooted(normalizedPattern))
+ {
+ // For relative patterns with base path, get the relative path
+ if (!IsPathWithinBase(normalizedFilePath, basePath))
+ {
+ return false;
+ }
+
+ pathToMatch = GetRelativePath(basePath, normalizedFilePath);
+ }
+ else
+ {
+ // For absolute patterns or no base path, use the normalized full path
+ pathToMatch = normalizedFilePath;
+ }
+
+ // Use the matcher to check if the path matches the pattern
+ var result = matcher.Match(pathToMatch);
+ return result.HasMatches;
+ }
+ catch
+ {
+ // If any exception occurs during matching, return false
+ return false;
+ }
+ }
+
+ ///
+ /// Checks if a file path is within the specified base path.
+ ///
+ /// The file path to check.
+ /// The base path.
+ /// True if the file path is within the base path, false otherwise.
+ private static bool IsPathWithinBase(string filePath, string basePath)
+ {
+ try
+ {
+ // Normalize paths for cross-platform compatibility
+ var normalizedFilePath = NormalizePath(filePath);
+ var normalizedBasePath = NormalizePath(basePath);
+
+ // For Windows-style paths on non-Windows systems, use manual comparison
+ if (IsWindowsStylePath(normalizedBasePath) && IsWindowsStylePath(normalizedFilePath))
+ {
+ var normalizedBase = normalizedBasePath.TrimEnd('/') + "/";
+ return normalizedFilePath.StartsWith(normalizedBase, StringComparison.OrdinalIgnoreCase);
+ }
+
+ // Use Path.GetFullPath for proper platform-specific handling
+ var fullFilePath = Path.GetFullPath(normalizedFilePath);
+ var fullBasePath = Path.GetFullPath(normalizedBasePath);
+
+ // Ensure base path ends with separator for proper prefix check
+ if (!fullBasePath.EndsWith(Path.DirectorySeparatorChar) &&
+ !fullBasePath.EndsWith(Path.AltDirectorySeparatorChar))
+ {
+ fullBasePath += Path.DirectorySeparatorChar;
+ }
+
+ return fullFilePath.StartsWith(fullBasePath, StringComparison.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ // Fallback: manual comparison with normalized paths
+ var normalizedFilePath = NormalizePath(filePath);
+ var normalizedBasePath = NormalizePath(basePath).TrimEnd('/') + "/";
+ return normalizedFilePath.StartsWith(normalizedBasePath, StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ ///
+ /// Gets the relative path from a base path to a target path.
+ ///
+ /// The base path.
+ /// The target path.
+ /// The relative path from base to target.
+ private static string GetRelativePath(string basePath, string targetPath)
+ {
+ try
+ {
+ // Normalize paths for cross-platform compatibility
+ var normalizedBasePath = NormalizePath(basePath);
+ var normalizedTargetPath = NormalizePath(targetPath);
+
+ // For cross-platform compatibility, handle Windows-style paths manually
+ if (IsWindowsStylePath(normalizedBasePath) && IsWindowsStylePath(normalizedTargetPath))
+ {
+ return GetRelativePathManual(normalizedBasePath, normalizedTargetPath);
+ }
+
+ // Use Path.GetRelativePath for Unix-style or when both paths are in the same format
+ var fullBasePath = Path.GetFullPath(normalizedBasePath);
+ var fullTargetPath = Path.GetFullPath(normalizedTargetPath);
+ return Path.GetRelativePath(fullBasePath, fullTargetPath);
+ }
+ catch
+ {
+ // Fallback: manual calculation
+ return GetRelativePathManual(basePath, targetPath);
+ }
+ }
+
+ ///
+ /// Normalizes a path by converting backslashes to forward slashes.
+ ///
+ /// The path to normalize.
+ /// The normalized path.
+ private static string NormalizePath(string path)
+ {
+ if (string.IsNullOrEmpty(path))
+ {
+ return path;
+ }
+
+ return path.Replace('\\', '/');
+ }
+
+ ///
+ /// Checks if a path is in Windows style (has a drive letter).
+ ///
+ /// The path to check.
+ /// True if the path is Windows-style, false otherwise.
+ private static bool IsWindowsStylePath(string path)
+ {
+ return !string.IsNullOrEmpty(path) &&
+ path.Length >= 2 &&
+ char.IsLetter(path[0]) &&
+ path[1] == ':';
+ }
+
+ ///
+ /// Manually calculates relative path for cross-platform compatibility.
+ ///
+ /// The base path.
+ /// The target path.
+ /// The relative path.
+ private static string GetRelativePathManual(string basePath, string targetPath)
+ {
+ if (string.IsNullOrEmpty(basePath) || string.IsNullOrEmpty(targetPath))
+ {
+ return targetPath;
+ }
+
+ var normalizedBase = NormalizePath(basePath).TrimEnd('/') + "/";
+ var normalizedTarget = NormalizePath(targetPath);
+
+ if (normalizedTarget.StartsWith(normalizedBase, StringComparison.OrdinalIgnoreCase))
+ {
+ return normalizedTarget.Substring(normalizedBase.Length);
+ }
+
+ return normalizedTarget;
+ }
+}
diff --git a/src/Microsoft.Sbom.Common/Config/IConfiguration.cs b/src/Microsoft.Sbom.Common/Config/IConfiguration.cs
index 61ddff52f..04dfeb885 100644
--- a/src/Microsoft.Sbom.Common/Config/IConfiguration.cs
+++ b/src/Microsoft.Sbom.Common/Config/IConfiguration.cs
@@ -80,6 +80,7 @@ public interface IConfiguration
/// Gets or sets if you're downloading only a part of the drop using the '-r' or 'root' parameter
/// in the drop client, specify the same string value here in order to skip
/// validating paths that are not downloaded.
+ /// Supports both path prefixes (legacy) and glob-style patterns (* and **) for flexible file path matching.
///
public ConfigurationSetting RootPathFilter { get; set; }
diff --git a/test/Microsoft.Sbom.Api.Tests/Filters/DownloadedRootPathFilterTests.cs b/test/Microsoft.Sbom.Api.Tests/Filters/DownloadedRootPathFilterTests.cs
index 1c1a918c2..fddf0b25e 100644
--- a/test/Microsoft.Sbom.Api.Tests/Filters/DownloadedRootPathFilterTests.cs
+++ b/test/Microsoft.Sbom.Api.Tests/Filters/DownloadedRootPathFilterTests.cs
@@ -55,4 +55,95 @@ public void DownloadedRootPathFilterTest_FilterPath_Succeeds()
fileSystemMock.VerifyAll();
configMock.VerifyAll();
}
+
+ [TestMethod]
+ public void DownloadedRootPathFilterTest_PatternFiltering_Succeeds()
+ {
+ var fileSystemMock = new Mock();
+
+ var configMock = new Mock();
+ configMock.SetupGet(c => c.BuildDropPath).Returns(new ConfigurationSetting { Value = "C:/test" });
+ configMock.SetupGet(c => c.RootPathFilter).Returns(new ConfigurationSetting { Value = "src/**/*.cs;bin/*.dll" });
+
+ var filter = new DownloadedRootPathFilter(configMock.Object, fileSystemMock.Object, logger.Object);
+ filter.Init();
+
+ // Should match pattern src/**/*.cs
+ Assert.IsTrue(filter.IsValid("C:/test/src/component/file.cs"));
+ Assert.IsTrue(filter.IsValid("C:/test/src/deep/nested/component/file.cs"));
+
+ // Should match pattern bin/*.dll
+ Assert.IsTrue(filter.IsValid("C:/test/bin/app.dll"));
+
+ // Should not match patterns
+ Assert.IsFalse(filter.IsValid("C:/test/lib/component.dll"));
+ Assert.IsFalse(filter.IsValid("C:/test/src/component/file.txt"));
+ Assert.IsFalse(filter.IsValid("C:/test/bin/nested/app.dll"));
+ Assert.IsFalse(filter.IsValid(null));
+
+ fileSystemMock.VerifyAll();
+ configMock.VerifyAll();
+ }
+
+ [TestMethod]
+ public void DownloadedRootPathFilterTest_LegacyPathStillWorks_Succeeds()
+ {
+ var fileSystemMock = new Mock();
+
+ var configMock = new Mock();
+ configMock.SetupGet(c => c.BuildDropPath).Returns(new ConfigurationSetting { Value = "C:/test" });
+ configMock.SetupGet(c => c.RootPathFilter).Returns(new ConfigurationSetting { Value = "src/*.cs" });
+
+ var filter = new DownloadedRootPathFilter(configMock.Object, fileSystemMock.Object, logger.Object);
+ filter.Init();
+
+ // Should use pattern matching since glob patterns are detected
+ Assert.IsTrue(filter.IsValid("C:/test/src/file.cs"));
+ Assert.IsFalse(filter.IsValid("C:/test/src/nested/file.cs")); // Doesn't match the pattern
+
+ // Pattern matching doesn't use JoinPaths, so we don't call VerifyAll on fileSystemMock
+ configMock.VerifyAll();
+ }
+
+ [TestMethod]
+ public void DownloadedRootPathFilterTest_EmptyPattern_SkipsValidation()
+ {
+ var fileSystemMock = new Mock();
+
+ var configMock = new Mock();
+ configMock.SetupGet(c => c.RootPathFilter).Returns(new ConfigurationSetting { Value = " ; ; " }); // Only whitespace and separators
+
+ var filter = new DownloadedRootPathFilter(configMock.Object, fileSystemMock.Object, logger.Object);
+ filter.Init();
+
+ // Should skip validation since no valid patterns or paths are provided
+ Assert.IsTrue(filter.IsValid("any/path/should/pass"));
+ Assert.IsTrue(filter.IsValid(null));
+
+ fileSystemMock.VerifyAll();
+ configMock.VerifyAll();
+ }
+
+ [TestMethod]
+ public void DownloadedRootPathFilterTest_LegacyPathPrefix_Succeeds()
+ {
+ var fileSystemMock = new Mock();
+ fileSystemMock.Setup(f => f.JoinPaths(It.IsAny(), It.IsAny())).Returns((string r, string p) => $"{r}/{p}");
+
+ var configMock = new Mock();
+ configMock.SetupGet(c => c.BuildDropPath).Returns(new ConfigurationSetting { Value = "C:/test" });
+ configMock.SetupGet(c => c.RootPathFilter).Returns(new ConfigurationSetting { Value = "src;bin" }); // No glob patterns, should use legacy mode
+
+ var filter = new DownloadedRootPathFilter(configMock.Object, fileSystemMock.Object, logger.Object);
+ filter.Init();
+
+ // Should use legacy path prefix matching
+ Assert.IsTrue(filter.IsValid("c:/test/src/anything"));
+ Assert.IsTrue(filter.IsValid("c:/test/bin/anything"));
+ Assert.IsFalse(filter.IsValid("c:/test/lib/anything"));
+ Assert.IsFalse(filter.IsValid(null));
+
+ fileSystemMock.VerifyAll();
+ configMock.VerifyAll();
+ }
}
diff --git a/test/Microsoft.Sbom.Api.Tests/Utils/PathPatternMatcherTests.cs b/test/Microsoft.Sbom.Api.Tests/Utils/PathPatternMatcherTests.cs
new file mode 100644
index 000000000..13c5d1ae7
--- /dev/null
+++ b/test/Microsoft.Sbom.Api.Tests/Utils/PathPatternMatcherTests.cs
@@ -0,0 +1,131 @@
+// Copyright (c) Microsoft. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Microsoft.Sbom.Api.Utils;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.Sbom.Api.Tests.Utils;
+
+[TestClass]
+public class PathPatternMatcherTests
+{
+ [TestMethod]
+ public void PathPatternMatcher_SingleWildcard_MatchesCorrectly()
+ {
+ var basePath = @"C:\test";
+ var pattern = @"src\*\file.txt";
+
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\src\component\file.txt", pattern, basePath));
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\src\another\file.txt", pattern, basePath));
+ Assert.IsFalse(PathPatternMatcher.IsMatch(@"C:\test\src\component\sub\file.txt", pattern, basePath));
+ Assert.IsFalse(PathPatternMatcher.IsMatch(@"C:\test\other\component\file.txt", pattern, basePath));
+ }
+
+ [TestMethod]
+ public void PathPatternMatcher_DoubleWildcard_MatchesRecursively()
+ {
+ var basePath = @"C:\test";
+ var pattern = @"src\**\*.txt";
+
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\src\file.txt", pattern, basePath));
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\src\component\file.txt", pattern, basePath));
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\src\component\sub\deep\file.txt", pattern, basePath));
+ Assert.IsFalse(PathPatternMatcher.IsMatch(@"C:\test\other\file.txt", pattern, basePath));
+ }
+
+ [TestMethod]
+ public void PathPatternMatcher_NoBasePath_MatchesAbsolutePath()
+ {
+ var pattern = @"C:\test\src\*.txt";
+
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\src\file.txt", pattern));
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\src\another.txt", pattern));
+ Assert.IsFalse(PathPatternMatcher.IsMatch(@"C:\test\src\sub\file.txt", pattern));
+ Assert.IsFalse(PathPatternMatcher.IsMatch(@"C:\other\src\file.txt", pattern));
+ }
+
+ [TestMethod]
+ public void PathPatternMatcher_UnixPaths_MatchesCorrectly()
+ {
+ var basePath = "/usr/local";
+ var pattern = "bin/*";
+
+ Assert.IsTrue(PathPatternMatcher.IsMatch("/usr/local/bin/myapp", pattern, basePath));
+ Assert.IsTrue(PathPatternMatcher.IsMatch("/usr/local/bin/tool", pattern, basePath));
+ Assert.IsFalse(PathPatternMatcher.IsMatch("/usr/local/bin/sub/tool", pattern, basePath));
+ Assert.IsFalse(PathPatternMatcher.IsMatch("/usr/local/lib/myapp", pattern, basePath));
+ }
+
+ [TestMethod]
+ public void PathPatternMatcher_MixedPathSeparators_MatchesCorrectly()
+ {
+ var basePath = @"C:\test";
+ var pattern = "src/component/*.txt"; // Using forward slashes in pattern
+
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\src\component\file.txt", pattern, basePath));
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:/test/src/component/file.txt", pattern, basePath));
+ Assert.IsFalse(PathPatternMatcher.IsMatch(@"C:\test\src\other\file.txt", pattern, basePath));
+ }
+
+ [TestMethod]
+ public void PathPatternMatcher_SingleCharacterPatterns_NotSupported()
+ {
+ // Note: The .NET FileSystemGlobbing library does not support ? wildcard for single character matching
+ // This is a known limitation of the underlying implementation
+ var basePath = @"C:\test";
+ var pattern = @"file*.txt"; // Use * instead of ? for broader matching
+
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\file1.txt", pattern, basePath));
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\filea.txt", pattern, basePath));
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\file12.txt", pattern, basePath));
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\file.txt", pattern, basePath));
+ }
+
+ [TestMethod]
+ public void PathPatternMatcher_EmptyOrNullInputs_ReturnsFalse()
+ {
+ Assert.IsFalse(PathPatternMatcher.IsMatch(null, "pattern"));
+ Assert.IsFalse(PathPatternMatcher.IsMatch(string.Empty, "pattern"));
+ Assert.IsFalse(PathPatternMatcher.IsMatch("path", null));
+ Assert.IsFalse(PathPatternMatcher.IsMatch("path", string.Empty));
+ Assert.IsFalse(PathPatternMatcher.IsMatch(null, null));
+ }
+
+ [TestMethod]
+ public void PathPatternMatcher_CaseInsensitive_MatchesCorrectly()
+ {
+ var basePath = @"C:\test";
+ var pattern = "SRC/*.TXT";
+
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\src\file.txt", pattern, basePath));
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\SRC\FILE.TXT", pattern, basePath));
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\Src\File.Txt", pattern, basePath));
+ }
+
+ [TestMethod]
+ public void PathPatternMatcher_ComplexPattern_MatchesCorrectly()
+ {
+ var basePath = @"C:\workspace";
+ var pattern = @"project\**\bin\*.dll";
+
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\workspace\project\Debug\bin\app.dll", pattern, basePath));
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\workspace\project\Release\bin\lib.dll", pattern, basePath));
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\workspace\project\x64\Debug\bin\test.dll", pattern, basePath));
+ Assert.IsFalse(PathPatternMatcher.IsMatch(@"C:\workspace\project\bin\app.exe", pattern, basePath));
+ Assert.IsFalse(PathPatternMatcher.IsMatch(@"C:\workspace\other\bin\app.dll", pattern, basePath));
+ }
+
+ [TestMethod]
+ public void PathPatternMatcher_DoubleWildcard_MatchesZeroDirectories()
+ {
+ var basePath = @"C:\test";
+ var pattern = @"src\**\*.txt";
+
+ // Test that ** matches zero directories (direct file in src folder)
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\src\file.txt", pattern, basePath));
+
+ // Test that ** also matches one or more directories
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\src\component\file.txt", pattern, basePath));
+ Assert.IsTrue(PathPatternMatcher.IsMatch(@"C:\test\src\component\sub\file.txt", pattern, basePath));
+ }
+}