Skip to content
Draft
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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<PackageVersion Include="Microsoft.ComponentDetection.Orchestrator" Version="$(ComponentDetectionPackageVersion)" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="9.0.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
Expand Down
3 changes: 2 additions & 1 deletion src/Microsoft.Sbom.Api/Config/Args/ValidationArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
[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; }

Expand Down
116 changes: 105 additions & 11 deletions src/Microsoft.Sbom.Api/Filters/DownloadedRootPathFilter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,6 +23,7 @@ public class DownloadedRootPathFilter : IFilter<DownloadedRootPathFilter>

private bool skipValidation;
private HashSet<string> validPaths;
private List<string> patterns;

public DownloadedRootPathFilter(
IConfiguration configuration,
Expand All @@ -37,12 +38,13 @@ public DownloadedRootPathFilter(
}

/// <summary>
/// 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.
/// </summary>
/// <param name="filePath">The file path to validate.</param>
public bool IsValid(string filePath)
Expand All @@ -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);
}

/// <summary>
/// Validates file path using glob-style patterns.
/// </summary>
/// <param name="filePath">The file path to validate.</param>
/// <returns>True if the path matches any pattern, false otherwise.</returns>
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;
}

/// <summary>
/// Validates file path using the legacy path prefix approach.
/// </summary>
/// <param name="filePath">The file path to validate.</param>
/// <returns>True if the path starts with any valid path prefix, false otherwise.</returns>
private bool IsValidWithPathPrefix(string filePath)
{
if (validPaths == null || validPaths.Count == 0)
{
return false;
}

var isValid = false;
var normalizedPath = new FileInfo(filePath).FullName;

Expand All @@ -69,7 +113,23 @@ public bool IsValid(string filePath)
}

/// <summary>
/// Initializes the root path filters list.
/// Checks if a string contains glob patterns.
/// Only checks for patterns supported by .NET FileSystemGlobbing: * and **.
/// </summary>
/// <param name="value">The string to check.</param>
/// <returns>True if the string contains glob patterns, false otherwise.</returns>
private static bool ContainsGlobPatterns(string value)
{
if (string.IsNullOrEmpty(value))
{
return false;
}

return value.Contains('*');
}

/// <summary>
/// Initializes the root path filters list or patterns.
/// </summary>
public void Init()
{
Expand All @@ -78,17 +138,51 @@ public void Init()

if (configuration.RootPathFilter != null && !string.IsNullOrWhiteSpace(configuration.RootPathFilter.Value))
{
skipValidation = false;
validPaths = new HashSet<string>();
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<string>();
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<string>();
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;
}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/Microsoft.Sbom.Api/Microsoft.Sbom.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<PackageReference Include="Microsoft.ComponentDetection.Orchestrator" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" />
<PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="NuGet.Configuration" />
<PackageReference Include="packageurl-dotnet" />
Expand Down
202 changes: 202 additions & 0 deletions src/Microsoft.Sbom.Api/Utils/PathPatternMatcher.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
public static class PathPatternMatcher
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.NET already provides cross-platform support for globbing that we should leverage unless there's some reason that it doesn't do what we need it to do. See https://learn.microsoft.com/en-us/dotnet/core/extensions/file-globbing for details and syntax.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great suggestion! I've refactored the PathPatternMatcher to use .NET's built-in Microsoft.Extensions.FileSystemGlobbing library instead of the custom implementation. This provides better reliability, cross-platform support, and leverages Microsoft's well-tested globbing functionality.

Key changes:

  • Added Microsoft.Extensions.FileSystemGlobbing dependency
  • Replaced custom regex-based pattern matching with .NET's Matcher class
  • Enhanced cross-platform path handling for both Windows and Unix paths
  • Updated tests to reflect .NET globbing behavior (note: ? wildcard not supported by .NET implementation)
  • All tests passing with improved maintainability

Commit: 806585d

{
/// <summary>
/// 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.
/// </summary>
/// <param name="filePath">The file path to check.</param>
/// <param name="pattern">The glob pattern to match against.</param>
/// <param name="basePath">The base path to resolve relative patterns.</param>
/// <returns>True if the path matches the pattern, false otherwise.</returns>
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;
}
}

/// <summary>
/// Checks if a file path is within the specified base path.
/// </summary>
/// <param name="filePath">The file path to check.</param>
/// <param name="basePath">The base path.</param>
/// <returns>True if the file path is within the base path, false otherwise.</returns>
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);
}
}

/// <summary>
/// Gets the relative path from a base path to a target path.
/// </summary>
/// <param name="basePath">The base path.</param>
/// <param name="targetPath">The target path.</param>
/// <returns>The relative path from base to target.</returns>
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);
}
}

/// <summary>
/// Normalizes a path by converting backslashes to forward slashes.
/// </summary>
/// <param name="path">The path to normalize.</param>
/// <returns>The normalized path.</returns>
private static string NormalizePath(string path)
{
if (string.IsNullOrEmpty(path))
{
return path;
}

return path.Replace('\\', '/');
}

/// <summary>
/// Checks if a path is in Windows style (has a drive letter).
/// </summary>
/// <param name="path">The path to check.</param>
/// <returns>True if the path is Windows-style, false otherwise.</returns>
private static bool IsWindowsStylePath(string path)
{
return !string.IsNullOrEmpty(path) &&
path.Length >= 2 &&
char.IsLetter(path[0]) &&
path[1] == ':';
}

/// <summary>
/// Manually calculates relative path for cross-platform compatibility.
/// </summary>
/// <param name="basePath">The base path.</param>
/// <param name="targetPath">The target path.</param>
/// <returns>The relative path.</returns>
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;
}
}
Loading