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)); + } +}