From 8d39d512f83c9ecc86fd440714e98215376daf58 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Mon, 13 Oct 2025 16:09:25 -0700 Subject: [PATCH 01/23] Refactor parsing logic to separate classes --- .../rust/Parsers/RustCargoLockParser.cs | 256 ++++++++++++ .../rust/Parsers/RustCliParser.cs | 261 ++++++++++++ .../rust/Parsers/RustSbomParser.cs | 141 +++++++ .../rust/RustCliDetector.cs | 379 ++---------------- .../rust/RustCrateDetector.cs | 208 +--------- .../rust/RustSbomDetector.cs | 89 +--- 6 files changed, 712 insertions(+), 622 deletions(-) create mode 100644 src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCargoLockParser.cs create mode 100644 src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs create mode 100644 src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCargoLockParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCargoLockParser.cs new file mode 100644 index 000000000..cf4ebd768 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCargoLockParser.cs @@ -0,0 +1,256 @@ +namespace Microsoft.ComponentDetection.Detectors.Rust; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ComponentDetection.Common.Telemetry.Records; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.ComponentDetection.Detectors.Rust.Contracts; +using Microsoft.Extensions.Logging; +using Tomlyn; + +/// +/// Detector for Cargo.lock files. +/// +public class RustCargoLockParser +{ + //// PkgName[ Version][ (Source)] + private static readonly Regex DependencyFormatRegex = new Regex( + @"^(?[^ ]+)(?: (?[^ ]+))?(?: \((?[^()]*)\))?$", + RegexOptions.Compiled); + + private static readonly TomlModelOptions TomlOptions = new TomlModelOptions + { + IgnoreMissingProperties = true, + }; + + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + public RustCargoLockParser(ILogger logger) => this.logger = logger; + + private static bool IsLocalPackage(CargoPackage package) => package.Source == null; + + /// + /// Parses a Cargo.lock file and records components. + /// + /// The component stream containing the Cargo.lock file. + /// The component recorder. + /// Cancellation token. + /// The lockfile version, or null if parsing failed. + public async Task ParseAsync( + IComponentStream componentStream, + ISingleFileComponentRecorder singleFileComponentRecorder, + CancellationToken cancellationToken = default) + { + try + { + using var reader = new StreamReader(componentStream.Stream); + var content = await reader.ReadToEndAsync(cancellationToken); + var cargoLock = Toml.ToModel(content, options: TomlOptions); + this.ProcessCargoLock(cargoLock, singleFileComponentRecorder, componentStream); + return cargoLock.Version; + } + catch (Exception e) + { + this.logger.LogError(e, "Failed to parse Cargo.lock file '{CargoLockLocation}'", componentStream.Location); + return null; + } + } + + private void ProcessCargoLock(CargoLock cargoLock, ISingleFileComponentRecorder singleFileComponentRecorder, IComponentStream cargoLockFile) + { + try + { + var seenAsDependency = new HashSet(); + + // Pass 1: Create typed components and allow lookup by name. + var packagesByName = new Dictionary>(); + if (cargoLock.Package != null) + { + foreach (var cargoPackage in cargoLock.Package) + { + // Get or create the list of packages with this name + if (!packagesByName.TryGetValue(cargoPackage.Name, out var packageList)) + { + // First package with this name + packageList = []; + packagesByName.Add(cargoPackage.Name, packageList); + } + else if (packageList.Any(p => p.Package.Equals(cargoPackage))) + { + // Ignore duplicate packages + continue; + } + + // Create a node for each non-local package to allow adding dependencies later. + CargoComponent cargoComponent = null; + if (cargoPackage.Source != null) + { + cargoComponent = new CargoComponent(cargoPackage.Name, cargoPackage.Version); + singleFileComponentRecorder.RegisterUsage(new DetectedComponent(cargoComponent)); + } + + // Add the package/component pair to the list + packageList.Add((cargoPackage, cargoComponent)); + } + + // Pass 2: Register dependencies. + foreach (var packageList in packagesByName.Values) + { + // Get the parent package and component + foreach (var (parentPackage, parentComponent) in packageList) + { + if (parentPackage.Dependencies == null) + { + // This package has no dependency edges to contribute. + continue; + } + + // Process each dependency + foreach (var dependency in parentPackage.Dependencies) + { + this.ProcessDependency( + cargoLockFile, + singleFileComponentRecorder, + seenAsDependency, + packagesByName, + parentPackage, + parentComponent, + dependency); + } + } + } + + // Pass 3: Conservatively mark packages we found no dependency to as roots + foreach (var packageList in packagesByName.Values) + { + // Get the package and component. + foreach (var (package, component) in packageList) + { + if (package.Source != null && !seenAsDependency.Contains(package)) + { + var detectedComponent = new DetectedComponent(component); + singleFileComponentRecorder.RegisterUsage(detectedComponent, isExplicitReferencedDependency: true); + } + } + } + } + } + catch (Exception e) + { + // If something went wrong, just ignore the file + this.logger.LogError(e, "Failed to process Cargo.lock file '{CargoLockLocation}'", cargoLockFile.Location); + } + } + + private void ProcessDependency( + IComponentStream cargoLockFile, + ISingleFileComponentRecorder singleFileComponentRecorder, + HashSet seenAsDependency, + Dictionary> packagesByName, + CargoPackage parentPackage, + CargoComponent parentComponent, + string dependency) + { + try + { + if (!this.ParseDependency(dependency, out var childName, out var childVersion, out var childSource)) + { + throw new FormatException($"Failed to parse dependency '{dependency}'"); + } + + if (!packagesByName.TryGetValue(childName, out var candidatePackages)) + { + throw new FormatException($"Could not find any package named '{childName}' for dependency string '{dependency}'"); + } + + // Search through the list of candidates to find a match (note that version and source are optional). + CargoPackage childPackage = null; + CargoComponent childComponent = null; + foreach (var (candidatePackage, candidateComponent) in candidatePackages) + { + if (childVersion != null && candidatePackage.Version != childVersion) + { + // This does not have the requested version + continue; + } + + if (childSource != null && candidatePackage.Source != childSource) + { + // This does not have the requested source + continue; + } + + if (childPackage != null) + { + throw new FormatException($"Found multiple matching packages for dependency string '{dependency}'"); + } + + // We have found the requested package. + childPackage = candidatePackage; + childComponent = candidateComponent; + } + + if (childPackage == null) + { + throw new FormatException($"Could not find matching package for dependency string '{dependency}'"); + } + + if (IsLocalPackage(childPackage)) + { + // This is a dependency on a package without a source + return; + } + + var detectedComponent = new DetectedComponent(childComponent); + seenAsDependency.Add(childPackage); + + if (IsLocalPackage(parentPackage)) + { + // We are adding a root edge (from a local package) + singleFileComponentRecorder.RegisterUsage(detectedComponent, isExplicitReferencedDependency: true); + } + else + { + // we are adding an edge within the graph + singleFileComponentRecorder.RegisterUsage(detectedComponent, isExplicitReferencedDependency: false, parentComponentId: parentComponent.Id); + } + } + catch (Exception e) + { + using var record = new RustCrateDetectorTelemetryRecord(); + record.PackageInfo = $"{parentPackage.Name}, {parentPackage.Version}, {parentPackage.Source}"; + record.Dependencies = dependency; + this.logger.LogError(e, "Failed to process dependency in Cargo.lock file '{CargoLockLocation}'", cargoLockFile.Location); + singleFileComponentRecorder.RegisterPackageParseFailure(record.PackageInfo); + } + } + + private bool ParseDependency(string dependency, out string packageName, out string version, out string source) + { + var match = DependencyFormatRegex.Match(dependency); + var packageNameMatch = match.Groups["packageName"]; + var versionMatch = match.Groups["version"]; + var sourceMatch = match.Groups["source"]; + + packageName = packageNameMatch.Success ? packageNameMatch.Value : null; + version = versionMatch.Success ? versionMatch.Value : null; + source = sourceMatch.Success ? sourceMatch.Value : null; + + if (string.IsNullOrWhiteSpace(source)) + { + source = null; + } + + return match.Success; + } +} diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs new file mode 100644 index 000000000..78891e539 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs @@ -0,0 +1,261 @@ +namespace Microsoft.ComponentDetection.Detectors.Rust; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ComponentDetection.Common.Telemetry.Records; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.ComponentDetection.Detectors.Rust.Contracts; +using Microsoft.Extensions.Logging; + +/// +/// Parser for Cargo.toml files using cargo metadata command. +/// +public class RustCliParser +{ + private readonly ICommandLineInvocationService cliService; + private readonly IEnvironmentVariableService envVarService; + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The command line invocation service. + /// The environment variable service. + /// The logger. + public RustCliParser( + ICommandLineInvocationService cliService, + IEnvironmentVariableService envVarService, + ILogger logger) + { + this.cliService = cliService; + this.envVarService = envVarService; + this.logger = logger; + } + + /// + /// Parses a Cargo.toml file using cargo metadata command. + /// + /// The component stream containing the Cargo.toml file. + /// The component recorder. + /// Cancellation token. + /// Parse result containing success status and local package directories. + public async Task ParseAsync( + IComponentStream componentStream, + ISingleFileComponentRecorder recorder, + CancellationToken cancellationToken = default) + { + var result = new ParseResult(); + + using var record = new RustGraphTelemetryRecord(); + record.CargoTomlLocation = componentStream.Location; + + try + { + if (this.IsRustCliManuallyDisabled()) + { + this.logger.LogInformation("Rust CLI manually disabled for {Location}", componentStream.Location); + result.FailureReason = "Manually Disabled"; + return result; + } + + if (!await this.cliService.CanCommandBeLocatedAsync("cargo", null)) + { + this.logger.LogInformation("Could not locate cargo command for {Location}", componentStream.Location); + result.FailureReason = "Could not locate cargo command"; + return result; + } + + var cliResult = await this.cliService.ExecuteCommandAsync( + command: "cargo", + additionalCandidateCommands: null, + workingDirectory: null, + cancellationToken: cancellationToken, + "metadata", + "--manifest-path", + componentStream.Location, + "--format-version=1", + "--locked"); + + if (cliResult.ExitCode != 0) + { + this.logger.LogWarning("`cargo metadata` failed for {Location}: {Error}", componentStream.Location, cliResult.StdErr); + result.ErrorMessage = cliResult.StdErr; + result.FailureReason = "`cargo metadata` failed"; + return result; + } + + var metadata = CargoMetadata.FromJson(cliResult.StdOut); + var graph = this.BuildGraph(metadata); + + var packages = metadata.Packages.ToDictionary( + x => $"{x.Id}", + x => new CargoComponent( + x.Name, + x.Version, + (x.Authors == null || x.Authors.Any(a => string.IsNullOrWhiteSpace(a)) || x.Authors.Length == 0) ? null : string.Join(", ", x.Authors), + string.IsNullOrWhiteSpace(x.License) ? null : x.License, + x.Source)); + + var root = metadata.Resolve.Root; + HashSet visitedDependencies = []; + + if (root == null) + { + this.logger.LogInformation("Virtual Manifest detected: {Location}", componentStream.Location); + foreach (var dep in metadata.Resolve.Nodes) + { + var componentKey = $"{dep.Id}"; + if (visitedDependencies.Add(componentKey)) + { + this.TraverseAndRecordComponents( + recorder, + componentStream.Location, + graph, + dep.Id, + null, + null, + packages, + visitedDependencies, + explicitlyReferencedDependency: false); + } + } + } + else + { + this.TraverseAndRecordComponents( + recorder, + componentStream.Location, + graph, + root, + null, + null, + packages, + visitedDependencies, + explicitlyReferencedDependency: true, + isTomlRoot: true); + } + + // Collect local package directories + foreach (var package in metadata.Packages.Where(p => p.Source == null)) + { + var pkgDir = Path.GetDirectoryName(package.ManifestPath); + if (!string.IsNullOrEmpty(pkgDir)) + { + result.LocalPackageDirectories.Add(pkgDir); + } + } + + result.Success = true; + return result; + } + catch (Exception e) + { + this.logger.LogWarning(e, "Failed to run cargo metadata for {Location}", componentStream.Location); + result.ErrorMessage = e.Message; + result.FailureReason = "Exception during cargo metadata"; + return result; + } + } + + private Dictionary BuildGraph(CargoMetadata cargoMetadata) => + cargoMetadata.Resolve.Nodes.ToDictionary(x => x.Id); + + private bool IsRustCliManuallyDisabled() => + this.envVarService.IsEnvironmentVariableValueTrue("DisableRustCliScan"); + + private void TraverseAndRecordComponents( + ISingleFileComponentRecorder recorder, + string location, + IReadOnlyDictionary graph, + string id, + DetectedComponent parent, + Dep depInfo, + IReadOnlyDictionary packagesMetadata, + ISet visitedDependencies, + bool explicitlyReferencedDependency = false, + bool isTomlRoot = false) + { + try + { + var isDevelopmentDependency = depInfo?.DepKinds.Any(x => x.Kind is Kind.Dev) ?? false; + + if (!packagesMetadata.TryGetValue($"{id}", out var cargoComponent)) + { + this.logger.LogWarning("Did not find dependency '{Id}' in Manifest.packages, skipping", id); + return; + } + + var detectedComponent = new DetectedComponent(cargoComponent); + + if (!graph.TryGetValue(id, out var node)) + { + this.logger.LogWarning("Could not find {Id} at {Location} in cargo metadata output", id, location); + return; + } + + var shouldRegister = !isTomlRoot && cargoComponent.Source != null; + if (shouldRegister) + { + recorder.RegisterUsage( + detectedComponent, + explicitlyReferencedDependency, + isDevelopmentDependency: isDevelopmentDependency, + parentComponentId: parent?.Component.Id); + } + + foreach (var dep in node.Deps) + { + var componentKey = $"{detectedComponent.Component.Id}{dep.Pkg} {isTomlRoot}"; + if (visitedDependencies.Add(componentKey)) + { + this.TraverseAndRecordComponents( + recorder, + location, + graph, + dep.Pkg, + shouldRegister ? detectedComponent : null, + dep, + packagesMetadata, + visitedDependencies, + explicitlyReferencedDependency: isTomlRoot && explicitlyReferencedDependency); + } + } + } + catch (IndexOutOfRangeException e) + { + this.logger.LogWarning(e, "Could not parse {Id} at {Location}", id, location); + recorder.RegisterPackageParseFailure(id); + } + } + + /// + /// Result of parsing a Cargo.toml file. + /// + public class ParseResult + { + /// + /// Gets or sets a value indicating whether parsing was successful. + /// + public bool Success { get; set; } + + /// + /// Gets or sets the error message if parsing failed. + /// + public string ErrorMessage { get; set; } + + /// + /// Gets or sets the reason for failure if parsing failed. + /// + public string FailureReason { get; set; } + + /// + /// Gets or sets the local package directories that should be marked as visited. + /// + public HashSet LocalPackageDirectories { get; set; } = []; + } +} diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs new file mode 100644 index 000000000..d2a562af1 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs @@ -0,0 +1,141 @@ +namespace Microsoft.ComponentDetection.Detectors.Rust; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.ComponentDetection.Detectors.Rust.Sbom.Contracts; +using Microsoft.Extensions.Logging; + +/// +/// Detector for Cargo SBOM (.cargo-sbom.json) files. +/// +public class RustSbomParser +{ + private const string CratesIoSource = "registry+https://github.com/rust-lang/crates.io-index"; + + /// + /// Cargo Package ID: source#name@version + /// https://rustwiki.org/en/cargo/reference/pkgid-spec.html. + /// + private static readonly Regex CargoPackageIdRegex = new Regex( + @"^(?[^#]*)#?(?[\w\-]*)[@#]?(?\d[\S]*)?$", + RegexOptions.Compiled); + + private readonly ILogger logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + public RustSbomParser(ILogger logger) => this.logger = logger; + + /// + /// Parses a Cargo SBOM file and records components. + /// + /// The component stream containing the SBOM file. + /// The component recorder. + /// Cancellation token. + /// The lockfile version, or null if parsing failed. + public async Task ParseAsync( + IComponentStream componentStream, + ISingleFileComponentRecorder recorder, + CancellationToken cancellationToken = default) + { + try + { + using var reader = new StreamReader(componentStream.Stream); + var cargoSbom = CargoSbom.FromJson(await reader.ReadToEndAsync(cancellationToken)); + this.ProcessCargoSbom(cargoSbom, recorder, componentStream); + return cargoSbom.Version; + } + catch (Exception e) + { + this.logger.LogError(e, "Failed to parse Cargo SBOM file '{FileLocation}'", componentStream.Location); + return null; + } + } + + private void ProcessCargoSbom(CargoSbom sbom, ISingleFileComponentRecorder recorder, IComponentStream components) + { + try + { + var visitedNodes = new HashSet(); + this.ProcessDependency(sbom, sbom.Crates[sbom.Root], recorder, components, visitedNodes); + } + catch (Exception e) + { + this.logger.LogError(e, "Failed to process Cargo SBOM file '{FileLocation}'", components.Location); + } + } + + private void ProcessDependency( + CargoSbom sbom, + SbomCrate package, + ISingleFileComponentRecorder recorder, + IComponentStream components, + HashSet visitedNodes, + CargoComponent parent = null, + int depth = 0) + { + foreach (var dependency in package.Dependencies) + { + var dep = sbom.Crates[dependency.Index]; + var parentComponent = parent; + + if (this.ParsePackageIdSpec(dep.Id, out var component)) + { + if (component.Source == CratesIoSource) + { + parentComponent = component; + recorder.RegisterUsage( + new DetectedComponent(component), + isExplicitReferencedDependency: depth == 0, + parent?.Id, + isDevelopmentDependency: false); + } + } + else + { + this.logger.LogError(null, "Failed to parse Cargo PackageIdSpec '{Id}' in '{Location}'", dep.Id, components.Location); + recorder.RegisterPackageParseFailure(dep.Id); + } + + if (visitedNodes.Add(dependency.Index)) + { + this.ProcessDependency(sbom, dep, recorder, components, visitedNodes, parentComponent, depth + 1); + } + } + } + + private bool ParsePackageIdSpec(string dependency, out CargoComponent component) + { + var match = CargoPackageIdRegex.Match(dependency); + var name = match.Groups["name"].Value; + var version = match.Groups["version"].Value; + var source = match.Groups["source"].Value; + + if (!match.Success) + { + component = null; + return false; + } + + if (string.IsNullOrWhiteSpace(name)) + { + name = source[(source.LastIndexOf('/') + 1)..]; + } + + if (string.IsNullOrWhiteSpace(source)) + { + source = null; + } + + component = new CargoComponent(name, version, source: source); + return true; + } +} diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs index 276dc79fb..b766d2b3b 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs @@ -4,7 +4,6 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.ComponentDetection.Common; @@ -12,29 +11,16 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; using Microsoft.ComponentDetection.Contracts; using Microsoft.ComponentDetection.Contracts.Internal; using Microsoft.ComponentDetection.Contracts.TypedComponent; -using Microsoft.ComponentDetection.Detectors.Rust.Contracts; using Microsoft.Extensions.Logging; -using MoreLinq.Extensions; using Newtonsoft.Json; -using Tomlyn; /// /// A Rust CLI detector that uses the cargo metadata command to detect Rust components. /// public class RustCliDetector : FileComponentDetector { - private static readonly Regex DependencyFormatRegexCargoLock = new Regex( - @"^(?[^ ]+)(?: (?[^ ]+))?(?: \((?[^()]*)\))?$", - RegexOptions.Compiled); - - private static readonly TomlModelOptions TomlOptions = new TomlModelOptions - { - IgnoreMissingProperties = true, - }; - - private readonly ICommandLineInvocationService cliService; - - private readonly IEnvironmentVariableService envVarService; + private readonly RustCliParser cliParser; + private readonly RustCargoLockParser cargoLockParser; /// /// Initializes a new instance of the class. @@ -53,9 +39,9 @@ public RustCliDetector( { this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; this.Scanner = walkerFactory; - this.cliService = cliService; - this.envVarService = envVarService; this.Logger = logger; + this.cliParser = new RustCliParser(cliService, envVarService, logger); + this.cargoLockParser = new RustCargoLockParser(logger); } /// @@ -78,84 +64,40 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID { var componentStream = processRequest.ComponentStream; this.Logger.LogInformation("Discovered Cargo.toml: {Location}", componentStream.Location); + using var record = new RustGraphTelemetryRecord(); record.CargoTomlLocation = processRequest.ComponentStream.Location; try { - if (this.IsRustCliManuallyDisabled()) + // Try to parse using cargo metadata command + var parseResult = await this.cliParser.ParseAsync( + componentStream, + processRequest.SingleFileComponentRecorder, + cancellationToken); + + if (parseResult.Success) { - this.Logger.LogWarning("Rust Cli has been manually disabled, fallback strategy performed."); + // CLI parsing succeeded record.DidRustCliCommandFail = false; - record.WasRustFallbackStrategyUsed = true; - record.FallbackReason = "Manually Disabled"; - } - else if (!await this.cliService.CanCommandBeLocatedAsync("cargo", null)) - { - this.Logger.LogWarning("Could not locate cargo command. Skipping Rust CLI detection"); - record.DidRustCliCommandFail = true; - record.WasRustFallbackStrategyUsed = true; - record.FallbackReason = "Could not locate cargo command"; + record.WasRustFallbackStrategyUsed = false; } else { - // Use --all-features to ensure that even optional feature dependencies are detected. - var cliResult = await this.cliService.ExecuteCommandAsync( - "cargo", - null, - "metadata", - "--all-features", - "--manifest-path", - componentStream.Location, - "--format-version=1", - "--locked"); + // CLI parsing failed + record.DidRustCliCommandFail = true; + record.RustCliCommandError = parseResult.ErrorMessage; + record.FallbackReason = parseResult.FailureReason; - if (cliResult.ExitCode != 0) + // Determine if we should use fallback based on the error + if (!string.IsNullOrEmpty(parseResult.ErrorMessage)) { - this.Logger.LogWarning("`cargo metadata` failed while processing {Location}. with error: {Error}", processRequest.ComponentStream.Location, cliResult.StdErr); - record.DidRustCliCommandFail = true; - record.WasRustFallbackStrategyUsed = ShouldFallbackFromError(cliResult.StdErr); - record.RustCliCommandError = cliResult.StdErr; - record.FallbackReason = "`cargo metadata` failed"; + record.WasRustFallbackStrategyUsed = ShouldFallbackFromError(parseResult.ErrorMessage); } - - if (!record.DidRustCliCommandFail) + else { - var metadata = CargoMetadata.FromJson(cliResult.StdOut); - var graph = BuildGraph(metadata); - - var packages = metadata.Packages.ToDictionary( - x => $"{x.Id}", - x => new CargoComponent( - x.Name, - x.Version, - (x.Authors == null || x.Authors.Any(a => string.IsNullOrWhiteSpace(a)) || x.Authors.Length == 0) ? null : string.Join(", ", x.Authors), - string.IsNullOrWhiteSpace(x.License) ? null : x.License, - x.Source)); - - var root = metadata.Resolve.Root; - HashSet visitedDependencies = []; - - // A cargo.toml can be used to declare a workspace and not a package (A Virtual Manifest). - // In this case, the root will be null as it will not be pulling in dependencies itself. - // https://doc.rust-lang.org/cargo/reference/workspaces.html#virtual-workspace - if (root == null) - { - this.Logger.LogWarning("Virtual Manifest: {Location}", processRequest.ComponentStream.Location); - - foreach (var dep in metadata.Resolve.Nodes) - { - var componentKey = $"{dep.Id}"; - if (visitedDependencies.Add(componentKey)) - { - this.TraverseAndRecordComponents(processRequest.SingleFileComponentRecorder, componentStream.Location, graph, dep.Id, null, null, packages, visitedDependencies, explicitlyReferencedDependency: false); - } - } - } - else - { - this.TraverseAndRecordComponents(processRequest.SingleFileComponentRecorder, componentStream.Location, graph, root, null, null, packages, visitedDependencies, explicitlyReferencedDependency: true, isTomlRoot: true); - } + // If there's no error message (e.g., manually disabled or cargo not found), use fallback + record.WasRustFallbackStrategyUsed = true; } } } @@ -173,7 +115,7 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID { try { - await this.ProcessCargoLockFallbackAsync(componentStream, processRequest.SingleFileComponentRecorder, record); + await this.ProcessCargoLockFallbackAsync(componentStream, processRequest.SingleFileComponentRecorder, record, cancellationToken); } catch (ArgumentException e) { @@ -188,10 +130,6 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID } } - private static Dictionary BuildGraph(CargoMetadata cargoMetadata) => cargoMetadata.Resolve.Nodes.ToDictionary(x => x.Id); - - private static bool IsLocalPackage(CargoPackage package) => package.Source == null; - private static bool ShouldFallbackFromError(string error) { if (error.Contains("current package believes it's in a workspace", StringComparison.OrdinalIgnoreCase)) @@ -202,92 +140,14 @@ private static bool ShouldFallbackFromError(string error) return true; } - private static bool ParseDependencyCargoLock(string dependency, out string packageName, out string version, out string source) - { - var match = DependencyFormatRegexCargoLock.Match(dependency); - var packageNameMatch = match.Groups["packageName"]; - var versionMatch = match.Groups["version"]; - var sourceMatch = match.Groups["source"]; - - packageName = packageNameMatch.Success ? packageNameMatch.Value : null; - version = versionMatch.Success ? versionMatch.Value : null; - source = sourceMatch.Success ? sourceMatch.Value : null; - - if (string.IsNullOrWhiteSpace(source)) - { - source = null; - } - - return match.Success; - } - - private bool IsRustCliManuallyDisabled() - { - return this.envVarService.IsEnvironmentVariableValueTrue("DisableRustCliScan"); - } - - private void TraverseAndRecordComponents( - ISingleFileComponentRecorder recorder, - string location, - IReadOnlyDictionary graph, - string id, - DetectedComponent parent, - Dep depInfo, - IReadOnlyDictionary packagesMetadata, - ISet visitedDependencies, - bool explicitlyReferencedDependency = false, - bool isTomlRoot = false) + private IComponentStream FindCorrespondingCargoLock(IComponentStream cargoToml) { - try - { - var isDevelopmentDependency = depInfo?.DepKinds.Any(x => x.Kind is Kind.Dev) ?? false; - - if (!packagesMetadata.TryGetValue($"{id}", out var cargoComponent)) - { - // Could not parse the dependency string - this.Logger.LogWarning("Did not find dependency '{Id}' in Manifest.packages, skipping", id); - return; - } - - var detectedComponent = new DetectedComponent(cargoComponent); - - if (!graph.TryGetValue(id, out var node)) - { - this.Logger.LogWarning("Could not find {Id} at {Location} in cargo metadata output", id, location); - return; - } - - var shouldRegister = !isTomlRoot && cargoComponent.Source != null; - if (shouldRegister) - { - recorder.RegisterUsage( - detectedComponent, - explicitlyReferencedDependency, - isDevelopmentDependency: isDevelopmentDependency, - parentComponentId: parent?.Component.Id); - } + var cargoLockStream = this.ComponentStreamEnumerableFactory.GetComponentStreams( + new FileInfo(cargoToml.Location).Directory, + ["Cargo.lock"], + (name, directoryName) => false, + recursivelyScanDirectories: false).FirstOrDefault(); - foreach (var dep in node.Deps) - { - // include isTomlRoot to ensure that the roots present in the toml are marked as such in circular dependency cases - var componentKey = $"{detectedComponent.Component.Id}{dep.Pkg} {isTomlRoot}"; - if (visitedDependencies.Add(componentKey)) - { - this.TraverseAndRecordComponents(recorder, location, graph, dep.Pkg, shouldRegister ? detectedComponent : null, dep, packagesMetadata, visitedDependencies, explicitlyReferencedDependency: isTomlRoot && explicitlyReferencedDependency); - } - } - } - catch (IndexOutOfRangeException e) - { - this.Logger.LogWarning(e, "Could not parse {Id} at {Location}", id, location); - recorder.RegisterPackageParseFailure(id); - } - } - - private IComponentStream FindCorrespondingCargoLock(IComponentStream cargoToml, ISingleFileComponentRecorder singleFileComponentRecorder) - { - var cargoLockLocation = Path.Combine(Path.GetDirectoryName(cargoToml.Location), "Cargo.lock"); - var cargoLockStream = this.ComponentStreamEnumerableFactory.GetComponentStreams(new FileInfo(cargoToml.Location).Directory, ["Cargo.lock"], (name, directoryName) => false, recursivelyScanDirectories: false).FirstOrDefault(); if (cargoLockStream == null) { return null; @@ -309,9 +169,13 @@ private IComponentStream FindCorrespondingCargoLock(IComponentStream cargoToml, } } - private async Task ProcessCargoLockFallbackAsync(IComponentStream cargoTomlFile, ISingleFileComponentRecorder singleFileComponentRecorder, RustGraphTelemetryRecord record) + private async Task ProcessCargoLockFallbackAsync( + IComponentStream cargoTomlFile, + ISingleFileComponentRecorder singleFileComponentRecorder, + RustGraphTelemetryRecord record, + CancellationToken cancellationToken = default) { - var cargoLockFileStream = this.FindCorrespondingCargoLock(cargoTomlFile, singleFileComponentRecorder); + var cargoLockFileStream = this.FindCorrespondingCargoLock(cargoTomlFile); if (cargoLockFileStream == null) { this.Logger.LogWarning("Fallback failed, could not find Cargo.lock file for {CargoTomlLocation}, skipping processing", cargoTomlFile.Location); @@ -325,171 +189,16 @@ private async Task ProcessCargoLockFallbackAsync(IComponentStream cargoTomlFile, record.FallbackCargoLockLocation = cargoLockFileStream.Location; record.FallbackCargoLockFound = true; - using var reader = new StreamReader(cargoLockFileStream.Stream); - var content = await reader.ReadToEndAsync(); - var cargoLock = Toml.ToModel(content, options: TomlOptions); - this.RecordLockfileVersion(cargoLock.Version); - try - { - var seenAsDependency = new HashSet(); - // Pass 1: Create typed components and allow lookup by name. - var packagesByName = new Dictionary>(); - if (cargoLock.Package != null) - { - foreach (var cargoPackage in cargoLock.Package) - { - // Get or create the list of packages with this name - if (!packagesByName.TryGetValue(cargoPackage.Name, out var packageList)) - { - // First package with this name - packageList = []; - packagesByName.Add(cargoPackage.Name, packageList); - } - else if (packageList.Any(p => p.Package.Equals(cargoPackage))) - { - // Ignore duplicate packages - continue; - } - - // Create a node for each non-local package to allow adding dependencies later. - CargoComponent cargoComponent = null; - if (!IsLocalPackage(cargoPackage)) - { - cargoComponent = new CargoComponent(cargoPackage.Name, cargoPackage.Version); - singleFileComponentRecorder.RegisterUsage(new DetectedComponent(cargoComponent)); - } - - // Add the package/component pair to the list - packageList.Add((cargoPackage, cargoComponent)); - } - - // Pass 2: Register dependencies. - foreach (var packageList in packagesByName.Values) - { - // Get the parent package and component - foreach (var (parentPackage, parentComponent) in packageList) - { - if (parentPackage.Dependencies == null) - { - // This package has no dependency edges to contribute. - continue; - } - - // Process each dependency - foreach (var dependency in parentPackage.Dependencies) - { - this.ProcessDependency(cargoLockFileStream, singleFileComponentRecorder, seenAsDependency, packagesByName, parentPackage, parentComponent, dependency); - } - } - } - - // Pass 3: Conservatively mark packages we found no dependency to as roots - foreach (var packageList in packagesByName.Values) - { - // Get the package and component. - foreach (var (package, component) in packageList) - { - if (!IsLocalPackage(package) && !seenAsDependency.Contains(package)) - { - var detectedComponent = new DetectedComponent(component); - singleFileComponentRecorder.RegisterUsage(detectedComponent, isExplicitReferencedDependency: true); - } - } - } - } - } - catch (Exception e) - { - // If something went wrong, just ignore the file - this.Logger.LogError(e, "Failed to process Cargo.lock file '{CargoLockLocation}'", cargoLockFileStream.Location); - } - } + // Use RustCrateParser to parse the Cargo.lock file + var lockfileVersion = await this.cargoLockParser.ParseAsync( + cargoLockFileStream, + singleFileComponentRecorder, + cancellationToken); - private void ProcessDependency( - IComponentStream cargoLockFile, - ISingleFileComponentRecorder singleFileComponentRecorder, - HashSet seenAsDependency, - Dictionary> packagesByName, - CargoPackage parentPackage, - CargoComponent parentComponent, - string dependency) - { - try + if (lockfileVersion.HasValue) { - // Extract the information from the dependency (name with optional version and source) - if (!ParseDependencyCargoLock(dependency, out var childName, out var childVersion, out var childSource)) - { - // Could not parse the dependency string - throw new FormatException($"Failed to parse dependency '{dependency}'"); - } - - if (!packagesByName.TryGetValue(childName, out var candidatePackages)) - { - throw new FormatException($"Could not find any package named '{childName}' for depenency string '{dependency}'"); - } - - // Search through the list of candidates to find a match (note that version and source are optional). - CargoPackage childPackage = null; - CargoComponent childComponent = null; - foreach (var (candidatePackage, candidateComponent) in candidatePackages) - { - if (childVersion != null && candidatePackage.Version != childVersion) - { - // This does not have the requested version - continue; - } - - if (childSource != null && candidatePackage.Source != childSource) - { - // This does not have the requested source - continue; - } - - if (childPackage != null) - { - throw new FormatException($"Found multiple matching packages for dependency string '{dependency}'"); - } - - // We have found the requested package. - childPackage = candidatePackage; - childComponent = candidateComponent; - } - - if (childPackage == null) - { - throw new FormatException($"Could not find matching package for dependency string '{dependency}'"); - } - - if (IsLocalPackage(childPackage)) - { - // This is a dependency on a package without a source - return; - } - - var detectedComponent = new DetectedComponent(childComponent); - seenAsDependency.Add(childPackage); - - if (IsLocalPackage(parentPackage)) - { - // We are adding a root edge (from a local package) - singleFileComponentRecorder.RegisterUsage(detectedComponent, isExplicitReferencedDependency: true); - } - else - { - // we are adding an edge within the graph - singleFileComponentRecorder.RegisterUsage(detectedComponent, isExplicitReferencedDependency: false, parentComponentId: parentComponent.Id); - } - } - catch (Exception e) - { - using var record = new RustCrateDetectorTelemetryRecord(); - - record.PackageInfo = $"{parentPackage.Name}, {parentPackage.Version}, {parentPackage.Source}"; - record.Dependencies = dependency; - - this.Logger.LogError(e, "Failed to process Cargo.lock file '{CargoLockLocation}'", cargoLockFile.Location); - singleFileComponentRecorder.RegisterPackageParseFailure(record.PackageInfo); + this.RecordLockfileVersion(lockfileVersion.Value); } } } diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs index 779435caf..62a425783 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs @@ -1,28 +1,17 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; -using System; using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; -using Microsoft.ComponentDetection.Common.Telemetry.Records; using Microsoft.ComponentDetection.Contracts; using Microsoft.ComponentDetection.Contracts.Internal; using Microsoft.ComponentDetection.Contracts.TypedComponent; -using Microsoft.ComponentDetection.Detectors.Rust.Contracts; using Microsoft.Extensions.Logging; -using Tomlyn; public class RustCrateDetector : FileComponentDetector { private const string CargoLockSearchPattern = "Cargo.lock"; - - //// PkgName[ Version][ (Source)] - private static readonly Regex DependencyFormatRegex = new Regex( - @"^(?[^ ]+)(?: (?[^ ]+))?(?: \((?[^()]*)\))?$", - RegexOptions.Compiled); + private readonly RustCargoLockParser parser; public RustCrateDetector( IComponentStreamEnumerableFactory componentStreamEnumerableFactory, @@ -32,6 +21,7 @@ public RustCrateDetector( this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; this.Scanner = walkerFactory; this.Logger = logger; + this.parser = new RustCargoLockParser(logger); } public override string Id => "RustCrateDetector"; @@ -44,204 +34,16 @@ public RustCrateDetector( public override IEnumerable Categories => ["Rust"]; - private static bool ParseDependency(string dependency, out string packageName, out string version, out string source) - { - var match = DependencyFormatRegex.Match(dependency); - var packageNameMatch = match.Groups["packageName"]; - var versionMatch = match.Groups["version"]; - var sourceMatch = match.Groups["source"]; - - packageName = packageNameMatch.Success ? packageNameMatch.Value : null; - version = versionMatch.Success ? versionMatch.Value : null; - source = sourceMatch.Success ? sourceMatch.Value : null; - - if (string.IsNullOrWhiteSpace(source)) - { - source = null; - } - - return match.Success; - } - - private static bool IsLocalPackage(CargoPackage package) => package.Source == null; - protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default) { var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder; var cargoLockFile = processRequest.ComponentStream; - var reader = new StreamReader(cargoLockFile.Stream); - var options = new TomlModelOptions - { - IgnoreMissingProperties = true, - }; - var cargoLock = Toml.ToModel(await reader.ReadToEndAsync(cancellationToken), options: options); - this.RecordLockfileVersion(cargoLock.Version); - this.ProcessCargoLock(cargoLock, singleFileComponentRecorder, cargoLockFile); - } - - private void ProcessCargoLock(CargoLock cargoLock, ISingleFileComponentRecorder singleFileComponentRecorder, IComponentStream cargoLockFile) - { - try - { - var seenAsDependency = new HashSet(); - - // Pass 1: Create typed components and allow lookup by name. - var packagesByName = new Dictionary>(); - if (cargoLock.Package != null) - { - foreach (var cargoPackage in cargoLock.Package) - { - // Get or create the list of packages with this name - if (!packagesByName.TryGetValue(cargoPackage.Name, out var packageList)) - { - // First package with this name - packageList = []; - packagesByName.Add(cargoPackage.Name, packageList); - } - else if (packageList.Any(p => p.Package.Equals(cargoPackage))) - { - // Ignore duplicate packages - continue; - } - - // Create a node for each non-local package to allow adding dependencies later. - CargoComponent cargoComponent = null; - if (!IsLocalPackage(cargoPackage)) - { - cargoComponent = new CargoComponent(cargoPackage.Name, cargoPackage.Version); - singleFileComponentRecorder.RegisterUsage(new DetectedComponent(cargoComponent)); - } - - // Add the package/component pair to the list - packageList.Add((cargoPackage, cargoComponent)); - } - - // Pass 2: Register dependencies. - foreach (var packageList in packagesByName.Values) - { - // Get the parent package and component - foreach (var (parentPackage, parentComponent) in packageList) - { - if (parentPackage.Dependencies == null) - { - // This package has no dependency edges to contribute. - continue; - } - // Process each dependency - foreach (var dependency in parentPackage.Dependencies) - { - this.ProcessDependency(cargoLockFile, singleFileComponentRecorder, seenAsDependency, packagesByName, parentPackage, parentComponent, dependency); - } - } - } + var lockfileVersion = await this.parser.ParseAsync(cargoLockFile, singleFileComponentRecorder, cancellationToken); - // Pass 3: Conservatively mark packages we found no dependency to as roots - foreach (var packageList in packagesByName.Values) - { - // Get the package and component. - foreach (var (package, component) in packageList) - { - if (!IsLocalPackage(package) && !seenAsDependency.Contains(package)) - { - var detectedComponent = new DetectedComponent(component); - singleFileComponentRecorder.RegisterUsage(detectedComponent, isExplicitReferencedDependency: true); - } - } - } - } - } - catch (Exception e) - { - // If something went wrong, just ignore the file - this.Logger.LogError(e, "Failed to process Cargo.lock file '{CargoLockLocation}'", cargoLockFile.Location); - } - } - - private void ProcessDependency( - IComponentStream cargoLockFile, - ISingleFileComponentRecorder singleFileComponentRecorder, - HashSet seenAsDependency, - Dictionary> packagesByName, - CargoPackage parentPackage, - CargoComponent parentComponent, - string dependency) - { - try + if (lockfileVersion.HasValue) { - // Extract the information from the dependency (name with optional version and source) - if (!ParseDependency(dependency, out var childName, out var childVersion, out var childSource)) - { - // Could not parse the dependency string - throw new FormatException($"Failed to parse dependency '{dependency}'"); - } - - if (!packagesByName.TryGetValue(childName, out var candidatePackages)) - { - throw new FormatException($"Could not find any package named '{childName}' for depenency string '{dependency}'"); - } - - // Search through the list of candidates to find a match (note that version and source are optional). - CargoPackage childPackage = null; - CargoComponent childComponent = null; - foreach (var (candidatePackage, candidateComponent) in candidatePackages) - { - if (childVersion != null && candidatePackage.Version != childVersion) - { - // This does not have the requested version - continue; - } - - if (childSource != null && candidatePackage.Source != childSource) - { - // This does not have the requested source - continue; - } - - if (childPackage != null) - { - throw new FormatException($"Found multiple matching packages for dependency string '{dependency}'"); - } - - // We have found the requested package. - childPackage = candidatePackage; - childComponent = candidateComponent; - } - - if (childPackage == null) - { - throw new FormatException($"Could not find matching package for dependency string '{dependency}'"); - } - - if (IsLocalPackage(childPackage)) - { - // This is a dependency on a package without a source - return; - } - - var detectedComponent = new DetectedComponent(childComponent); - seenAsDependency.Add(childPackage); - - if (IsLocalPackage(parentPackage)) - { - // We are adding a root edge (from a local package) - singleFileComponentRecorder.RegisterUsage(detectedComponent, isExplicitReferencedDependency: true); - } - else - { - // we are adding an edge within the graph - singleFileComponentRecorder.RegisterUsage(detectedComponent, isExplicitReferencedDependency: false, parentComponentId: parentComponent.Id); - } - } - catch (Exception e) - { - using var record = new RustCrateDetectorTelemetryRecord(); - - record.PackageInfo = $"{parentPackage.Name}, {parentPackage.Version}, {parentPackage.Source}"; - record.Dependencies = dependency; - - this.Logger.LogError(e, "Failed to process Cargo.lock file '{CargoLockLocation}'", cargoLockFile.Location); - singleFileComponentRecorder.RegisterPackageParseFailure(record.PackageInfo); + this.RecordLockfileVersion(lockfileVersion.Value); } } } diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs index 507b15e7e..b5a368210 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs @@ -1,29 +1,17 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; -using System; using System.Collections.Generic; -using System.IO; -using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Microsoft.ComponentDetection.Contracts; using Microsoft.ComponentDetection.Contracts.Internal; using Microsoft.ComponentDetection.Contracts.TypedComponent; -using Microsoft.ComponentDetection.Detectors.Rust.Sbom.Contracts; using Microsoft.Extensions.Logging; public class RustSbomDetector : FileComponentDetector, IExperimentalDetector { private const string CargoSbomSearchPattern = "*.cargo-sbom.json"; - private const string CratesIoSource = "registry+https://github.com/rust-lang/crates.io-index"; - - /// - /// Cargo Package ID: source#name@version - /// https://rustwiki.org/en/cargo/reference/pkgid-spec.html. - /// - private static readonly Regex CargoPackageIdRegex = new Regex( - @"^(?[^#]*)#?(?[\w\-]*)[@#]?(?\d[\S]*)?$", - RegexOptions.Compiled); + private readonly RustSbomParser parser; public RustSbomDetector( IComponentStreamEnumerableFactory componentStreamEnumerableFactory, @@ -33,6 +21,7 @@ public RustSbomDetector( this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; this.Scanner = walkerFactory; this.Logger = logger; + this.parser = new RustSbomParser(logger); } public override string Id => "RustSbom"; @@ -45,82 +34,14 @@ public RustSbomDetector( public override IEnumerable Categories => ["Rust"]; - private static bool ParsePackageIdSpec(string dependency, out CargoComponent component) - { - var match = CargoPackageIdRegex.Match(dependency); - var name = match.Groups["name"].Value; - var version = match.Groups["version"].Value; - var source = match.Groups["source"].Value; - - if (!match.Success) - { - component = null; - return false; - } - - if (string.IsNullOrWhiteSpace(name)) - { - name = source[(source.LastIndexOf('/') + 1)..]; - } - - if (string.IsNullOrWhiteSpace(source)) - { - source = null; - } - - component = new CargoComponent(name, version, source: source); - return true; - } - protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default) { var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder; var components = processRequest.ComponentStream; - var reader = new StreamReader(components.Stream); - var cargoSbom = CargoSbom.FromJson(await reader.ReadToEndAsync(cancellationToken)); - this.RecordLockfileVersion(cargoSbom.Version); - this.ProcessCargoSbom(cargoSbom, singleFileComponentRecorder, components); - } - - private void ProcessDependency(CargoSbom sbom, SbomCrate package, ISingleFileComponentRecorder recorder, IComponentStream components, HashSet visitedNodes, CargoComponent parent = null, int depth = 0) - { - foreach (var dependency in package.Dependencies) - { - var dep = sbom.Crates[dependency.Index]; - var parentComponent = parent; - if (ParsePackageIdSpec(dep.Id, out var component)) - { - if (component.Source == CratesIoSource) - { - parentComponent = component; - recorder.RegisterUsage(new DetectedComponent(component), isExplicitReferencedDependency: depth == 0, parent?.Id, isDevelopmentDependency: false); - } - } - else - { - this.Logger.LogError(null, "Failed to parse Cargo PackageIdSpec '{Id}' in '{Location}'", dep.Id, components.Location); - recorder.RegisterPackageParseFailure(dep.Id); - } - - if (visitedNodes.Add(dependency.Index)) - { - // Skip processing already processed nodes - this.ProcessDependency(sbom, dep, recorder, components, visitedNodes, parentComponent, depth + 1); - } - } - } - - private void ProcessCargoSbom(CargoSbom sbom, ISingleFileComponentRecorder recorder, IComponentStream components) - { - try - { - var visitedNodes = new HashSet(); - this.ProcessDependency(sbom, sbom.Crates[sbom.Root], recorder, components, visitedNodes); - } - catch (Exception e) + var sbomVersion = await this.parser.ParseAsync(components, singleFileComponentRecorder, cancellationToken); + if (sbomVersion.HasValue) { - // If something went wrong, just ignore the file - this.Logger.LogError(e, "Failed to process Cargo SBOM file '{FileLocation}'", components.Location); + this.RecordLockfileVersion(sbomVersion.Value); } } } From ad0778900403b047ba74a479d8aa2d8e9123d07d Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Tue, 14 Oct 2025 10:29:33 -0700 Subject: [PATCH 02/23] Fix RustCli UTs --- .../RustCliDetectorTests.cs | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs index 790a4801b..04f717c5a 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs @@ -4,6 +4,7 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.ComponentDetection.Common; @@ -648,7 +649,12 @@ public async Task RustCliDetector_RegistersCorrectRootDepsAsync() { var cargoMetadata = this.mockMetadataV1; this.mockCliService.Setup(x => x.CanCommandBeLocatedAsync("cargo", It.IsAny>())).ReturnsAsync(true); - this.mockCliService.Setup(x => x.ExecuteCommandAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + this.mockCliService.Setup(x => x.ExecuteCommandAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new CommandLineExecutionResult { StdOut = cargoMetadata }); var (scanResult, componentRecorder) = await this.DetectorTestUtility @@ -770,7 +776,12 @@ public async Task RustCliDetector_ComponentContainsAuthorAndLicenseAsync() { var cargoMetadata = this.mockMetadataWithLicenses; this.mockCliService.Setup(x => x.CanCommandBeLocatedAsync("cargo", It.IsAny>())).ReturnsAsync(true); - this.mockCliService.Setup(x => x.ExecuteCommandAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + this.mockCliService.Setup(x => x.ExecuteCommandAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new CommandLineExecutionResult { StdOut = cargoMetadata }); var (scanResult, componentRecorder) = await this.DetectorTestUtility @@ -803,7 +814,12 @@ public async Task RustCliDetector_AuthorAndLicenseNullAsync() { var cargoMetadata = this.mockMetadataV1; this.mockCliService.Setup(x => x.CanCommandBeLocatedAsync("cargo", It.IsAny>())).ReturnsAsync(true); - this.mockCliService.Setup(x => x.ExecuteCommandAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + this.mockCliService.Setup(x => x.ExecuteCommandAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new CommandLineExecutionResult { StdOut = cargoMetadata }); var (scanResult, componentRecorder) = await this.DetectorTestUtility @@ -937,7 +953,12 @@ public async Task RustCliDetector_AuthorAndLicenseEmptyStringAsync() }"; this.mockCliService.Setup(x => x.CanCommandBeLocatedAsync("cargo", It.IsAny>())).ReturnsAsync(true); - this.mockCliService.Setup(x => x.ExecuteCommandAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + this.mockCliService.Setup(x => x.ExecuteCommandAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new CommandLineExecutionResult { StdOut = cargoMetadata }); var (scanResult, componentRecorder) = await this.DetectorTestUtility @@ -971,7 +992,12 @@ public async Task RustCliDetector_VirtualManifestSuccessfullyProcessedAsync() var cargoMetadata = this.mockMetadataVirtualManifest; this.mockCliService.Setup(x => x.CanCommandBeLocatedAsync("cargo", It.IsAny>())).ReturnsAsync(true); - this.mockCliService.Setup(x => x.ExecuteCommandAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + this.mockCliService.Setup(x => x.ExecuteCommandAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new CommandLineExecutionResult { StdOut = cargoMetadata }); var (scanResult, componentRecorder) = await this.DetectorTestUtility @@ -1157,7 +1183,12 @@ public async Task RustCliDetector_FallBackLogicTriggeredOnFailedProcessingAsync( public async Task RustCliDetector_FallBackLogicSkippedOnWorkspaceErrorAsync() { this.mockCliService.Setup(x => x.CanCommandBeLocatedAsync("cargo", It.IsAny>())).ReturnsAsync(true); - this.mockCliService.Setup(x => x.ExecuteCommandAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + this.mockCliService.Setup(x => x.ExecuteCommandAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny(), + It.IsAny(), + It.IsAny())) .ReturnsAsync(new CommandLineExecutionResult { StdOut = null, StdErr = "current package believes it's in a workspace when it's not:", ExitCode = -1 }); var testCargoLockString = @" [[package]] From df5bf07f816053fdd5dc15f85f4b4fa6507c7fc6 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Tue, 14 Oct 2025 10:29:42 -0700 Subject: [PATCH 03/23] Add skeleton for RustComponentDetector --- .../rust/RustComponentDetector.cs | 667 ++++++++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 1 + 2 files changed, 668 insertions(+) create mode 100644 src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs new file mode 100644 index 000000000..7350af3e7 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs @@ -0,0 +1,667 @@ +namespace Microsoft.ComponentDetection.Detectors.Rust; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Threading; +using System.Threading.Tasks; +using global::DotNet.Globbing; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.Internal; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.Extensions.Logging; +using Tomlyn; +using Tomlyn.Model; + +/// +/// A unified Rust detector that orchestrates SBOM, CLI, and Crate parsing. +/// +public class RustComponentDetector : FileComponentDetector +{ + private static readonly TomlModelOptions TomlOptions = new TomlModelOptions + { + IgnoreMissingProperties = true, + }; + + private readonly RustSbomParser sbomParser; + private readonly RustCliParser cliParser; + private readonly RustCargoLockParser cargoLockParser; + private readonly HashSet visitedDirs; + private readonly List visitedGlobRules; + private readonly StringComparer pathComparer; + private readonly StringComparison pathComparison; + private DetectionMode mode; + + /// + /// Initializes a new instance of the class. + /// + /// The component stream enumerable factory. + /// The walker factory. + /// The command line invocation service. + /// The environment variable service. + /// The logger. + public RustComponentDetector( + IComponentStreamEnumerableFactory componentStreamEnumerableFactory, + IObservableDirectoryWalkerFactory walkerFactory, + ICommandLineInvocationService cliService, + IEnvironmentVariableService envVarService, + ILogger logger) + { + this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; + this.Scanner = walkerFactory; + this.Logger = logger; + + // Initialize parsers + this.sbomParser = new RustSbomParser(logger); + this.cliParser = new RustCliParser(cliService, envVarService, logger); + this.cargoLockParser = new RustCargoLockParser(logger); + + // Initialize with case-insensitive comparison on Windows + this.pathComparer = OperatingSystem.IsWindows() + ? StringComparer.OrdinalIgnoreCase + : StringComparer.Ordinal; + this.pathComparison = OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + this.visitedDirs = new HashSet(this.pathComparer); + this.visitedGlobRules = []; + } + + /// + /// Detection modes for the unified Rust detector. + /// + private enum DetectionMode + { + /// + /// Only use SBOM files for detection. + /// + SBOM_ONLY, + + /// + /// Use fallback strategy (Cargo CLI and/or Cargo.lock parsing). + /// + FALLBACK, + } + + /// + /// File kinds for skip logic. + /// + private enum FileKind + { + /// + /// Cargo.toml file. + /// + CargoToml, + + /// + /// Cargo.lock file. + /// + CargoLock, + + /// + /// Cargo SBOM file. + /// + CargoSbom, + } + + /// + public override string Id => nameof(RustComponentDetector); + + /// + public override IEnumerable Categories { get; } = ["Rust"]; + + /// + public override IEnumerable SupportedComponentTypes => [ComponentType.Cargo]; + + /// + public override int Version => 1; + + /// + public override IList SearchPatterns { get; } = ["Cargo.toml", "Cargo.lock", "*.cargo-sbom.json"]; + + /// + protected override async Task> OnPrepareDetectionAsync( + IObservable processRequests, + IDictionary detectorArgs, + CancellationToken cancellationToken = default) + { + this.Logger.LogInformation("Preparing Rust component detection"); + + // Step 1: Collect all process requests into a list + var allRequests = await processRequests.ToList().ToTask(cancellationToken); + + // Step 2: Determine detection mode + var hasSbomFiles = allRequests.Any(r => r.ComponentStream.Location.EndsWith(".cargo-sbom.json", StringComparison.OrdinalIgnoreCase)); + this.mode = hasSbomFiles ? DetectionMode.SBOM_ONLY : DetectionMode.FALLBACK; + + this.Logger.LogInformation("Detection mode: {Mode}", this.mode); + + // Step 3: Filter and order candidates based on mode + IEnumerable filteredRequests; + + if (this.mode == DetectionMode.SBOM_ONLY) + { + // Only SBOM files, ordered by path ascending + filteredRequests = allRequests + .Where(r => r.ComponentStream.Location.EndsWith(".cargo-sbom.json", StringComparison.OrdinalIgnoreCase)) + .OrderBy(r => r.ComponentStream.Location, StringComparer.Ordinal); + + this.Logger.LogInformation("SBOM_ONLY mode: Processing {Count} SBOM files", filteredRequests.Count()); + } + else + { + // FALLBACK mode: Select Cargo.toml and Cargo.lock files + // Order: TOML before LOCK, then depth ascending, then path ascending + filteredRequests = allRequests + .Where(r => + { + var fileName = Path.GetFileName(r.ComponentStream.Location); + return fileName.Equals("Cargo.toml", StringComparison.OrdinalIgnoreCase) || + fileName.Equals("Cargo.lock", StringComparison.OrdinalIgnoreCase); + }) + .OrderBy(r => Path.GetFileName(r.ComponentStream.Location).Equals("Cargo.lock", StringComparison.OrdinalIgnoreCase) ? 1 : 0) // TOML before LOCK + .ThenBy(r => this.GetDirectoryDepth(r.ComponentStream.Location)) + .ThenBy(r => r.ComponentStream.Location, StringComparer.Ordinal); + + this.Logger.LogInformation("FALLBACK mode: Processing {Count} Cargo.toml and Cargo.lock files", filteredRequests.Count()); + } + + // Step 4: Return the ordered sequence as an observable + return filteredRequests.ToObservable(); + } + + /// + protected override async Task OnFileFoundAsync( + ProcessRequest processRequest, + IDictionary detectorArgs, + CancellationToken cancellationToken = default) + { + var componentStream = processRequest.ComponentStream; + var location = componentStream.Location; + var directory = Path.GetDirectoryName(location); + var fileName = Path.GetFileName(location); + + this.Logger.LogInformation("Processing file: {Location}", location); + + // Determine file kind + FileKind fileKind; + if (fileName.Equals("Cargo.toml", StringComparison.OrdinalIgnoreCase)) + { + fileKind = FileKind.CargoToml; + } + else if (fileName.Equals("Cargo.lock", StringComparison.OrdinalIgnoreCase)) + { + fileKind = FileKind.CargoLock; + } + else + { + fileKind = FileKind.CargoSbom; + } + + // Check if directory should be skipped + if (this.ShouldSkip(directory, fileKind, location)) + { + this.Logger.LogInformation("Skipping file due to skip rules: {Location}", location); + return; + } + + if (this.mode == DetectionMode.SBOM_ONLY) + { + await this.ProcessSbomFileAsync(processRequest, cancellationToken); + } + else + { + // FALLBACK mode + if (fileKind == FileKind.CargoToml) + { + await this.ProcessCargoTomlAsync(processRequest, directory, cancellationToken); + } + else if (fileKind == FileKind.CargoLock) + { + await this.ProcessCargoLockAsync(processRequest, directory, cancellationToken); + } + } + } + + /// + /// Normalizes a file path for consistent comparison across platforms. + /// + /// The path to normalize. + /// + /// A normalized path with forward slashes. On Windows, the path is converted to uppercase for case-insensitive comparison. + /// Returns the original path if it is null or empty. + /// + private string NormalizePath(string path) + { + if (string.IsNullOrEmpty(path)) + { + return path; + } + + // Normalize to forward slashes and handle case sensitivity + var normalized = path.Replace('\\', '/'); + + // On Windows, normalize to uppercase for comparison + if (OperatingSystem.IsWindows()) + { + normalized = normalized.ToUpperInvariant(); + } + + return normalized; + } + + /// + /// Calculates the depth of a directory path by counting the number of directory separators. + /// + /// The file or directory path to analyze. + /// + /// The number of directory separators in the normalized path, representing the depth. + /// Returns 0 if the path is null or empty. + /// + /// + /// The path is normalized to use forward slashes before counting separators. + /// This ensures consistent depth calculation across different operating systems. + /// + private int GetDirectoryDepth(string path) + { + if (string.IsNullOrEmpty(path)) + { + return 0; + } + + var normalizedPath = this.NormalizePath(path); + return normalizedPath.Count(c => c == '/'); + } + + /// + /// Determines whether a file should be skipped based on visited directories and workspace glob rules. + /// + /// The directory path containing the file. + /// The kind of file being processed (CargoToml, CargoLock, or CargoSbom). + /// The full path to the file being evaluated. + /// + /// true if the file should be skipped; otherwise, false. + /// + /// + /// The skip logic follows these rules: + /// + /// If the directory has already been processed, skip immediately. + /// Workspace-only Cargo.toml files (with [workspace] but no [package] section) are never skipped. + /// Files are checked against workspace glob rules for inclusion/exclusion patterns. + /// + /// + private bool ShouldSkip(string directory, FileKind fileKind, string fullPath) + { + var normalizedDir = this.NormalizePath(directory); + + // 1. If directory already processed, skip immediately + if (this.visitedDirs.Contains(normalizedDir)) + { + return true; + } + + // 2. Workspace-only Cargo.toml should always be processed (never skipped) + if (fileKind == FileKind.CargoToml && this.IsWorkspaceOnlyToml(fullPath)) + { + return false; + } + + var normalizedFullPath = this.NormalizePath(fullPath); + + // 3. Check each workspace rule for inclusion/exclusion + foreach (var rule in this.visitedGlobRules) + { + if (!this.IsDescendantOf(normalizedDir, rule.Root)) + { + continue; + } + + var relativePath = this.GetRelativePath(rule.Root, normalizedDir); + + // Match against include globs + var matchesInclude = rule.IncludeGlobs.Any(g => + g.IsMatch(relativePath) || g.IsMatch(normalizedFullPath)); + + if (!matchesInclude) + { + continue; + } + + // Match against exclude globs + var matchesExclude = rule.ExcludeGlobs.Any(g => + g.IsMatch(relativePath) || g.IsMatch(normalizedFullPath)); + + if (matchesExclude) + { + continue; + } + + // If included and not excluded, skip this directory + return true; + } + + return false; + } + + /// + /// Adds a glob rule for workspace member filtering based on include and exclude patterns. + /// + /// The root directory path where the workspace is defined. + /// Collection of glob patterns to include workspace members (e.g., "member1", "path/*"). + /// Collection of glob patterns to exclude workspace members (e.g., "examples/*", "tests/*"). + /// + /// This method normalizes all paths and patterns for cross-platform compatibility. + /// On Windows, patterns are evaluated case-insensitively, while on other platforms they are case-sensitive. + /// The glob rule is used to determine whether files in descendant directories should be skipped during detection. + /// + private void AddGlobRule(string root, IEnumerable includes, IEnumerable excludes) + { + var normalizedRoot = this.NormalizePath(root); + var includesList = includes?.ToList() ?? []; + var excludesList = excludes?.ToList() ?? []; + + var globOptions = new GlobOptions + { + Evaluation = new EvaluationOptions + { + CaseInsensitive = OperatingSystem.IsWindows(), + }, + }; + + var includeGlobs = new List(); + foreach (var pattern in includesList) + { + var normalizedPattern = this.NormalizePath(pattern); + includeGlobs.Add(Glob.Parse(normalizedPattern, globOptions)); + } + + var excludeGlobs = new List(); + foreach (var pattern in excludesList) + { + var normalizedPattern = this.NormalizePath(pattern); + excludeGlobs.Add(Glob.Parse(normalizedPattern, globOptions)); + } + + var rule = new GlobRule + { + Root = normalizedRoot, + Includes = includesList, + Excludes = excludesList, + IncludeGlobs = includeGlobs, + ExcludeGlobs = excludeGlobs, + }; + + this.visitedGlobRules.Add(rule); + this.Logger.LogDebug("Added glob rule with root {Root}, {IncludeCount} includes, {ExcludeCount} excludes", normalizedRoot, includesList.Count, excludesList.Count); + } + + /// + /// Determines if the specified path is a descendant of the potential parent directory. + /// + /// The path to check. + /// The potential parent directory path. + /// + /// true if is a descendant of or equal to ; otherwise, false. + /// + /// + /// This method normalizes both paths for cross-platform comparison and handles case sensitivity based on the operating system. + /// The comparison treats paths with and without trailing separators as equivalent. + /// + private bool IsDescendantOf(string path, string potentialParent) + { + var normalizedPath = this.NormalizePath(path); + var normalizedParent = this.NormalizePath(potentialParent); + + // Ensure parent path ends with separator for proper comparison + if (!normalizedParent.EndsWith('/')) + { + normalizedParent += "/"; + } + + return normalizedPath.StartsWith(normalizedParent, this.pathComparison) || + normalizedPath.Equals(normalizedParent.TrimEnd('/'), this.pathComparison); + } + + /// + /// Calculates the relative path from a base path to a full path. + /// + /// The base directory path to calculate relative to. + /// The full path to convert to a relative path. + /// + /// The relative path from to . + /// If is not under , returns the normalized full path. + /// + /// + /// The method normalizes both paths for cross-platform comparison and handles case sensitivity based on the operating system. + /// The base path is automatically appended with a trailing separator if not present to ensure correct path comparison. + /// + private string GetRelativePath(string basePath, string fullPath) + { + var normalizedBase = this.NormalizePath(basePath); + var normalizedFull = this.NormalizePath(fullPath); + + if (!normalizedBase.EndsWith('/')) + { + normalizedBase += "/"; + } + + if (normalizedFull.StartsWith(normalizedBase, this.pathComparison)) + { + return normalizedFull[normalizedBase.Length..]; + } + + return normalizedFull; + } + + /// + /// Determines whether the specified Cargo.toml file is a workspace-only configuration file. + /// + /// The full path to the Cargo.toml file to analyze. + /// + /// true if the file contains a [workspace] section but no [package] section, indicating it is a workspace-only file; + /// otherwise, false. + /// + /// + /// A workspace-only Cargo.toml file defines workspace configuration and members but does not define a package itself. + /// Such files should always be processed during detection and never skipped, as they provide critical workspace structure information. + /// If the file cannot be parsed, this method logs a warning and returns false to allow continued processing. + /// + private bool IsWorkspaceOnlyToml(string cargoTomlPath) + { + try + { + var content = File.ReadAllText(cargoTomlPath); + var tomlTable = Toml.ToModel(content, options: TomlOptions); + + // Check if it has a [workspace] section but no [package] section + var hasWorkspace = tomlTable.ContainsKey("workspace"); + var hasPackage = tomlTable.ContainsKey("package"); + + return hasWorkspace && !hasPackage; + } + catch (Exception e) + { + this.Logger.LogWarning(e, "Failed to check if {Path} is workspace-only", cargoTomlPath); + return false; + } + } + + /// + /// Processes a Cargo SBOM file asynchronously by parsing it and recording the lockfile version if available. + /// + /// The process request containing the component stream for the SBOM file. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. + /// + /// This method delegates parsing to the and records the lockfile version + /// in telemetry if the parsing operation returns a version number. + /// + private async Task ProcessSbomFileAsync(ProcessRequest processRequest, CancellationToken cancellationToken) + { + // Just before calling ParseAsync + this.Logger.LogDebug( + "SBOM parse starting. Recorder manifest location = {ManifestLocation}; SBOM stream location = {StreamLocation}", + processRequest.SingleFileComponentRecorder.ManifestFileLocation, + processRequest.ComponentStream.Location); + var version = await this.sbomParser.ParseAsync( + processRequest.ComponentStream, + processRequest.SingleFileComponentRecorder, + cancellationToken); + + if (version.HasValue) + { + this.RecordLockfileVersion(version.Value); + } + } + + /// + /// Processes a Cargo.toml file asynchronously by attempting to execute the Cargo CLI for metadata extraction. + /// + /// The process request containing the component stream for the Cargo.toml file. + /// The directory path where the Cargo.toml file is located. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. + /// + /// This method delegates parsing to the which executes the 'cargo metadata' command. + /// If the CLI parsing is successful, the method: + /// + /// Adds all local package directories found in the workspace to the visited directories set to prevent duplicate processing. + /// Marks the current directory as visited. + /// + /// If the CLI parsing fails, the directory is not marked as visited, allowing fallback to Cargo.lock parsing if available. + /// + private async Task ProcessCargoTomlAsync(ProcessRequest processRequest, string directory, CancellationToken cancellationToken) + { + var result = await this.cliParser.ParseAsync( + processRequest.ComponentStream, + processRequest.SingleFileComponentRecorder, + cancellationToken); + + if (result.Success) + { + // Add local package directories and current directory to visited + foreach (var dir in result.LocalPackageDirectories) + { + this.visitedDirs.Add(this.NormalizePath(dir)); + } + + this.visitedDirs.Add(this.NormalizePath(directory)); + } + } + + /// + /// Processes a Cargo.lock file asynchronously by parsing it and extracting component information. + /// + /// The process request containing the component stream for the Cargo.lock file. + /// The directory path where the Cargo.lock file is located. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. + /// + /// This method performs the following steps: + /// + /// Delegates parsing of the Cargo.lock file to the . + /// If parsing is successful and returns a lockfile version, records the version in telemetry. + /// Checks if a corresponding Cargo.toml file exists in the same directory. + /// If Cargo.toml exists, parses its workspace tables to extract member and exclude patterns. + /// Marks the current directory as visited to prevent duplicate processing. + /// + /// The workspace table processing enables proper glob-based filtering of workspace members in subsequent detection operations. + /// + private async Task ProcessCargoLockAsync(ProcessRequest processRequest, string directory, CancellationToken cancellationToken) + { + var version = await this.cargoLockParser.ParseAsync( + processRequest.ComponentStream, + processRequest.SingleFileComponentRecorder, + cancellationToken); + + if (version.HasValue) + { + this.RecordLockfileVersion(version.Value); + + // Check if Cargo.toml exists in same directory to parse workspace tables + var cargoTomlPath = Path.Combine(directory, "Cargo.toml"); + if (File.Exists(cargoTomlPath)) + { + await this.ProcessWorkspaceTablesAsync(cargoTomlPath, directory); + } + + // Add current directory to visitedDirs + this.visitedDirs.Add(this.NormalizePath(directory)); + } + } + + /// + /// Processes the workspace tables from a Cargo.toml file asynchronously to extract member and exclude patterns. + /// + /// The full path to the Cargo.toml file to parse. + /// The directory path where the Cargo.toml file is located, used as the root for glob patterns. + /// A task that represents the asynchronous operation. + /// + /// This method parses the [workspace] section of a Cargo.toml file to extract: + /// + /// default-members or members arrays as include patterns for workspace members. + /// exclude array as exclude patterns for workspace members. + /// + /// If include patterns are found, they are added as a glob rule with the specified directory as the root. + /// This enables proper filtering of workspace members during subsequent detection operations. + /// If parsing fails, a warning is logged and the method continues without throwing an exception. + /// + private async Task ProcessWorkspaceTablesAsync(string cargoTomlPath, string directory) + { + try + { + var content = await File.ReadAllTextAsync(cargoTomlPath); + var tomlTable = Toml.ToModel(content, options: TomlOptions); + + if (tomlTable.ContainsKey("workspace") && tomlTable["workspace"] is TomlTable workspaceTable) + { + var includes = new List(); + var excludes = new List(); + + // Parse default-members or members + if (workspaceTable.ContainsKey("default-members") && workspaceTable["default-members"] is TomlArray defaultMembers) + { + includes.AddRange(defaultMembers.Cast()); + } + else if (workspaceTable.ContainsKey("members") && workspaceTable["members"] is TomlArray members) + { + includes.AddRange(members.Cast()); + } + + // Parse exclude + if (workspaceTable.ContainsKey("exclude") && workspaceTable["exclude"] is TomlArray excludeArray) + { + excludes.AddRange(excludeArray.Cast()); + } + + if (includes.Count > 0) + { + this.AddGlobRule(directory, includes, excludes); + } + } + } + catch (Exception e) + { + this.Logger.LogWarning(e, "Failed to parse workspace tables from {Path}", cargoTomlPath); + } + } + + /// + /// Represents a glob rule with root directory and include/exclude patterns. + /// + private class GlobRule + { + public string Root { get; set; } + + public List Includes { get; set; } + + public List Excludes { get; set; } + + public List IncludeGlobs { get; set; } + + public List ExcludeGlobs { get; set; } + } +} diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs index dedf38dc3..369c1c95c 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs @@ -138,6 +138,7 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // SPDX services.AddSingleton(); From 08fc8b2910d13c35526ad5abdc87c9cd89acee34 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Tue, 14 Oct 2025 19:53:22 -0700 Subject: [PATCH 04/23] Compute locations for registrations in SBOM --- .../rust/IRustMetadataContextBuilder.cs | 61 ++++++ .../rust/Parsers/RustSbomParser.cs | 141 ++++++++++++- .../rust/RustComponentDetector.cs | 63 +++++- .../rust/RustMetadataContextBuilder.cs | 195 ++++++++++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 1 + 5 files changed, 455 insertions(+), 6 deletions(-) create mode 100644 src/Microsoft.ComponentDetection.Detectors/rust/IRustMetadataContextBuilder.cs create mode 100644 src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/IRustMetadataContextBuilder.cs b/src/Microsoft.ComponentDetection.Detectors/rust/IRustMetadataContextBuilder.cs new file mode 100644 index 000000000..5aa59c270 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/rust/IRustMetadataContextBuilder.cs @@ -0,0 +1,61 @@ +namespace Microsoft.ComponentDetection.Detectors.Rust; + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +/// +/// Provides functionality to construct contextual metadata for Rust packages, +/// specifically mapping package (crate) names to the Cargo.toml manifests +/// that declare or reference them. +/// +public interface IRustMetadataContextBuilder +{ + /// + /// Builds a mapping of package (crate) names to the set of Cargo.toml files + /// (supplied in dependency resolution order) that either declare or reference them, + /// and returns additional ownership metadata. + /// + /// + /// An ordered enumeration of paths to Cargo.toml manifest files. The order should + /// reflect dependency resolution (e.g. workspace root manifests first, followed by members), + /// enabling deterministic ownership attribution. + /// + /// A token used to observe cancellation requests. + /// + /// A task that, when completed successfully, yields an + /// containing the package-to-manifest ownership mapping and the set of manifests that + /// represent locally declared (non-external) packages. + /// + /// + /// Implementations may perform file IO and parsing of TOML manifests; callers should + /// provide a cancellation token for responsiveness. Implementations are expected to be + /// case-insensitive with respect to crate and file path comparisons. + /// + public Task BuildPackageOwnershipMapAsync( + IEnumerable orderedTomlPaths, + CancellationToken cancellationToken); + + /// + /// Represents the result of building Rust package ownership metadata. + /// + public class OwnershipResult + { + /// + /// Gets or sets a mapping from a package (crate) name to all Cargo.toml manifest + /// paths that declare or reference that package. Keys are compared using + /// . + /// + public Dictionary> PackageToTomls { get; set; } + = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets the set of Cargo.toml manifest paths that declare local (non-external) + /// packages within the current workspace or solution. Entries are compared using + /// . + /// + public HashSet LocalPackageManifests { get; set; } + = new(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs index d2a562af1..d99d9dd86 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs @@ -29,7 +29,7 @@ public class RustSbomParser private readonly ILogger logger; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The logger. public RustSbomParser(ILogger logger) => this.logger = logger; @@ -112,6 +112,145 @@ private void ProcessDependency( } } + /// + /// Parses a Cargo SBOM file and registers each discovered component against all owning Cargo.toml recorders + /// using the provided ownership map (cargo metadata package id -> set of manifest paths). + /// Falls back to the supplied sbomRecorder when ownership info is absent. + /// + /// SBOM stream. + /// Recorder tied to the SBOM file (fallback target). + /// Root component recorder used to create (or reuse) per-manifest recorders. + /// Package ownership map from RustMetadataContextBuilder (may be null). + /// Cancellation token. + /// SBOM version or null on failure. + public async Task ParseWithOwnershipAsync( + IComponentStream componentStream, + ISingleFileComponentRecorder sbomRecorder, + IComponentRecorder parentComponentRecorder, + IReadOnlyDictionary> ownershipMap, + CancellationToken cancellationToken = default) + { + try + { + using var reader = new StreamReader(componentStream.Stream); + var cargoSbom = CargoSbom.FromJson(await reader.ReadToEndAsync(cancellationToken)); + this.ProcessCargoSbomWithOwnership( + cargoSbom, + componentStream, + sbomRecorder, + parentComponentRecorder, + ownershipMap); + return cargoSbom.Version; + } + catch (Exception e) + { + this.logger.LogError(e, "Failed to parse Cargo SBOM (ownership mode) '{FileLocation}'", componentStream.Location); + return null; + } + } + + private void ProcessCargoSbomWithOwnership( + CargoSbom sbom, + IComponentStream sbomStream, + ISingleFileComponentRecorder sbomRecorder, + IComponentRecorder parentComponentRecorder, + IReadOnlyDictionary> ownershipMap) + { + try + { + var visitedNodes = new HashSet(); + this.ProcessDependencyWithOwnership( + sbom, + sbom.Crates[sbom.Root], + sbomStream, + sbomRecorder, + parentComponentRecorder, + ownershipMap, + visitedNodes, + parent: null, + depth: 0); + } + catch (Exception e) + { + this.logger.LogError(e, "Failed to process Cargo SBOM (ownership mode) '{FileLocation}'", sbomStream.Location); + } + } + + private void ProcessDependencyWithOwnership( + CargoSbom sbom, + SbomCrate package, + IComponentStream sbomStream, + ISingleFileComponentRecorder sbomRecorder, + IComponentRecorder parentComponentRecorder, + IReadOnlyDictionary> ownershipMap, + HashSet visitedNodes, + CargoComponent parent, + int depth) + { + foreach (var dependency in package.Dependencies) + { + var depCrate = sbom.Crates[dependency.Index]; + var parentComponent = parent; + + if (this.ParsePackageIdSpec(depCrate.Id, out var component)) + { + if (component.Source == CratesIoSource) + { + parentComponent = component; + + // Determine ownership + var metadataId = depCrate.Id; + var ownersApplied = false; + + if (ownershipMap != null && + ownershipMap.TryGetValue(metadataId, out var owners) && + owners != null && owners.Count > 0) + { + ownersApplied = true; + foreach (var manifestPath in owners) + { + var ownerRecorder = parentComponentRecorder.CreateSingleFileComponentRecorder(manifestPath); + ownerRecorder.RegisterUsage( + new DetectedComponent(component), + isExplicitReferencedDependency: depth == 0, + parentComponentId: null, + isDevelopmentDependency: false); + } + } + + if (!ownersApplied) + { + // Fallback to SBOM recorder if no ownership info + sbomRecorder.RegisterUsage( + new DetectedComponent(component), + isExplicitReferencedDependency: depth == 0, + parentComponentId: null, + isDevelopmentDependency: false); + } + } + } + else + { + this.logger.LogError(null, "Failed to parse Cargo PackageIdSpec '{Id}' in '{Location}'", depCrate.Id, sbomStream.Location); + sbomRecorder.RegisterPackageParseFailure(depCrate.Id); + } + + if (visitedNodes.Add(dependency.Index)) + { + this.ProcessDependencyWithOwnership( + sbom, + depCrate, + sbomStream, + sbomRecorder, + parentComponentRecorder, + ownershipMap, + visitedNodes, + parentComponent, + depth + 1); + } + } + } + private bool ParsePackageIdSpec(string dependency, out CargoComponent component) { var match = CargoPackageIdRegex.Match(dependency); diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs index 7350af3e7..e26a59f80 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs @@ -29,10 +29,13 @@ public class RustComponentDetector : FileComponentDetector private readonly RustSbomParser sbomParser; private readonly RustCliParser cliParser; private readonly RustCargoLockParser cargoLockParser; + private readonly IRustMetadataContextBuilder metadataContextBuilder; + private readonly HashSet visitedDirs; private readonly List visitedGlobRules; private readonly StringComparer pathComparer; private readonly StringComparison pathComparison; + private IReadOnlyDictionary> ownershipMap; private DetectionMode mode; /// @@ -43,12 +46,14 @@ public class RustComponentDetector : FileComponentDetector /// The command line invocation service. /// The environment variable service. /// The logger. + /// Rust meta data context builder. public RustComponentDetector( IComponentStreamEnumerableFactory componentStreamEnumerableFactory, IObservableDirectoryWalkerFactory walkerFactory, ICommandLineInvocationService cliService, IEnvironmentVariableService envVarService, - ILogger logger) + ILogger logger, + IRustMetadataContextBuilder metadataContextBuilder) { this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; this.Scanner = walkerFactory; @@ -58,6 +63,7 @@ public RustComponentDetector( this.sbomParser = new RustSbomParser(logger); this.cliParser = new RustCliParser(cliService, envVarService, logger); this.cargoLockParser = new RustCargoLockParser(logger); + this.metadataContextBuilder = metadataContextBuilder; // Initialize with case-insensitive comparison on Windows this.pathComparer = OperatingSystem.IsWindows() @@ -139,6 +145,39 @@ protected override async Task> OnPrepareDetectionAsy this.Logger.LogInformation("Detection mode: {Mode}", this.mode); + if (this.mode == DetectionMode.SBOM_ONLY) + { + try + { + // Gather Cargo.toml files (we do not filter out workspace-only; metadata handles coverage) + var tomlPaths = allRequests + .Select(r => r.ComponentStream.Location) + .Where(p => string.Equals(Path.GetFileName(p), "Cargo.toml", StringComparison.OrdinalIgnoreCase)) + .OrderBy(p => this.GetDirectoryDepth(p)) + .ThenBy(p => p, StringComparer.Ordinal) + .ToList(); + + if (tomlPaths.Count > 0) + { + this.Logger.LogInformation("Building Rust ownership map from {Count} Cargo.toml files", tomlPaths.Count); + var ownership = await this.metadataContextBuilder.BuildPackageOwnershipMapAsync(tomlPaths, cancellationToken); + + // Primary map (cargo metadata package IDs) + this.ownershipMap = ownership.PackageToTomls; + } + else + { + this.Logger.LogInformation("No Cargo.toml files found; SBOM ownership mapping unavailable"); + this.ownershipMap = null; + } + } + catch (Exception ex) + { + this.Logger.LogWarning(ex, "Failed to compute Rust ownership map; proceeding without ownership"); + this.ownershipMap = null; + } + } + // Step 3: Filter and order candidates based on mode IEnumerable filteredRequests; @@ -506,10 +545,24 @@ private async Task ProcessSbomFileAsync(ProcessRequest processRequest, Cancellat "SBOM parse starting. Recorder manifest location = {ManifestLocation}; SBOM stream location = {StreamLocation}", processRequest.SingleFileComponentRecorder.ManifestFileLocation, processRequest.ComponentStream.Location); - var version = await this.sbomParser.ParseAsync( - processRequest.ComponentStream, - processRequest.SingleFileComponentRecorder, - cancellationToken); + + int? version; + if (this.ownershipMap != null) + { + version = await this.sbomParser.ParseWithOwnershipAsync( + processRequest.ComponentStream, + processRequest.SingleFileComponentRecorder, + this.ComponentRecorder, + this.ownershipMap, + cancellationToken); + } + else + { + version = await this.sbomParser.ParseAsync( + processRequest.ComponentStream, + processRequest.SingleFileComponentRecorder, + cancellationToken); + } if (version.HasValue) { diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs new file mode 100644 index 000000000..f88d31542 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs @@ -0,0 +1,195 @@ +namespace Microsoft.ComponentDetection.Detectors.Rust; + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Detectors.Rust.Contracts; +using Microsoft.Extensions.Logging; +using static Microsoft.ComponentDetection.Detectors.Rust.IRustMetadataContextBuilder; + +public class RustMetadataContextBuilder : IRustMetadataContextBuilder +{ + private readonly ILogger logger; + private readonly ICommandLineInvocationService cliService; + + public RustMetadataContextBuilder(ILogger logger, ICommandLineInvocationService cliService) + { + this.logger = logger; + this.cliService = cliService; + } + + public async Task BuildPackageOwnershipMapAsync( + IEnumerable orderedTomlPaths, + CancellationToken cancellationToken = default) + { + // aggregated ownership across all TOMLs + var aggregate = new OwnershipResult(); + var visitedManifests = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var toml in orderedTomlPaths ?? []) + { + var normToml = this.NormalizePath(toml); + if (visitedManifests.Contains(normToml)) + { + this.logger.LogDebug("Skipping {Toml} (already visited)", toml); + continue; + } + + var metadata = await this.RunCargoMetadataAsync(toml, cancellationToken); + if (metadata == null) + { + this.logger.LogWarning("Skipping TOML due to cargo metadata failure: {Toml}", toml); + continue; + } + + // Compute ownership information just from metadata of this TOML + var result = this.BuildOwnershipFromMetadata(metadata); + + // Merge results into global aggregate + foreach (var (pkgId, owners) in result.PackageToTomls) + { + if (!aggregate.PackageToTomls.TryGetValue(pkgId, out var globalOwners)) + { + globalOwners = new HashSet(StringComparer.OrdinalIgnoreCase); + aggregate.PackageToTomls[pkgId] = globalOwners; + } + + foreach (var ownerToml in owners) + { + globalOwners.Add(ownerToml); + } + } + + foreach (var localManifest in result.LocalPackageManifests) + { + aggregate.LocalPackageManifests.Add(localManifest); + visitedManifests.Add(localManifest); + } + + this.logger.LogInformation( + "Processed {Toml}: +{LocalCount} local manifests, +{DepCount} deps (aggregate: {AggLocal} manifests, {AggDeps} deps)", + toml, + result.LocalPackageManifests.Count, + result.PackageToTomls.Count, + aggregate.LocalPackageManifests.Count, + aggregate.PackageToTomls.Count); + } + + return aggregate; + } + + private OwnershipResult BuildOwnershipFromMetadata(CargoMetadata metadata) + { + // Step 0: Build dependency graph (package -> deps) + var graph = metadata.Resolve.Nodes.ToDictionary( + n => n.Id, + n => n.Deps.Select(d => d.Pkg).ToList()); + + var ownership = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var localManifests = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Step 1: Gather all local packages (user-owned TOMLs) + foreach (var pkg in metadata.Packages.Where(p => p.Source == null && !string.IsNullOrEmpty(p.ManifestPath))) + { + var manifestPath = this.NormalizePath(pkg.ManifestPath); + localManifests.Add(manifestPath); + ownership[pkg.Id] = [manifestPath]; + } + + // Step 2: Initialize multi-source BFS queue + inQueue tracker + var queue = new Queue(); + var inQueue = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var id in ownership.Keys) + { + queue.Enqueue(id); + inQueue.Add(id); + } + + // Step 3: Propagate ownership + while (queue.Count > 0) + { + var currentId = queue.Dequeue(); + inQueue.Remove(currentId); // mark as not queued + + if (!graph.TryGetValue(currentId, out var deps)) + { + continue; + } + + var currentOwners = ownership[currentId]; + + foreach (var depId in deps) + { + if (!ownership.TryGetValue(depId, out var depOwners)) + { + depOwners = new HashSet(StringComparer.OrdinalIgnoreCase); + ownership[depId] = depOwners; + } + + var beforeCount = depOwners.Count; + depOwners.UnionWith(currentOwners); + + // If ownership expanded and the dep isn't already in queue, enqueue it + if (depOwners.Count > beforeCount && !inQueue.Contains(depId)) + { + queue.Enqueue(depId); + inQueue.Add(depId); + } + } + } + + this.logger.LogDebug( + "Computed ownership for workspace: {LocalCount} local packages, {DepCount} total deps", + localManifests.Count, + ownership.Count); + + return new OwnershipResult + { + PackageToTomls = ownership, + LocalPackageManifests = localManifests, + }; + } + + private async Task RunCargoMetadataAsync(string manifestPath, CancellationToken token) + { + if (!await this.cliService.CanCommandBeLocatedAsync("cargo", null)) + { + this.logger.LogWarning("Cargo not found while processing {Toml}", manifestPath); + return null; + } + + var res = await this.cliService.ExecuteCommandAsync( + "cargo", + additionalCandidateCommands: null, + workingDirectory: null, + cancellationToken: token, + "metadata", + "--manifest-path", + manifestPath, + "--format-version=1", + "--locked"); + + if (res.ExitCode != 0) + { + this.logger.LogWarning("`cargo metadata` failed for {Toml}: {Err}", manifestPath, res.StdErr); + return null; + } + + return CargoMetadata.FromJson(res.StdOut); + } + + private string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return path; + } + + var s = path.Replace('\\', '/'); + return OperatingSystem.IsWindows() ? s.ToUpperInvariant() : s; + } +} diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs index 369c1c95c..8d998da6e 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs @@ -54,6 +54,7 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Command line services services.AddSingleton(); From bee435a26316e37ddf8a1c82b68d7f4524fb0237 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Wed, 15 Oct 2025 13:45:30 -0700 Subject: [PATCH 05/23] Add interface for rust cli parsing --- .../rust/Parsers/IRustCliParser.cs | 33 ++++ .../rust/Parsers/RustCliParser.cs | 183 ++++++++++++------ .../Extensions/ServiceCollectionExtensions.cs | 1 + 3 files changed, 154 insertions(+), 63 deletions(-) create mode 100644 src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustCliParser.cs diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustCliParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustCliParser.cs new file mode 100644 index 000000000..2f68203a5 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustCliParser.cs @@ -0,0 +1,33 @@ +namespace Microsoft.ComponentDetection.Detectors.Rust; + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Detectors.Rust.Contracts; +using static Microsoft.ComponentDetection.Detectors.Rust.RustCliParser; + +public interface IRustCliParser +{ + /// + /// Parses a Cargo.toml file by invoking 'cargo metadata'. + /// + /// The component stream containing the Cargo.toml file. + /// The component recorder. + /// Cancellation token. + /// Parse result containing success status and local package directories. + public Task ParseAsync( + IComponentStream componentStream, + ISingleFileComponentRecorder recorder, + CancellationToken cancellationToken); + + /// + /// Parses a Cargo.toml file using a previously obtained CargoMetadata (cached output). + /// Avoids re-running the cargo command. + /// + /// Result of parsing cargo metadata. + public Task ParseFromMetadataAsync( + IComponentStream componentStream, + ISingleFileComponentRecorder recorder, + CargoMetadata cachedMetadata, + CancellationToken cancellationToken); +} diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs index 78891e539..2f0f6e632 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs @@ -13,13 +13,14 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; using Microsoft.Extensions.Logging; /// -/// Parser for Cargo.toml files using cargo metadata command. +/// Parser for Cargo.toml files using cargo metadata command or cached metadata. /// -public class RustCliParser +public class RustCliParser : IRustCliParser { private readonly ICommandLineInvocationService cliService; private readonly IEnvironmentVariableService envVarService; private readonly ILogger logger; + private readonly IPathUtilityService pathUtilityService; /// /// Initializes a new instance of the class. @@ -27,18 +28,21 @@ public class RustCliParser /// The command line invocation service. /// The environment variable service. /// The logger. + /// Path utility service. public RustCliParser( ICommandLineInvocationService cliService, IEnvironmentVariableService envVarService, - ILogger logger) + ILogger logger, + IPathUtilityService pathUtilityService) { this.cliService = cliService; this.envVarService = envVarService; this.logger = logger; + this.pathUtilityService = pathUtilityService; } /// - /// Parses a Cargo.toml file using cargo metadata command. + /// Parses a Cargo.toml file by invoking 'cargo metadata'. /// /// The component stream containing the Cargo.toml file. /// The component recorder. @@ -90,76 +94,128 @@ public async Task ParseAsync( } var metadata = CargoMetadata.FromJson(cliResult.StdOut); - var graph = this.BuildGraph(metadata); + return this.ProcessMetadata(componentStream.Location, recorder, metadata); + } + catch (Exception e) + { + this.logger.LogWarning(e, "Failed to run cargo metadata for {Location}", componentStream.Location); + result.ErrorMessage = e.Message; + result.FailureReason = "Exception during cargo metadata"; + return result; + } + } - var packages = metadata.Packages.ToDictionary( - x => $"{x.Id}", - x => new CargoComponent( - x.Name, - x.Version, - (x.Authors == null || x.Authors.Any(a => string.IsNullOrWhiteSpace(a)) || x.Authors.Length == 0) ? null : string.Join(", ", x.Authors), - string.IsNullOrWhiteSpace(x.License) ? null : x.License, - x.Source)); + /// + /// Parses a Cargo.toml file using a previously obtained CargoMetadata (cached output). + /// Avoids re-running the cargo command. + /// + /// Result of parsing cargo metadata. + public Task ParseFromMetadataAsync( + IComponentStream componentStream, + ISingleFileComponentRecorder recorder, + CargoMetadata cachedMetadata, + CancellationToken cancellationToken = default) + { + // Cancellation token is unused here since we do no IO, but kept for API symmetry. + var result = new ParseResult(); - var root = metadata.Resolve.Root; - HashSet visitedDependencies = []; + if (cachedMetadata == null) + { + result.FailureReason = "Cached metadata unavailable"; + return Task.FromResult(result); + } - if (root == null) - { - this.logger.LogInformation("Virtual Manifest detected: {Location}", componentStream.Location); - foreach (var dep in metadata.Resolve.Nodes) - { - var componentKey = $"{dep.Id}"; - if (visitedDependencies.Add(componentKey)) - { - this.TraverseAndRecordComponents( - recorder, - componentStream.Location, - graph, - dep.Id, - null, - null, - packages, - visitedDependencies, - explicitlyReferencedDependency: false); - } - } - } - else - { - this.TraverseAndRecordComponents( - recorder, - componentStream.Location, - graph, - root, - null, - null, - packages, - visitedDependencies, - explicitlyReferencedDependency: true, - isTomlRoot: true); - } + if (this.IsRustCliManuallyDisabled()) + { + this.logger.LogInformation("Rust CLI manually disabled (cached path) for {Location}", componentStream.Location); + result.FailureReason = "Manually Disabled"; + return Task.FromResult(result); + } - // Collect local package directories - foreach (var package in metadata.Packages.Where(p => p.Source == null)) + try + { + return Task.FromResult(this.ProcessMetadata(componentStream.Location, recorder, cachedMetadata)); + } + catch (Exception e) + { + this.logger.LogWarning(e, "Failed processing cached cargo metadata for {Location}", componentStream.Location); + result.ErrorMessage = e.Message; + result.FailureReason = "Exception during cached cargo metadata processing"; + return Task.FromResult(result); + } + } + + /// + /// Shared implementation to translate CargoMetadata into registered components and a ParseResult. + /// + private ParseResult ProcessMetadata( + string manifestLocation, + ISingleFileComponentRecorder recorder, + CargoMetadata metadata) + { + var result = new ParseResult(); + + var graph = this.BuildGraph(metadata); + + var packages = metadata.Packages.ToDictionary( + x => $"{x.Id}", + x => new CargoComponent( + x.Name, + x.Version, + (x.Authors == null || x.Authors.Any(a => string.IsNullOrWhiteSpace(a)) || x.Authors.Length == 0) ? null : string.Join(", ", x.Authors), + string.IsNullOrWhiteSpace(x.License) ? null : x.License, + x.Source)); + + var root = metadata.Resolve.Root; + HashSet visitedDependencies = []; + + if (root == null) + { + this.logger.LogInformation("Virtual Manifest detected: {Location}", manifestLocation); + foreach (var dep in metadata.Resolve.Nodes) { - var pkgDir = Path.GetDirectoryName(package.ManifestPath); - if (!string.IsNullOrEmpty(pkgDir)) + var componentKey = $"{dep.Id}"; + if (visitedDependencies.Add(componentKey)) { - result.LocalPackageDirectories.Add(pkgDir); + this.TraverseAndRecordComponents( + recorder, + manifestLocation, + graph, + dep.Id, + null, + null, + packages, + visitedDependencies, + explicitlyReferencedDependency: false); } } - - result.Success = true; - return result; } - catch (Exception e) + else { - this.logger.LogWarning(e, "Failed to run cargo metadata for {Location}", componentStream.Location); - result.ErrorMessage = e.Message; - result.FailureReason = "Exception during cargo metadata"; - return result; + this.TraverseAndRecordComponents( + recorder, + manifestLocation, + graph, + root, + null, + null, + packages, + visitedDependencies, + explicitlyReferencedDependency: true, + isTomlRoot: true); } + + foreach (var package in metadata.Packages.Where(p => p.Source == null)) + { + var pkgDir = Path.GetDirectoryName(package.ManifestPath); + if (!string.IsNullOrEmpty(pkgDir)) + { + result.LocalPackageDirectories.Add(this.pathUtilityService.NormalizePath(pkgDir)); + } + } + + result.Success = true; + return result; } private Dictionary BuildGraph(CargoMetadata cargoMetadata) => @@ -255,6 +311,7 @@ public class ParseResult /// /// Gets or sets the local package directories that should be marked as visited. + /// This allows upstream client to skip TOMLs that were already accounted for in this run. /// public HashSet LocalPackageDirectories { get; set; } = []; } diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs index 8d998da6e..8e62aadaa 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs @@ -55,6 +55,7 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Command line services services.AddSingleton(); From 93ecd2bfd09288e1e6597a42e04cedef6a5e6b6f Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Wed, 15 Oct 2025 13:45:45 -0700 Subject: [PATCH 06/23] Add support to use cached metadata in rust cli parser --- .../rust/IRustMetadataContextBuilder.cs | 38 ++--- .../rust/RustComponentDetector.cs | 151 +++++++++--------- .../rust/RustMetadataContextBuilder.cs | 39 +++-- 3 files changed, 111 insertions(+), 117 deletions(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/IRustMetadataContextBuilder.cs b/src/Microsoft.ComponentDetection.Detectors/rust/IRustMetadataContextBuilder.cs index 5aa59c270..b332805ce 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/IRustMetadataContextBuilder.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/IRustMetadataContextBuilder.cs @@ -4,6 +4,7 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Microsoft.ComponentDetection.Detectors.Rust.Contracts; /// /// Provides functionality to construct contextual metadata for Rust packages, @@ -15,24 +16,15 @@ public interface IRustMetadataContextBuilder /// /// Builds a mapping of package (crate) names to the set of Cargo.toml files /// (supplied in dependency resolution order) that either declare or reference them, - /// and returns additional ownership metadata. + /// and returns additional ownership metadata. Also returns a cache of raw Cargo metadata + /// per manifest so downstream detection code can avoid invoking cargo metadata again. /// /// /// An ordered enumeration of paths to Cargo.toml manifest files. The order should - /// reflect dependency resolution (e.g. workspace root manifests first, followed by members), - /// enabling deterministic ownership attribution. + /// reflect dependency resolution (e.g. workspace root manifests first, followed by members). /// /// A token used to observe cancellation requests. - /// - /// A task that, when completed successfully, yields an - /// containing the package-to-manifest ownership mapping and the set of manifests that - /// represent locally declared (non-external) packages. - /// - /// - /// Implementations may perform file IO and parsing of TOML manifests; callers should - /// provide a cancellation token for responsiveness. Implementations are expected to be - /// case-insensitive with respect to crate and file path comparisons. - /// + /// An with ownership and per-manifest metadata cache. public Task BuildPackageOwnershipMapAsync( IEnumerable orderedTomlPaths, CancellationToken cancellationToken); @@ -43,19 +35,27 @@ public Task BuildPackageOwnershipMapAsync( public class OwnershipResult { /// - /// Gets or sets a mapping from a package (crate) name to all Cargo.toml manifest - /// paths that declare or reference that package. Keys are compared using - /// . + /// Mapping from a package (crate) id to all Cargo.toml manifest + /// paths that declare or reference that package. /// public Dictionary> PackageToTomls { get; set; } = new(StringComparer.OrdinalIgnoreCase); /// - /// Gets or sets the set of Cargo.toml manifest paths that declare local (non-external) - /// packages within the current workspace or solution. Entries are compared using - /// . + /// Set of Cargo.toml manifest paths that declare local packages. /// public HashSet LocalPackageManifests { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Raw cargo metadata JSON (already parsed) per manifest path (normalized). + /// This enables downstream detectors to reuse metadata without issuing + /// another CLI call. + /// + public Dictionary ManifestToMetadata { get; set; } + = new(StringComparer.OrdinalIgnoreCase); + + // Manifests for which cargo metadata failed. + public HashSet FailedManifests { get; set; } = new(StringComparer.OrdinalIgnoreCase); } } diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs index e26a59f80..3e31e43db 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs @@ -26,6 +26,7 @@ public class RustComponentDetector : FileComponentDetector IgnoreMissingProperties = true, }; + private readonly IPathUtilityService pathUtilityService; private readonly RustSbomParser sbomParser; private readonly RustCliParser cliParser; private readonly RustCargoLockParser cargoLockParser; @@ -36,6 +37,7 @@ public class RustComponentDetector : FileComponentDetector private readonly StringComparer pathComparer; private readonly StringComparison pathComparison; private IReadOnlyDictionary> ownershipMap; + private Dictionary manifestMetadataCache; private DetectionMode mode; /// @@ -47,17 +49,20 @@ public class RustComponentDetector : FileComponentDetector /// The environment variable service. /// The logger. /// Rust meta data context builder. + /// Path utility service. public RustComponentDetector( IComponentStreamEnumerableFactory componentStreamEnumerableFactory, IObservableDirectoryWalkerFactory walkerFactory, ICommandLineInvocationService cliService, IEnvironmentVariableService envVarService, ILogger logger, - IRustMetadataContextBuilder metadataContextBuilder) + IRustMetadataContextBuilder metadataContextBuilder, + IPathUtilityService pathUtilityService) { this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; this.Scanner = walkerFactory; this.Logger = logger; + this.pathUtilityService = pathUtilityService; // Initialize parsers this.sbomParser = new RustSbomParser(logger); @@ -74,6 +79,7 @@ public RustComponentDetector( : StringComparison.Ordinal; this.visitedDirs = new HashSet(this.pathComparer); this.visitedGlobRules = []; + this.manifestMetadataCache = new Dictionary(this.pathComparer); } /// @@ -145,42 +151,50 @@ protected override async Task> OnPrepareDetectionAsy this.Logger.LogInformation("Detection mode: {Mode}", this.mode); - if (this.mode == DetectionMode.SBOM_ONLY) + // Collect Cargo.toml paths ordered (depth, then path) + var tomlPaths = allRequests + .Select(r => r.ComponentStream.Location) + .Where(p => string.Equals(Path.GetFileName(p), "Cargo.toml", StringComparison.OrdinalIgnoreCase)) + .OrderBy(p => this.GetDirectoryDepth(p)) + .ThenBy(p => p, StringComparer.Ordinal) + .ToList(); + + if (tomlPaths.Count > 0) { try { - // Gather Cargo.toml files (we do not filter out workspace-only; metadata handles coverage) - var tomlPaths = allRequests - .Select(r => r.ComponentStream.Location) - .Where(p => string.Equals(Path.GetFileName(p), "Cargo.toml", StringComparison.OrdinalIgnoreCase)) - .OrderBy(p => this.GetDirectoryDepth(p)) - .ThenBy(p => p, StringComparer.Ordinal) - .ToList(); - - if (tomlPaths.Count > 0) - { - this.Logger.LogInformation("Building Rust ownership map from {Count} Cargo.toml files", tomlPaths.Count); - var ownership = await this.metadataContextBuilder.BuildPackageOwnershipMapAsync(tomlPaths, cancellationToken); - - // Primary map (cargo metadata package IDs) - this.ownershipMap = ownership.PackageToTomls; - } - else + this.Logger.LogInformation("Building Rust ownership map from {Count} Cargo.toml files", tomlPaths.Count); + var ownership = await this.metadataContextBuilder.BuildPackageOwnershipMapAsync(tomlPaths, cancellationToken); + this.ownershipMap = ownership.PackageToTomls; + this.manifestMetadataCache = ownership.ManifestToMetadata; + this.Logger.LogInformation( + "Loaded Rust ownership (packages: {PkgCount}) and metadata cache (manifests: {ManifestCount})", + this.ownershipMap?.Count ?? 0, + this.manifestMetadataCache?.Count ?? 0); + + if (ownership.FailedManifests?.Count > 0) { - this.Logger.LogInformation("No Cargo.toml files found; SBOM ownership mapping unavailable"); - this.ownershipMap = null; + this.Logger.LogInformation( + "Rust metadata failed for {Count} manifests (will rely on lockfiles): {Manifests}", + ownership.FailedManifests.Count, + string.Join(", ", ownership.FailedManifests)); } } catch (Exception ex) { - this.Logger.LogWarning(ex, "Failed to compute Rust ownership map; proceeding without ownership"); + this.Logger.LogWarning(ex, "Failed to compute Rust ownership/metadata cache; proceeding without cache"); this.ownershipMap = null; + this.manifestMetadataCache = null; } } + else + { + this.Logger.LogInformation("No Cargo.toml files found; ownership and metadata cache unavailable"); + this.ownershipMap = null; + this.manifestMetadataCache = null; + } - // Step 3: Filter and order candidates based on mode IEnumerable filteredRequests; - if (this.mode == DetectionMode.SBOM_ONLY) { // Only SBOM files, ordered by path ascending @@ -204,8 +218,7 @@ protected override async Task> OnPrepareDetectionAsy .OrderBy(r => Path.GetFileName(r.ComponentStream.Location).Equals("Cargo.lock", StringComparison.OrdinalIgnoreCase) ? 1 : 0) // TOML before LOCK .ThenBy(r => this.GetDirectoryDepth(r.ComponentStream.Location)) .ThenBy(r => r.ComponentStream.Location, StringComparer.Ordinal); - - this.Logger.LogInformation("FALLBACK mode: Processing {Count} Cargo.toml and Cargo.lock files", filteredRequests.Count()); + this.Logger.LogInformation("FALLBACK mode: Processing {Count} Cargo.toml/Cargo.lock files", filteredRequests.Count()); } // Step 4: Return the ordered sequence as an observable @@ -220,7 +233,9 @@ protected override async Task OnFileFoundAsync( { var componentStream = processRequest.ComponentStream; var location = componentStream.Location; + var normLocation = this.pathUtilityService.NormalizePath(location); var directory = Path.GetDirectoryName(location); + var normDirectory = this.pathUtilityService.NormalizePath(directory); var fileName = Path.GetFileName(location); this.Logger.LogInformation("Processing file: {Location}", location); @@ -243,7 +258,7 @@ protected override async Task OnFileFoundAsync( // Check if directory should be skipped if (this.ShouldSkip(directory, fileKind, location)) { - this.Logger.LogInformation("Skipping file due to skip rules: {Location}", location); + this.Logger.LogInformation("Skipping file due to skip rules: {Location}", normLocation); return; } @@ -265,33 +280,6 @@ protected override async Task OnFileFoundAsync( } } - /// - /// Normalizes a file path for consistent comparison across platforms. - /// - /// The path to normalize. - /// - /// A normalized path with forward slashes. On Windows, the path is converted to uppercase for case-insensitive comparison. - /// Returns the original path if it is null or empty. - /// - private string NormalizePath(string path) - { - if (string.IsNullOrEmpty(path)) - { - return path; - } - - // Normalize to forward slashes and handle case sensitivity - var normalized = path.Replace('\\', '/'); - - // On Windows, normalize to uppercase for comparison - if (OperatingSystem.IsWindows()) - { - normalized = normalized.ToUpperInvariant(); - } - - return normalized; - } - /// /// Calculates the depth of a directory path by counting the number of directory separators. /// @@ -311,8 +299,8 @@ private int GetDirectoryDepth(string path) return 0; } - var normalizedPath = this.NormalizePath(path); - return normalizedPath.Count(c => c == '/'); + var normalizedPath = this.pathUtilityService.NormalizePath(path); + return normalizedPath.Count(c => c == Path.AltDirectorySeparatorChar); } /// @@ -334,7 +322,7 @@ private int GetDirectoryDepth(string path) /// private bool ShouldSkip(string directory, FileKind fileKind, string fullPath) { - var normalizedDir = this.NormalizePath(directory); + var normalizedDir = this.pathUtilityService.NormalizePath(directory); // 1. If directory already processed, skip immediately if (this.visitedDirs.Contains(normalizedDir)) @@ -348,7 +336,7 @@ private bool ShouldSkip(string directory, FileKind fileKind, string fullPath) return false; } - var normalizedFullPath = this.NormalizePath(fullPath); + var normalizedFullPath = this.pathUtilityService.NormalizePath(fullPath); // 3. Check each workspace rule for inclusion/exclusion foreach (var rule in this.visitedGlobRules) @@ -398,7 +386,7 @@ private bool ShouldSkip(string directory, FileKind fileKind, string fullPath) /// private void AddGlobRule(string root, IEnumerable includes, IEnumerable excludes) { - var normalizedRoot = this.NormalizePath(root); + var normalizedRoot = this.pathUtilityService.NormalizePath(root); var includesList = includes?.ToList() ?? []; var excludesList = excludes?.ToList() ?? []; @@ -413,14 +401,14 @@ private void AddGlobRule(string root, IEnumerable includes, IEnumerable< var includeGlobs = new List(); foreach (var pattern in includesList) { - var normalizedPattern = this.NormalizePath(pattern); + var normalizedPattern = this.pathUtilityService.NormalizePath(pattern); includeGlobs.Add(Glob.Parse(normalizedPattern, globOptions)); } var excludeGlobs = new List(); foreach (var pattern in excludesList) { - var normalizedPattern = this.NormalizePath(pattern); + var normalizedPattern = this.pathUtilityService.NormalizePath(pattern); excludeGlobs.Add(Glob.Parse(normalizedPattern, globOptions)); } @@ -451,8 +439,8 @@ private void AddGlobRule(string root, IEnumerable includes, IEnumerable< /// private bool IsDescendantOf(string path, string potentialParent) { - var normalizedPath = this.NormalizePath(path); - var normalizedParent = this.NormalizePath(potentialParent); + var normalizedPath = this.pathUtilityService.NormalizePath(path); + var normalizedParent = this.pathUtilityService.NormalizePath(potentialParent); // Ensure parent path ends with separator for proper comparison if (!normalizedParent.EndsWith('/')) @@ -479,8 +467,8 @@ private bool IsDescendantOf(string path, string potentialParent) /// private string GetRelativePath(string basePath, string fullPath) { - var normalizedBase = this.NormalizePath(basePath); - var normalizedFull = this.NormalizePath(fullPath); + var normalizedBase = this.pathUtilityService.NormalizePath(basePath); + var normalizedFull = this.pathUtilityService.NormalizePath(fullPath); if (!normalizedBase.EndsWith('/')) { @@ -588,20 +576,29 @@ private async Task ProcessSbomFileAsync(ProcessRequest processRequest, Cancellat /// private async Task ProcessCargoTomlAsync(ProcessRequest processRequest, string directory, CancellationToken cancellationToken) { - var result = await this.cliParser.ParseAsync( - processRequest.ComponentStream, - processRequest.SingleFileComponentRecorder, - cancellationToken); - - if (result.Success) + var normalized = this.pathUtilityService.NormalizePath(processRequest.ComponentStream.Location); + if (this.manifestMetadataCache != null && + this.manifestMetadataCache.TryGetValue(normalized, out var cachedMetadata)) { - // Add local package directories and current directory to visited - foreach (var dir in result.LocalPackageDirectories) + this.Logger.LogDebug("Using cached cargo metadata for {Location}", normalized); + var result = await this.cliParser.ParseFromMetadataAsync( + processRequest.ComponentStream, + processRequest.SingleFileComponentRecorder, + cachedMetadata, + cancellationToken); + if (result.Success) { - this.visitedDirs.Add(this.NormalizePath(dir)); - } + foreach (var dir in result.LocalPackageDirectories) + { + this.visitedDirs.Add(this.pathUtilityService.NormalizePath(dir)); + } - this.visitedDirs.Add(this.NormalizePath(directory)); + this.visitedDirs.Add(this.pathUtilityService.NormalizePath(directory)); + } + } + else + { + this.Logger.LogWarning("No cached cargo metadata for {Location}", processRequest.ComponentStream.Location); } } @@ -642,7 +639,7 @@ private async Task ProcessCargoLockAsync(ProcessRequest processRequest, string d } // Add current directory to visitedDirs - this.visitedDirs.Add(this.NormalizePath(directory)); + this.visitedDirs.Add(this.pathUtilityService.NormalizePath(directory)); } } diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs index f88d31542..e988bf31e 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs @@ -14,41 +14,47 @@ public class RustMetadataContextBuilder : IRustMetadataContextBuilder { private readonly ILogger logger; private readonly ICommandLineInvocationService cliService; + private readonly IPathUtilityService pathUtilityService; - public RustMetadataContextBuilder(ILogger logger, ICommandLineInvocationService cliService) + public RustMetadataContextBuilder( + ILogger logger, + ICommandLineInvocationService cliService, + IPathUtilityService pathUtilityService) { this.logger = logger; this.cliService = cliService; + this.pathUtilityService = pathUtilityService; } public async Task BuildPackageOwnershipMapAsync( IEnumerable orderedTomlPaths, CancellationToken cancellationToken = default) { - // aggregated ownership across all TOMLs var aggregate = new OwnershipResult(); var visitedManifests = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var toml in orderedTomlPaths ?? []) { - var normToml = this.NormalizePath(toml); + var normToml = this.pathUtilityService.NormalizePath(toml); if (visitedManifests.Contains(normToml)) { - this.logger.LogDebug("Skipping {Toml} (already visited)", toml); + this.logger.LogDebug("Skipping {Toml} (already visited)", normToml); continue; } var metadata = await this.RunCargoMetadataAsync(toml, cancellationToken); if (metadata == null) { - this.logger.LogWarning("Skipping TOML due to cargo metadata failure: {Toml}", toml); + this.logger.LogWarning("Skipping TOML due to cargo metadata failure: {Toml}", normToml); + aggregate.FailedManifests.Add(normToml); continue; } - // Compute ownership information just from metadata of this TOML + // Cache metadata for reuse (key by normalized manifest path) + aggregate.ManifestToMetadata[normToml] = metadata; + var result = this.BuildOwnershipFromMetadata(metadata); - // Merge results into global aggregate foreach (var (pkgId, owners) in result.PackageToTomls) { if (!aggregate.PackageToTomls.TryGetValue(pkgId, out var globalOwners)) @@ -70,12 +76,13 @@ public async Task BuildPackageOwnershipMapAsync( } this.logger.LogInformation( - "Processed {Toml}: +{LocalCount} local manifests, +{DepCount} deps (aggregate: {AggLocal} manifests, {AggDeps} deps)", + "Processed {Toml}: +{LocalCount} local manifests, +{DepCount} deps (aggregate: {AggLocal} manifests, {AggDeps} deps, {MetadataCache} cached)", toml, result.LocalPackageManifests.Count, result.PackageToTomls.Count, aggregate.LocalPackageManifests.Count, - aggregate.PackageToTomls.Count); + aggregate.PackageToTomls.Count, + aggregate.ManifestToMetadata.Count); } return aggregate; @@ -94,7 +101,7 @@ private OwnershipResult BuildOwnershipFromMetadata(CargoMetadata metadata) // Step 1: Gather all local packages (user-owned TOMLs) foreach (var pkg in metadata.Packages.Where(p => p.Source == null && !string.IsNullOrEmpty(p.ManifestPath))) { - var manifestPath = this.NormalizePath(pkg.ManifestPath); + var manifestPath = this.pathUtilityService.NormalizePath(pkg.ManifestPath); localManifests.Add(manifestPath); ownership[pkg.Id] = [manifestPath]; } @@ -151,6 +158,7 @@ private OwnershipResult BuildOwnershipFromMetadata(CargoMetadata metadata) { PackageToTomls = ownership, LocalPackageManifests = localManifests, + ManifestToMetadata = new Dictionary(StringComparer.OrdinalIgnoreCase), }; } @@ -181,15 +189,4 @@ private async Task RunCargoMetadataAsync(string manifestPath, Can return CargoMetadata.FromJson(res.StdOut); } - - private string NormalizePath(string path) - { - if (string.IsNullOrWhiteSpace(path)) - { - return path; - } - - var s = path.Replace('\\', '/'); - return OperatingSystem.IsWindows() ? s.ToUpperInvariant() : s; - } } From 775643bf02db571111af58d4be65a6be828dc78c Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Wed, 15 Oct 2025 14:24:54 -0700 Subject: [PATCH 07/23] Remove redudant normalization of the paths --- .../rust/Parsers/RustCliParser.cs | 17 ++---- .../rust/RustCliDetector.cs | 8 ++- .../rust/RustComponentDetector.cs | 55 +++++++------------ .../rust/RustMetadataContextBuilder.cs | 2 +- 4 files changed, 32 insertions(+), 50 deletions(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs index 2f0f6e632..07ad7a336 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs @@ -19,26 +19,19 @@ public class RustCliParser : IRustCliParser { private readonly ICommandLineInvocationService cliService; private readonly IEnvironmentVariableService envVarService; - private readonly ILogger logger; + private readonly ILogger logger; private readonly IPathUtilityService pathUtilityService; - /// - /// Initializes a new instance of the class. - /// - /// The command line invocation service. - /// The environment variable service. - /// The logger. - /// Path utility service. public RustCliParser( ICommandLineInvocationService cliService, IEnvironmentVariableService envVarService, - ILogger logger, - IPathUtilityService pathUtilityService) + IPathUtilityService pathUtilityService, + ILogger logger) { this.cliService = cliService; this.envVarService = envVarService; - this.logger = logger; this.pathUtilityService = pathUtilityService; + this.logger = logger; } /// @@ -171,7 +164,7 @@ private ParseResult ProcessMetadata( if (root == null) { - this.logger.LogInformation("Virtual Manifest detected: {Location}", manifestLocation); + this.logger.LogInformation("Virtual Manifest detected: {Location}", this.pathUtilityService.NormalizePath(manifestLocation)); foreach (var dep in metadata.Resolve.Nodes) { var componentKey = $"{dep.Id}"; diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs index b766d2b3b..3885f7cfd 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs @@ -19,7 +19,7 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; /// public class RustCliDetector : FileComponentDetector { - private readonly RustCliParser cliParser; + private readonly IRustCliParser cliParser; private readonly RustCargoLockParser cargoLockParser; /// @@ -30,17 +30,19 @@ public class RustCliDetector : FileComponentDetector /// The command line invocation service. /// The environment variable reader service. /// The logger. + /// Rust cli parser. public RustCliDetector( IComponentStreamEnumerableFactory componentStreamEnumerableFactory, IObservableDirectoryWalkerFactory walkerFactory, ICommandLineInvocationService cliService, IEnvironmentVariableService envVarService, - ILogger logger) + ILogger logger, + IRustCliParser cliParser) { this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; this.Scanner = walkerFactory; this.Logger = logger; - this.cliParser = new RustCliParser(cliService, envVarService, logger); + this.cliParser = cliParser; this.cargoLockParser = new RustCargoLockParser(logger); } diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs index 3e31e43db..4626f6c1c 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs @@ -28,7 +28,7 @@ public class RustComponentDetector : FileComponentDetector private readonly IPathUtilityService pathUtilityService; private readonly RustSbomParser sbomParser; - private readonly RustCliParser cliParser; + private readonly IRustCliParser cliParser; private readonly RustCargoLockParser cargoLockParser; private readonly IRustMetadataContextBuilder metadataContextBuilder; @@ -40,16 +40,6 @@ public class RustComponentDetector : FileComponentDetector private Dictionary manifestMetadataCache; private DetectionMode mode; - /// - /// Initializes a new instance of the class. - /// - /// The component stream enumerable factory. - /// The walker factory. - /// The command line invocation service. - /// The environment variable service. - /// The logger. - /// Rust meta data context builder. - /// Path utility service. public RustComponentDetector( IComponentStreamEnumerableFactory componentStreamEnumerableFactory, IObservableDirectoryWalkerFactory walkerFactory, @@ -57,7 +47,8 @@ public RustComponentDetector( IEnvironmentVariableService envVarService, ILogger logger, IRustMetadataContextBuilder metadataContextBuilder, - IPathUtilityService pathUtilityService) + IPathUtilityService pathUtilityService, + IRustCliParser cliParser) { this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; this.Scanner = walkerFactory; @@ -66,17 +57,13 @@ public RustComponentDetector( // Initialize parsers this.sbomParser = new RustSbomParser(logger); - this.cliParser = new RustCliParser(cliService, envVarService, logger); + this.cliParser = cliParser; this.cargoLockParser = new RustCargoLockParser(logger); this.metadataContextBuilder = metadataContextBuilder; - // Initialize with case-insensitive comparison on Windows - this.pathComparer = OperatingSystem.IsWindows() - ? StringComparer.OrdinalIgnoreCase - : StringComparer.Ordinal; - this.pathComparison = OperatingSystem.IsWindows() - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; + // Initialize with uniform case-insensitive comparison across all platforms + this.pathComparer = StringComparer.OrdinalIgnoreCase; + this.pathComparison = StringComparison.OrdinalIgnoreCase; this.visitedDirs = new HashSet(this.pathComparer); this.visitedGlobRules = []; this.manifestMetadataCache = new Dictionary(this.pathComparer); @@ -146,7 +133,7 @@ protected override async Task> OnPrepareDetectionAsy var allRequests = await processRequests.ToList().ToTask(cancellationToken); // Step 2: Determine detection mode - var hasSbomFiles = allRequests.Any(r => r.ComponentStream.Location.EndsWith(".cargo-sbom.json", StringComparison.OrdinalIgnoreCase)); + var hasSbomFiles = allRequests.Any(r => r.ComponentStream.Location.EndsWith(".cargo-sbom.json", this.pathComparison)); this.mode = hasSbomFiles ? DetectionMode.SBOM_ONLY : DetectionMode.FALLBACK; this.Logger.LogInformation("Detection mode: {Mode}", this.mode); @@ -154,9 +141,9 @@ protected override async Task> OnPrepareDetectionAsy // Collect Cargo.toml paths ordered (depth, then path) var tomlPaths = allRequests .Select(r => r.ComponentStream.Location) - .Where(p => string.Equals(Path.GetFileName(p), "Cargo.toml", StringComparison.OrdinalIgnoreCase)) + .Where(p => string.Equals(Path.GetFileName(p), "Cargo.toml", this.pathComparison)) .OrderBy(p => this.GetDirectoryDepth(p)) - .ThenBy(p => p, StringComparer.Ordinal) + .ThenBy(p => p, this.pathComparer) .ToList(); if (tomlPaths.Count > 0) @@ -199,8 +186,8 @@ protected override async Task> OnPrepareDetectionAsy { // Only SBOM files, ordered by path ascending filteredRequests = allRequests - .Where(r => r.ComponentStream.Location.EndsWith(".cargo-sbom.json", StringComparison.OrdinalIgnoreCase)) - .OrderBy(r => r.ComponentStream.Location, StringComparer.Ordinal); + .Where(r => r.ComponentStream.Location.EndsWith(".cargo-sbom.json", this.pathComparison)) + .OrderBy(r => r.ComponentStream.Location, this.pathComparer); this.Logger.LogInformation("SBOM_ONLY mode: Processing {Count} SBOM files", filteredRequests.Count()); } @@ -212,12 +199,12 @@ protected override async Task> OnPrepareDetectionAsy .Where(r => { var fileName = Path.GetFileName(r.ComponentStream.Location); - return fileName.Equals("Cargo.toml", StringComparison.OrdinalIgnoreCase) || - fileName.Equals("Cargo.lock", StringComparison.OrdinalIgnoreCase); + return fileName.Equals("Cargo.toml", this.pathComparison) || + fileName.Equals("Cargo.lock", this.pathComparison); }) - .OrderBy(r => Path.GetFileName(r.ComponentStream.Location).Equals("Cargo.lock", StringComparison.OrdinalIgnoreCase) ? 1 : 0) // TOML before LOCK + .OrderBy(r => Path.GetFileName(r.ComponentStream.Location).Equals("Cargo.lock", this.pathComparison) ? 1 : 0) // TOML before LOCK .ThenBy(r => this.GetDirectoryDepth(r.ComponentStream.Location)) - .ThenBy(r => r.ComponentStream.Location, StringComparer.Ordinal); + .ThenBy(r => r.ComponentStream.Location, this.pathComparer); this.Logger.LogInformation("FALLBACK mode: Processing {Count} Cargo.toml/Cargo.lock files", filteredRequests.Count()); } @@ -238,15 +225,15 @@ protected override async Task OnFileFoundAsync( var normDirectory = this.pathUtilityService.NormalizePath(directory); var fileName = Path.GetFileName(location); - this.Logger.LogInformation("Processing file: {Location}", location); + this.Logger.LogInformation("Processing file: {Location}", normLocation); // Determine file kind FileKind fileKind; - if (fileName.Equals("Cargo.toml", StringComparison.OrdinalIgnoreCase)) + if (fileName.Equals("Cargo.toml", this.pathComparison)) { fileKind = FileKind.CargoToml; } - else if (fileName.Equals("Cargo.lock", StringComparison.OrdinalIgnoreCase)) + else if (fileName.Equals("Cargo.lock", this.pathComparison)) { fileKind = FileKind.CargoLock; } @@ -394,7 +381,7 @@ private void AddGlobRule(string root, IEnumerable includes, IEnumerable< { Evaluation = new EvaluationOptions { - CaseInsensitive = OperatingSystem.IsWindows(), + CaseInsensitive = true, }, }; @@ -590,7 +577,7 @@ private async Task ProcessCargoTomlAsync(ProcessRequest processRequest, string d { foreach (var dir in result.LocalPackageDirectories) { - this.visitedDirs.Add(this.pathUtilityService.NormalizePath(dir)); + this.visitedDirs.Add(dir); } this.visitedDirs.Add(this.pathUtilityService.NormalizePath(directory)); diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs index e988bf31e..c8784cb29 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs @@ -77,7 +77,7 @@ public async Task BuildPackageOwnershipMapAsync( this.logger.LogInformation( "Processed {Toml}: +{LocalCount} local manifests, +{DepCount} deps (aggregate: {AggLocal} manifests, {AggDeps} deps, {MetadataCache} cached)", - toml, + normToml, result.LocalPackageManifests.Count, result.PackageToTomls.Count, aggregate.LocalPackageManifests.Count, From 725b8eb3cfc912d7fb55e7c99e67a76f635d115c Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Wed, 15 Oct 2025 14:58:54 -0700 Subject: [PATCH 08/23] Use ownership map in fallback mode as well --- .../rust/Parsers/IRustCliParser.cs | 81 +++++++++-- .../rust/Parsers/RustCliParser.cs | 137 ++++++++++-------- .../rust/Parsers/RustSbomParser.cs | 1 + .../rust/RustComponentDetector.cs | 5 + 4 files changed, 154 insertions(+), 70 deletions(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustCliParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustCliParser.cs index 2f68203a5..46961ef87 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustCliParser.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustCliParser.cs @@ -1,33 +1,94 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.ComponentDetection.Contracts; using Microsoft.ComponentDetection.Detectors.Rust.Contracts; -using static Microsoft.ComponentDetection.Detectors.Rust.RustCliParser; +/// +/// Provides methods to parse Rust Cargo.toml / workspace dependency information into the component +/// recording system. Implementations may choose to invoke the Rust CLI (e.g. cargo metadata) or +/// operate on already-supplied serialized metadata. +/// +/// +/// There are three entry points: +/// 1. triggers a fresh acquisition (typically by invoking the Cargo CLI). +/// 2. consumes a pre-fetched blob. +/// 3. adds support +/// for multi-file / workspace ownership resolution by leveraging a parent recorder and an ownership map. +/// public interface IRustCliParser { /// - /// Parses a Cargo.toml file by invoking 'cargo metadata'. + /// Parses Rust dependency information for the supplied component stream (generally a Cargo.toml) + /// by invoking the Cargo CLI (e.g. running cargo metadata) and recording discovered components + /// and dependency edges into the provided . /// - /// The component stream containing the Cargo.toml file. - /// The component recorder. - /// Cancellation token. - /// Parse result containing success status and local package directories. + /// The stream representing the manifest file being parsed. + /// The per-file component recorder used to register detected components and graph edges. + /// A token that can be used to cancel the parse operation. + /// + /// A indicating success or failure (with failure reason and any relevant local + /// package directories that were resolved). + /// public Task ParseAsync( IComponentStream componentStream, ISingleFileComponentRecorder recorder, CancellationToken cancellationToken); /// - /// Parses a Cargo.toml file using a previously obtained CargoMetadata (cached output). - /// Avoids re-running the cargo command. + /// Parses Rust dependency information using a pre-obtained object, with support + /// for attributing discovered packages to owning manifests in a multi-project / workspace scenario. /// - /// Result of parsing cargo metadata. + /// The manifest stream being processed (used primarily for location context). + /// + /// A single-file recorder used if ownership cannot be resolved to a more specific recorder via the + /// or . + /// + /// The pre-fetched Cargo metadata describing packages and their relationships. + /// + /// The parent recorder that can produce (or correlate) other single-file recorders used to correctly + /// attribute dependencies to their originating manifest locations. + /// + /// + /// A mapping of package ID (or equivalent key) to a set of manifest file paths indicating ownership. + /// Used to decide which recorder should own which package entries. + /// + /// A token to cancel the operation. + /// A containing success state and any local package directories discovered. public Task ParseFromMetadataAsync( IComponentStream componentStream, - ISingleFileComponentRecorder recorder, + ISingleFileComponentRecorder fallbackRecorder, CargoMetadata cachedMetadata, + IComponentRecorder parentComponentRecorder, + IReadOnlyDictionary> ownershipMap, CancellationToken cancellationToken); + + /// + /// Result of parsing a Cargo.toml file. + /// + public class ParseResult + { + /// + /// Gets or sets a value indicating whether parsing was successful. + /// + public bool Success { get; set; } + + /// + /// Gets or sets the error message if parsing failed. + /// + public string ErrorMessage { get; set; } + + /// + /// Gets or sets the reason for failure if parsing failed. + /// + public string FailureReason { get; set; } + + /// + /// Gets or sets the local package directories that should be marked as visited. + /// This allows upstream client to skip TOMLs that were already accounted for in this run. + /// + public HashSet LocalPackageDirectories { get; set; } = []; + } } diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs index 07ad7a336..7d54b4d3a 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs @@ -11,9 +11,11 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; using Microsoft.ComponentDetection.Contracts.TypedComponent; using Microsoft.ComponentDetection.Detectors.Rust.Contracts; using Microsoft.Extensions.Logging; +using static Microsoft.ComponentDetection.Detectors.Rust.IRustCliParser; /// -/// Parser for Cargo.toml files using cargo metadata command or cached metadata. +/// Parser for Cargo.toml files using cargo metadata command or cached metadata, +/// with optional ownership-aware component registration. /// public class RustCliParser : IRustCliParser { @@ -87,7 +89,12 @@ public async Task ParseAsync( } var metadata = CargoMetadata.FromJson(cliResult.StdOut); - return this.ProcessMetadata(componentStream.Location, recorder, metadata); + return this.ProcessMetadata( + componentStream.Location, + fallbackRecorder: recorder, + metadata, + parentComponentRecorder: null, + ownershipMap: null); } catch (Exception e) { @@ -105,11 +112,12 @@ public async Task ParseAsync( /// Result of parsing cargo metadata. public Task ParseFromMetadataAsync( IComponentStream componentStream, - ISingleFileComponentRecorder recorder, + ISingleFileComponentRecorder fallbackRecorder, CargoMetadata cachedMetadata, + IComponentRecorder parentComponentRecorder, + IReadOnlyDictionary> ownershipMap, CancellationToken cancellationToken = default) { - // Cancellation token is unused here since we do no IO, but kept for API symmetry. var result = new ParseResult(); if (cachedMetadata == null) @@ -127,7 +135,12 @@ public Task ParseFromMetadataAsync( try { - return Task.FromResult(this.ProcessMetadata(componentStream.Location, recorder, cachedMetadata)); + return Task.FromResult(this.ProcessMetadata( + componentStream.Location, + fallbackRecorder, + cachedMetadata, + parentComponentRecorder, + ownershipMap)); } catch (Exception e) { @@ -138,13 +151,12 @@ public Task ParseFromMetadataAsync( } } - /// - /// Shared implementation to translate CargoMetadata into registered components and a ParseResult. - /// private ParseResult ProcessMetadata( string manifestLocation, - ISingleFileComponentRecorder recorder, - CargoMetadata metadata) + ISingleFileComponentRecorder fallbackRecorder, + CargoMetadata metadata, + IComponentRecorder parentComponentRecorder, + IReadOnlyDictionary> ownershipMap) { var result = new ParseResult(); @@ -171,31 +183,36 @@ private ParseResult ProcessMetadata( if (visitedDependencies.Add(componentKey)) { this.TraverseAndRecordComponents( - recorder, manifestLocation, graph, dep.Id, - null, - null, - packages, - visitedDependencies, - explicitlyReferencedDependency: false); + parent: null, + depInfo: null, + packagesMetadata: packages, + visitedDependencies: visitedDependencies, + explicitlyReferencedDependency: false, + isTomlRoot: false, + parentComponentRecorder: parentComponentRecorder, + ownershipMap: ownershipMap, + fallbackRecorder: fallbackRecorder); } } } else { this.TraverseAndRecordComponents( - recorder, manifestLocation, graph, root, - null, - null, - packages, - visitedDependencies, + parent: null, + depInfo: null, + packagesMetadata: packages, + visitedDependencies: visitedDependencies, explicitlyReferencedDependency: true, - isTomlRoot: true); + isTomlRoot: true, + parentComponentRecorder: parentComponentRecorder, + ownershipMap: ownershipMap, + fallbackRecorder: fallbackRecorder); } foreach (var package in metadata.Packages.Where(p => p.Source == null)) @@ -218,7 +235,6 @@ private bool IsRustCliManuallyDisabled() => this.envVarService.IsEnvironmentVariableValueTrue("DisableRustCliScan"); private void TraverseAndRecordComponents( - ISingleFileComponentRecorder recorder, string location, IReadOnlyDictionary graph, string id, @@ -226,8 +242,11 @@ private void TraverseAndRecordComponents( Dep depInfo, IReadOnlyDictionary packagesMetadata, ISet visitedDependencies, - bool explicitlyReferencedDependency = false, - bool isTomlRoot = false) + bool explicitlyReferencedDependency, + bool isTomlRoot, + IComponentRecorder parentComponentRecorder, + IReadOnlyDictionary> ownershipMap, + ISingleFileComponentRecorder fallbackRecorder) { try { @@ -250,11 +269,33 @@ private void TraverseAndRecordComponents( var shouldRegister = !isTomlRoot && cargoComponent.Source != null; if (shouldRegister) { - recorder.RegisterUsage( - detectedComponent, - explicitlyReferencedDependency, - isDevelopmentDependency: isDevelopmentDependency, - parentComponentId: parent?.Component.Id); + var ownersApplied = false; + if (ownershipMap != null && + parentComponentRecorder != null && + ownershipMap.TryGetValue(id, out var owners) && + owners != null && owners.Count > 0) + { + ownersApplied = true; + foreach (var manifestPath in owners) + { + var ownerRecorder = parentComponentRecorder.CreateSingleFileComponentRecorder(manifestPath); + ownerRecorder.RegisterUsage( + detectedComponent, + explicitlyReferencedDependency, + isDevelopmentDependency: isDevelopmentDependency, + parentComponentId: parent?.Component.Id); + } + } + + if (!ownersApplied) + { + // Fallback to the manifest-local recorder + fallbackRecorder.RegisterUsage( + detectedComponent, + explicitlyReferencedDependency, + isDevelopmentDependency: isDevelopmentDependency, + parentComponentId: parent?.Component.Id); + } } foreach (var dep in node.Deps) @@ -263,7 +304,6 @@ private void TraverseAndRecordComponents( if (visitedDependencies.Add(componentKey)) { this.TraverseAndRecordComponents( - recorder, location, graph, dep.Pkg, @@ -271,41 +311,18 @@ private void TraverseAndRecordComponents( dep, packagesMetadata, visitedDependencies, - explicitlyReferencedDependency: isTomlRoot && explicitlyReferencedDependency); + explicitlyReferencedDependency: isTomlRoot && explicitlyReferencedDependency, + isTomlRoot: false, + parentComponentRecorder: parentComponentRecorder, + ownershipMap: ownershipMap, + fallbackRecorder: fallbackRecorder); } } } catch (IndexOutOfRangeException e) { this.logger.LogWarning(e, "Could not parse {Id} at {Location}", id, location); - recorder.RegisterPackageParseFailure(id); + fallbackRecorder.RegisterPackageParseFailure(id); } } - - /// - /// Result of parsing a Cargo.toml file. - /// - public class ParseResult - { - /// - /// Gets or sets a value indicating whether parsing was successful. - /// - public bool Success { get; set; } - - /// - /// Gets or sets the error message if parsing failed. - /// - public string ErrorMessage { get; set; } - - /// - /// Gets or sets the reason for failure if parsing failed. - /// - public string FailureReason { get; set; } - - /// - /// Gets or sets the local package directories that should be marked as visited. - /// This allows upstream client to skip TOMLs that were already accounted for in this run. - /// - public HashSet LocalPackageDirectories { get; set; } = []; - } } diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs index d99d9dd86..4c6e8117d 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs @@ -203,6 +203,7 @@ private void ProcessDependencyWithOwnership( var ownersApplied = false; if (ownershipMap != null && + parentComponentRecorder != null && ownershipMap.TryGetValue(metadataId, out var owners) && owners != null && owners.Count > 0) { diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs index 4626f6c1c..1c1705d6e 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs @@ -568,11 +568,16 @@ private async Task ProcessCargoTomlAsync(ProcessRequest processRequest, string d this.manifestMetadataCache.TryGetValue(normalized, out var cachedMetadata)) { this.Logger.LogDebug("Using cached cargo metadata for {Location}", normalized); + + var parentRecorder = processRequest.SingleFileComponentRecorder.GetParentComponentRecorder(); var result = await this.cliParser.ParseFromMetadataAsync( processRequest.ComponentStream, processRequest.SingleFileComponentRecorder, cachedMetadata, + this.ComponentRecorder, + this.ownershipMap, cancellationToken); + if (result.Success) { foreach (var dir in result.LocalPackageDirectories) From 7ff9692be4aeeecda48cebb3465b916d3e696a1a Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Wed, 15 Oct 2025 18:50:58 -0700 Subject: [PATCH 09/23] Copy new detector logic into existing RustSbomDetector --- .../rust/RustSbomDetector.cs | 692 +++++++++++++++++- .../Extensions/ServiceCollectionExtensions.cs | 1 - 2 files changed, 677 insertions(+), 16 deletions(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs index b5a368210..3307feb11 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs @@ -1,47 +1,709 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; +using System; using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; using System.Threading; using System.Threading.Tasks; +using global::DotNet.Globbing; using Microsoft.ComponentDetection.Contracts; using Microsoft.ComponentDetection.Contracts.Internal; using Microsoft.ComponentDetection.Contracts.TypedComponent; using Microsoft.Extensions.Logging; +using Tomlyn; +using Tomlyn.Model; -public class RustSbomDetector : FileComponentDetector, IExperimentalDetector +/// +/// A unified Rust detector that orchestrates SBOM, CLI, and Crate parsing. +/// +public class RustSbomDetector : FileComponentDetector { - private const string CargoSbomSearchPattern = "*.cargo-sbom.json"; - private readonly RustSbomParser parser; + private static readonly TomlModelOptions TomlOptions = new TomlModelOptions + { + IgnoreMissingProperties = true, + }; + + private readonly IPathUtilityService pathUtilityService; + private readonly RustSbomParser sbomParser; + private readonly IRustCliParser cliParser; + private readonly RustCargoLockParser cargoLockParser; + private readonly IRustMetadataContextBuilder metadataContextBuilder; + + private readonly HashSet visitedDirs; + private readonly List visitedGlobRules; + private readonly StringComparer pathComparer; + private readonly StringComparison pathComparison; + private IReadOnlyDictionary> ownershipMap; + private Dictionary manifestMetadataCache; + private DetectionMode mode; public RustSbomDetector( IComponentStreamEnumerableFactory componentStreamEnumerableFactory, IObservableDirectoryWalkerFactory walkerFactory, - ILogger logger) + ICommandLineInvocationService cliService, + IEnvironmentVariableService envVarService, + ILogger logger, + IRustMetadataContextBuilder metadataContextBuilder, + IPathUtilityService pathUtilityService, + IRustCliParser cliParser) { this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; this.Scanner = walkerFactory; this.Logger = logger; - this.parser = new RustSbomParser(logger); + this.pathUtilityService = pathUtilityService; + + // Initialize parsers + this.sbomParser = new RustSbomParser(logger); + this.cliParser = cliParser; + this.cargoLockParser = new RustCargoLockParser(logger); + this.metadataContextBuilder = metadataContextBuilder; + + // Initialize with uniform case-insensitive comparison across all platforms + this.pathComparer = StringComparer.OrdinalIgnoreCase; + this.pathComparison = StringComparison.OrdinalIgnoreCase; + this.visitedDirs = new HashSet(this.pathComparer); + this.visitedGlobRules = []; + this.manifestMetadataCache = new Dictionary(this.pathComparer); } - public override string Id => "RustSbom"; + /// + /// Detection modes for the unified Rust detector. + /// + private enum DetectionMode + { + /// + /// Only use SBOM files for detection. + /// + SBOM_ONLY, - public override IList SearchPatterns => [CargoSbomSearchPattern]; + /// + /// Use fallback strategy (Cargo CLI and/or Cargo.lock parsing). + /// + FALLBACK, + } + + /// + /// File kinds for skip logic. + /// + private enum FileKind + { + /// + /// Cargo.toml file. + /// + CargoToml, + /// + /// Cargo.lock file. + /// + CargoLock, + + /// + /// Cargo SBOM file. + /// + CargoSbom, + } + + /// + public override string Id => nameof(RustSbomDetector); + + /// + public override IEnumerable Categories { get; } = ["Rust"]; + + /// public override IEnumerable SupportedComponentTypes => [ComponentType.Cargo]; - public override int Version { get; } = 1; + /// + public override int Version => 1; - public override IEnumerable Categories => ["Rust"]; + /// + public override IList SearchPatterns { get; } = ["Cargo.toml", "Cargo.lock", "*.cargo-sbom.json"]; - protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default) + /// + protected override async Task> OnPrepareDetectionAsync( + IObservable processRequests, + IDictionary detectorArgs, + CancellationToken cancellationToken = default) { - var singleFileComponentRecorder = processRequest.SingleFileComponentRecorder; - var components = processRequest.ComponentStream; - var sbomVersion = await this.parser.ParseAsync(components, singleFileComponentRecorder, cancellationToken); - if (sbomVersion.HasValue) + this.Logger.LogInformation("Preparing Rust component detection"); + + // Step 1: Collect all process requests into a list + var allRequests = await processRequests.ToList().ToTask(cancellationToken); + + // Step 2: Determine detection mode + var hasSbomFiles = allRequests.Any(r => r.ComponentStream.Location.EndsWith(".cargo-sbom.json", this.pathComparison)); + this.mode = hasSbomFiles ? DetectionMode.SBOM_ONLY : DetectionMode.FALLBACK; + + this.Logger.LogInformation("Detection mode: {Mode}", this.mode); + + // Collect Cargo.toml paths ordered (depth, then path) + var tomlPaths = allRequests + .Select(r => r.ComponentStream.Location) + .Where(p => string.Equals(Path.GetFileName(p), "Cargo.toml", this.pathComparison)) + .OrderBy(p => this.GetDirectoryDepth(p)) + .ThenBy(p => p, this.pathComparer) + .ToList(); + + if (tomlPaths.Count > 0) { - this.RecordLockfileVersion(sbomVersion.Value); + try + { + this.Logger.LogInformation("Building Rust ownership map from {Count} Cargo.toml files", tomlPaths.Count); + var ownership = await this.metadataContextBuilder.BuildPackageOwnershipMapAsync(tomlPaths, cancellationToken); + this.ownershipMap = ownership.PackageToTomls; + this.manifestMetadataCache = ownership.ManifestToMetadata; + this.Logger.LogInformation( + "Loaded Rust ownership (packages: {PkgCount}) and metadata cache (manifests: {ManifestCount})", + this.ownershipMap?.Count ?? 0, + this.manifestMetadataCache?.Count ?? 0); + + if (ownership.FailedManifests?.Count > 0) + { + this.Logger.LogInformation( + "Rust metadata failed for {Count} manifests (will rely on lockfiles): {Manifests}", + ownership.FailedManifests.Count, + string.Join(", ", ownership.FailedManifests)); + } + } + catch (Exception ex) + { + this.Logger.LogWarning(ex, "Failed to compute Rust ownership/metadata cache; proceeding without cache"); + this.ownershipMap = null; + this.manifestMetadataCache = null; + } } + else + { + this.Logger.LogInformation("No Cargo.toml files found; ownership and metadata cache unavailable"); + this.ownershipMap = null; + this.manifestMetadataCache = null; + } + + IEnumerable filteredRequests; + if (this.mode == DetectionMode.SBOM_ONLY) + { + // Only SBOM files, ordered by path ascending + filteredRequests = allRequests + .Where(r => r.ComponentStream.Location.EndsWith(".cargo-sbom.json", this.pathComparison)) + .OrderBy(r => r.ComponentStream.Location, this.pathComparer); + + this.Logger.LogInformation("SBOM_ONLY mode: Processing {Count} SBOM files", filteredRequests.Count()); + } + else + { + // FALLBACK mode: Select Cargo.toml and Cargo.lock files + // Order: TOML before LOCK, then depth ascending, then path ascending + filteredRequests = allRequests + .Where(r => + { + var fileName = Path.GetFileName(r.ComponentStream.Location); + return fileName.Equals("Cargo.toml", this.pathComparison) || + fileName.Equals("Cargo.lock", this.pathComparison); + }) + .OrderBy(r => Path.GetFileName(r.ComponentStream.Location).Equals("Cargo.lock", this.pathComparison) ? 1 : 0) // TOML before LOCK + .ThenBy(r => this.GetDirectoryDepth(r.ComponentStream.Location)) + .ThenBy(r => r.ComponentStream.Location, this.pathComparer); + this.Logger.LogInformation("FALLBACK mode: Processing {Count} Cargo.toml/Cargo.lock files", filteredRequests.Count()); + } + + // Step 4: Return the ordered sequence as an observable + return filteredRequests.ToObservable(); + } + + /// + protected override async Task OnFileFoundAsync( + ProcessRequest processRequest, + IDictionary detectorArgs, + CancellationToken cancellationToken = default) + { + var componentStream = processRequest.ComponentStream; + var location = componentStream.Location; + var normLocation = this.pathUtilityService.NormalizePath(location); + var directory = Path.GetDirectoryName(location); + var normDirectory = this.pathUtilityService.NormalizePath(directory); + var fileName = Path.GetFileName(location); + + this.Logger.LogInformation("Processing file: {Location}", normLocation); + + // Determine file kind + FileKind fileKind; + if (fileName.Equals("Cargo.toml", this.pathComparison)) + { + fileKind = FileKind.CargoToml; + } + else if (fileName.Equals("Cargo.lock", this.pathComparison)) + { + fileKind = FileKind.CargoLock; + } + else + { + fileKind = FileKind.CargoSbom; + } + + // Check if directory should be skipped + if (this.ShouldSkip(directory, fileKind, location)) + { + this.Logger.LogInformation("Skipping file due to skip rules: {Location}", normLocation); + return; + } + + if (this.mode == DetectionMode.SBOM_ONLY) + { + await this.ProcessSbomFileAsync(processRequest, cancellationToken); + } + else + { + // FALLBACK mode + if (fileKind == FileKind.CargoToml) + { + await this.ProcessCargoTomlAsync(processRequest, directory, cancellationToken); + } + else if (fileKind == FileKind.CargoLock) + { + await this.ProcessCargoLockAsync(processRequest, directory, cancellationToken); + } + } + } + + /// + /// Calculates the depth of a directory path by counting the number of directory separators. + /// + /// The file or directory path to analyze. + /// + /// The number of directory separators in the normalized path, representing the depth. + /// Returns 0 if the path is null or empty. + /// + /// + /// The path is normalized to use forward slashes before counting separators. + /// This ensures consistent depth calculation across different operating systems. + /// + private int GetDirectoryDepth(string path) + { + if (string.IsNullOrEmpty(path)) + { + return 0; + } + + var normalizedPath = this.pathUtilityService.NormalizePath(path); + return normalizedPath.Count(c => c == Path.AltDirectorySeparatorChar); + } + + /// + /// Determines whether a file should be skipped based on visited directories and workspace glob rules. + /// + /// The directory path containing the file. + /// The kind of file being processed (CargoToml, CargoLock, or CargoSbom). + /// The full path to the file being evaluated. + /// + /// true if the file should be skipped; otherwise, false. + /// + /// + /// The skip logic follows these rules: + /// + /// If the directory has already been processed, skip immediately. + /// Workspace-only Cargo.toml files (with [workspace] but no [package] section) are never skipped. + /// Files are checked against workspace glob rules for inclusion/exclusion patterns. + /// + /// + private bool ShouldSkip(string directory, FileKind fileKind, string fullPath) + { + var normalizedDir = this.pathUtilityService.NormalizePath(directory); + + // 1. If directory already processed, skip immediately + if (this.visitedDirs.Contains(normalizedDir)) + { + return true; + } + + // 2. Workspace-only Cargo.toml should always be processed (never skipped) + if (fileKind == FileKind.CargoToml && this.IsWorkspaceOnlyToml(fullPath)) + { + return false; + } + + var normalizedFullPath = this.pathUtilityService.NormalizePath(fullPath); + + // 3. Check each workspace rule for inclusion/exclusion + foreach (var rule in this.visitedGlobRules) + { + if (!this.IsDescendantOf(normalizedDir, rule.Root)) + { + continue; + } + + var relativePath = this.GetRelativePath(rule.Root, normalizedDir); + + // Match against include globs + var matchesInclude = rule.IncludeGlobs.Any(g => + g.IsMatch(relativePath) || g.IsMatch(normalizedFullPath)); + + if (!matchesInclude) + { + continue; + } + + // Match against exclude globs + var matchesExclude = rule.ExcludeGlobs.Any(g => + g.IsMatch(relativePath) || g.IsMatch(normalizedFullPath)); + + if (matchesExclude) + { + continue; + } + + // If included and not excluded, skip this directory + return true; + } + + return false; + } + + /// + /// Adds a glob rule for workspace member filtering based on include and exclude patterns. + /// + /// The root directory path where the workspace is defined. + /// Collection of glob patterns to include workspace members (e.g., "member1", "path/*"). + /// Collection of glob patterns to exclude workspace members (e.g., "examples/*", "tests/*"). + /// + /// This method normalizes all paths and patterns for cross-platform compatibility. + /// On Windows, patterns are evaluated case-insensitively, while on other platforms they are case-sensitive. + /// The glob rule is used to determine whether files in descendant directories should be skipped during detection. + /// + private void AddGlobRule(string root, IEnumerable includes, IEnumerable excludes) + { + var normalizedRoot = this.pathUtilityService.NormalizePath(root); + var includesList = includes?.ToList() ?? []; + var excludesList = excludes?.ToList() ?? []; + + var globOptions = new GlobOptions + { + Evaluation = new EvaluationOptions + { + CaseInsensitive = true, + }, + }; + + var includeGlobs = new List(); + foreach (var pattern in includesList) + { + var normalizedPattern = this.pathUtilityService.NormalizePath(pattern); + includeGlobs.Add(Glob.Parse(normalizedPattern, globOptions)); + } + + var excludeGlobs = new List(); + foreach (var pattern in excludesList) + { + var normalizedPattern = this.pathUtilityService.NormalizePath(pattern); + excludeGlobs.Add(Glob.Parse(normalizedPattern, globOptions)); + } + + var rule = new GlobRule + { + Root = normalizedRoot, + Includes = includesList, + Excludes = excludesList, + IncludeGlobs = includeGlobs, + ExcludeGlobs = excludeGlobs, + }; + + this.visitedGlobRules.Add(rule); + this.Logger.LogDebug("Added glob rule with root {Root}, {IncludeCount} includes, {ExcludeCount} excludes", normalizedRoot, includesList.Count, excludesList.Count); + } + + /// + /// Determines if the specified path is a descendant of the potential parent directory. + /// + /// The path to check. + /// The potential parent directory path. + /// + /// true if is a descendant of or equal to ; otherwise, false. + /// + /// + /// This method normalizes both paths for cross-platform comparison and handles case sensitivity based on the operating system. + /// The comparison treats paths with and without trailing separators as equivalent. + /// + private bool IsDescendantOf(string path, string potentialParent) + { + var normalizedPath = this.pathUtilityService.NormalizePath(path); + var normalizedParent = this.pathUtilityService.NormalizePath(potentialParent); + + // Ensure parent path ends with separator for proper comparison + if (!normalizedParent.EndsWith('/')) + { + normalizedParent += "/"; + } + + return normalizedPath.StartsWith(normalizedParent, this.pathComparison) || + normalizedPath.Equals(normalizedParent.TrimEnd('/'), this.pathComparison); + } + + /// + /// Calculates the relative path from a base path to a full path. + /// + /// The base directory path to calculate relative to. + /// The full path to convert to a relative path. + /// + /// The relative path from to . + /// If is not under , returns the normalized full path. + /// + /// + /// The method normalizes both paths for cross-platform comparison and handles case sensitivity based on the operating system. + /// The base path is automatically appended with a trailing separator if not present to ensure correct path comparison. + /// + private string GetRelativePath(string basePath, string fullPath) + { + var normalizedBase = this.pathUtilityService.NormalizePath(basePath); + var normalizedFull = this.pathUtilityService.NormalizePath(fullPath); + + if (!normalizedBase.EndsWith('/')) + { + normalizedBase += "/"; + } + + if (normalizedFull.StartsWith(normalizedBase, this.pathComparison)) + { + return normalizedFull[normalizedBase.Length..]; + } + + return normalizedFull; + } + + /// + /// Determines whether the specified Cargo.toml file is a workspace-only configuration file. + /// + /// The full path to the Cargo.toml file to analyze. + /// + /// true if the file contains a [workspace] section but no [package] section, indicating it is a workspace-only file; + /// otherwise, false. + /// + /// + /// A workspace-only Cargo.toml file defines workspace configuration and members but does not define a package itself. + /// Such files should always be processed during detection and never skipped, as they provide critical workspace structure information. + /// If the file cannot be parsed, this method logs a warning and returns false to allow continued processing. + /// + private bool IsWorkspaceOnlyToml(string cargoTomlPath) + { + try + { + var content = File.ReadAllText(cargoTomlPath); + var tomlTable = Toml.ToModel(content, options: TomlOptions); + + // Check if it has a [workspace] section but no [package] section + var hasWorkspace = tomlTable.ContainsKey("workspace"); + var hasPackage = tomlTable.ContainsKey("package"); + + return hasWorkspace && !hasPackage; + } + catch (Exception e) + { + this.Logger.LogWarning(e, "Failed to check if {Path} is workspace-only", cargoTomlPath); + return false; + } + } + + /// + /// Processes a Cargo SBOM file asynchronously by parsing it and recording the lockfile version if available. + /// + /// The process request containing the component stream for the SBOM file. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. + /// + /// This method delegates parsing to the and records the lockfile version + /// in telemetry if the parsing operation returns a version number. + /// + private async Task ProcessSbomFileAsync(ProcessRequest processRequest, CancellationToken cancellationToken) + { + // Just before calling ParseAsync + this.Logger.LogDebug( + "SBOM parse starting. Recorder manifest location = {ManifestLocation}; SBOM stream location = {StreamLocation}", + processRequest.SingleFileComponentRecorder.ManifestFileLocation, + processRequest.ComponentStream.Location); + + int? version; + if (this.ownershipMap != null) + { + version = await this.sbomParser.ParseWithOwnershipAsync( + processRequest.ComponentStream, + processRequest.SingleFileComponentRecorder, + this.ComponentRecorder, + this.ownershipMap, + cancellationToken); + } + else + { + version = await this.sbomParser.ParseAsync( + processRequest.ComponentStream, + processRequest.SingleFileComponentRecorder, + cancellationToken); + } + + if (version.HasValue) + { + this.RecordLockfileVersion(version.Value); + } + } + + /// + /// Processes a Cargo.toml file asynchronously by attempting to execute the Cargo CLI for metadata extraction. + /// + /// The process request containing the component stream for the Cargo.toml file. + /// The directory path where the Cargo.toml file is located. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. + /// + /// This method delegates parsing to the which executes the 'cargo metadata' command. + /// If the CLI parsing is successful, the method: + /// + /// Adds all local package directories found in the workspace to the visited directories set to prevent duplicate processing. + /// Marks the current directory as visited. + /// + /// If the CLI parsing fails, the directory is not marked as visited, allowing fallback to Cargo.lock parsing if available. + /// + private async Task ProcessCargoTomlAsync(ProcessRequest processRequest, string directory, CancellationToken cancellationToken) + { + var normalized = this.pathUtilityService.NormalizePath(processRequest.ComponentStream.Location); + if (this.manifestMetadataCache != null && + this.manifestMetadataCache.TryGetValue(normalized, out var cachedMetadata)) + { + this.Logger.LogDebug("Using cached cargo metadata for {Location}", normalized); + + var parentRecorder = processRequest.SingleFileComponentRecorder.GetParentComponentRecorder(); + var result = await this.cliParser.ParseFromMetadataAsync( + processRequest.ComponentStream, + processRequest.SingleFileComponentRecorder, + cachedMetadata, + this.ComponentRecorder, + this.ownershipMap, + cancellationToken); + + if (result.Success) + { + foreach (var dir in result.LocalPackageDirectories) + { + this.visitedDirs.Add(dir); + } + + this.visitedDirs.Add(this.pathUtilityService.NormalizePath(directory)); + } + } + else + { + this.Logger.LogWarning("No cached cargo metadata for {Location}", processRequest.ComponentStream.Location); + } + } + + /// + /// Processes a Cargo.lock file asynchronously by parsing it and extracting component information. + /// + /// The process request containing the component stream for the Cargo.lock file. + /// The directory path where the Cargo.lock file is located. + /// A cancellation token to observe while waiting for the task to complete. + /// A task that represents the asynchronous operation. + /// + /// This method performs the following steps: + /// + /// Delegates parsing of the Cargo.lock file to the . + /// If parsing is successful and returns a lockfile version, records the version in telemetry. + /// Checks if a corresponding Cargo.toml file exists in the same directory. + /// If Cargo.toml exists, parses its workspace tables to extract member and exclude patterns. + /// Marks the current directory as visited to prevent duplicate processing. + /// + /// The workspace table processing enables proper glob-based filtering of workspace members in subsequent detection operations. + /// + private async Task ProcessCargoLockAsync(ProcessRequest processRequest, string directory, CancellationToken cancellationToken) + { + var version = await this.cargoLockParser.ParseAsync( + processRequest.ComponentStream, + processRequest.SingleFileComponentRecorder, + cancellationToken); + + if (version.HasValue) + { + this.RecordLockfileVersion(version.Value); + + // Check if Cargo.toml exists in same directory to parse workspace tables + var cargoTomlPath = Path.Combine(directory, "Cargo.toml"); + if (File.Exists(cargoTomlPath)) + { + await this.ProcessWorkspaceTablesAsync(cargoTomlPath, directory); + } + + // Add current directory to visitedDirs + this.visitedDirs.Add(this.pathUtilityService.NormalizePath(directory)); + } + } + + /// + /// Processes the workspace tables from a Cargo.toml file asynchronously to extract member and exclude patterns. + /// + /// The full path to the Cargo.toml file to parse. + /// The directory path where the Cargo.toml file is located, used as the root for glob patterns. + /// A task that represents the asynchronous operation. + /// + /// This method parses the [workspace] section of a Cargo.toml file to extract: + /// + /// default-members or members arrays as include patterns for workspace members. + /// exclude array as exclude patterns for workspace members. + /// + /// If include patterns are found, they are added as a glob rule with the specified directory as the root. + /// This enables proper filtering of workspace members during subsequent detection operations. + /// If parsing fails, a warning is logged and the method continues without throwing an exception. + /// + private async Task ProcessWorkspaceTablesAsync(string cargoTomlPath, string directory) + { + try + { + var content = await File.ReadAllTextAsync(cargoTomlPath); + var tomlTable = Toml.ToModel(content, options: TomlOptions); + + if (tomlTable.ContainsKey("workspace") && tomlTable["workspace"] is TomlTable workspaceTable) + { + var includes = new List(); + var excludes = new List(); + + // Parse default-members or members + if (workspaceTable.ContainsKey("default-members") && workspaceTable["default-members"] is TomlArray defaultMembers) + { + includes.AddRange(defaultMembers.Cast()); + } + else if (workspaceTable.ContainsKey("members") && workspaceTable["members"] is TomlArray members) + { + includes.AddRange(members.Cast()); + } + + // Parse exclude + if (workspaceTable.ContainsKey("exclude") && workspaceTable["exclude"] is TomlArray excludeArray) + { + excludes.AddRange(excludeArray.Cast()); + } + + if (includes.Count > 0) + { + this.AddGlobRule(directory, includes, excludes); + } + } + } + catch (Exception e) + { + this.Logger.LogWarning(e, "Failed to parse workspace tables from {Path}", cargoTomlPath); + } + } + + /// + /// Represents a glob rule with root directory and include/exclude patterns. + /// + private class GlobRule + { + public string Root { get; set; } + + public List Includes { get; set; } + + public List Excludes { get; set; } + + public List IncludeGlobs { get; set; } + + public List ExcludeGlobs { get; set; } } } diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs index 8e62aadaa..42b6e1f7f 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs @@ -140,7 +140,6 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); // SPDX services.AddSingleton(); From 54062dbcb2a96b0c03e9e2fa61ef9413d4de4d3a Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Wed, 15 Oct 2025 18:51:20 -0700 Subject: [PATCH 10/23] Check parentId in graph before adding parent child edge --- .../rust/Parsers/RustCliParser.cs | 76 ++++++++++++------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs index 7d54b4d3a..734187c4f 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs @@ -269,33 +269,15 @@ private void TraverseAndRecordComponents( var shouldRegister = !isTomlRoot && cargoComponent.Source != null; if (shouldRegister) { - var ownersApplied = false; - if (ownershipMap != null && - parentComponentRecorder != null && - ownershipMap.TryGetValue(id, out var owners) && - owners != null && owners.Count > 0) - { - ownersApplied = true; - foreach (var manifestPath in owners) - { - var ownerRecorder = parentComponentRecorder.CreateSingleFileComponentRecorder(manifestPath); - ownerRecorder.RegisterUsage( - detectedComponent, - explicitlyReferencedDependency, - isDevelopmentDependency: isDevelopmentDependency, - parentComponentId: parent?.Component.Id); - } - } - - if (!ownersApplied) - { - // Fallback to the manifest-local recorder - fallbackRecorder.RegisterUsage( - detectedComponent, - explicitlyReferencedDependency, - isDevelopmentDependency: isDevelopmentDependency, - parentComponentId: parent?.Component.Id); - } + this.ApplyOwners( + id, + detectedComponent, + explicitlyReferencedDependency, + isDevelopmentDependency, + parentComponentRecorder, + ownershipMap, + fallbackRecorder, + parent); } foreach (var dep in node.Deps) @@ -325,4 +307,44 @@ private void TraverseAndRecordComponents( fallbackRecorder.RegisterPackageParseFailure(id); } } + + private void ApplyOwners( + string id, + DetectedComponent detectedComponent, + bool explicitlyReferencedDependency, + bool isDevelopmentDependency, + IComponentRecorder parentComponentRecorder, + IReadOnlyDictionary> ownershipMap, + ISingleFileComponentRecorder fallbackRecorder, + DetectedComponent parent) + { + var ownersApplied = false; + var parentId = parent?.Component.Id; + if (ownershipMap != null && + parentComponentRecorder != null && + ownershipMap.TryGetValue(id, out var owners) && + owners != null && owners.Count > 0) + { + ownersApplied = true; + foreach (var manifestPath in owners) + { + var ownerRecorder = parentComponentRecorder.CreateSingleFileComponentRecorder(manifestPath); + ownerRecorder.RegisterUsage( + detectedComponent, + explicitlyReferencedDependency, + isDevelopmentDependency: isDevelopmentDependency, + parentComponentId: parentId != null && ownerRecorder.DependencyGraph.Contains(parentId) ? parentId : null); + } + } + + if (!ownersApplied) + { + // Fallback to the manifest-local recorder + fallbackRecorder.RegisterUsage( + detectedComponent, + explicitlyReferencedDependency, + isDevelopmentDependency: isDevelopmentDependency, + parentComponentId: parentId != null && fallbackRecorder.DependencyGraph.Contains(parentId) ? parentId : null); + } + } } From fe9d6af52df8ef370a217769b9d0a2da6c1216f3 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Thu, 16 Oct 2025 11:06:43 -0700 Subject: [PATCH 11/23] Use normalized path in logs --- .../rust/Parsers/RustSbomParser.cs | 2 ++ .../rust/RustSbomDetector.cs | 6 +++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs index 4c6e8117d..9cb8e8835 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs @@ -221,6 +221,8 @@ private void ProcessDependencyWithOwnership( if (!ownersApplied) { + this.logger.LogWarning("Falling back to SBOM recorder for {Id} because no ownership found", metadataId); + // Fallback to SBOM recorder if no ownership info sbomRecorder.RegisterUsage( new DetectedComponent(component), diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs index 3307feb11..19d2d2ccd 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs @@ -162,7 +162,7 @@ protected override async Task> OnPrepareDetectionAsy if (ownership.FailedManifests?.Count > 0) { this.Logger.LogInformation( - "Rust metadata failed for {Count} manifests (will rely on lockfiles): {Manifests}", + "Rust metadata failed for {Count} manifests: {Manifests}", ownership.FailedManifests.Count, string.Join(", ", ownership.FailedManifests)); } @@ -518,8 +518,8 @@ private async Task ProcessSbomFileAsync(ProcessRequest processRequest, Cancellat // Just before calling ParseAsync this.Logger.LogDebug( "SBOM parse starting. Recorder manifest location = {ManifestLocation}; SBOM stream location = {StreamLocation}", - processRequest.SingleFileComponentRecorder.ManifestFileLocation, - processRequest.ComponentStream.Location); + this.pathUtilityService.NormalizePath(processRequest.SingleFileComponentRecorder.ManifestFileLocation), + this.pathUtilityService.NormalizePath(processRequest.ComponentStream.Location)); int? version; if (this.ownershipMap != null) From 5c277566af25dea644875fa1057929a21f627875 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Thu, 16 Oct 2025 16:56:30 -0700 Subject: [PATCH 12/23] Fix rust cli detector UTs --- .../RustCliDetectorTests.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs index 04f717c5a..d21c2f037 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs @@ -12,6 +12,7 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using Microsoft.ComponentDetection.Contracts.TypedComponent; using Microsoft.ComponentDetection.Detectors.Rust; using Microsoft.ComponentDetection.TestsUtilities; +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; using Moq; @@ -510,15 +511,19 @@ public class RustCliDetectorTests : BaseDetectorTest private Mock mockComponentStreamEnumerableFactory; + private Mock> mockRustCliParserLogger; + [TestInitialize] public void InitMocks() { this.mockCliService = new Mock(); this.DetectorTestUtility.AddServiceMock(this.mockCliService); this.mockComponentStreamEnumerableFactory = new Mock(); + this.mockRustCliParserLogger = new Mock>(); this.DetectorTestUtility.AddServiceMock(this.mockComponentStreamEnumerableFactory); this.mockEnvVarService = new Mock(); this.DetectorTestUtility.AddServiceMock(this.mockEnvVarService); + this.DetectorTestUtility.AddService(new RustCliParser(this.mockCliService.Object, this.mockEnvVarService.Object, new PathUtilityService(new Mock>().Object), this.mockRustCliParserLogger.Object)); } [TestMethod] From 35c1c599cfa59cda99ba0b94d191efc297b5cb62 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Thu, 16 Oct 2025 17:34:32 -0700 Subject: [PATCH 13/23] Extract all rust parsers into interfaces --- .../rust/Parsers/IRustCargoLockParser.cs | 13 + .../rust/Parsers/IRustSbomParser.cs | 21 + .../rust/Parsers/RustCargoLockParser.cs | 6 +- .../rust/Parsers/RustSbomParser.cs | 6 +- .../rust/RustCliDetector.cs | 8 +- .../rust/RustComponentDetector.cs | 709 ------------------ .../rust/RustCrateDetector.cs | 7 +- .../rust/RustSbomDetector.cs | 14 +- .../Extensions/ServiceCollectionExtensions.cs | 2 + 9 files changed, 58 insertions(+), 728 deletions(-) create mode 100644 src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustCargoLockParser.cs create mode 100644 src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustSbomParser.cs delete mode 100644 src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustCargoLockParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustCargoLockParser.cs new file mode 100644 index 000000000..317113193 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustCargoLockParser.cs @@ -0,0 +1,13 @@ +namespace Microsoft.ComponentDetection.Detectors.Rust; + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ComponentDetection.Contracts; + +public interface IRustCargoLockParser +{ + public Task ParseAsync( + IComponentStream componentStream, + ISingleFileComponentRecorder singleFileComponentRecorder, + CancellationToken cancellationToken); +} diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustSbomParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustSbomParser.cs new file mode 100644 index 000000000..f2f5c8b74 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/IRustSbomParser.cs @@ -0,0 +1,21 @@ +namespace Microsoft.ComponentDetection.Detectors.Rust; + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.ComponentDetection.Contracts; + +public interface IRustSbomParser +{ + public Task ParseAsync( + IComponentStream componentStream, + ISingleFileComponentRecorder recorder, + CancellationToken cancellationToken); + + public Task ParseWithOwnershipAsync( + IComponentStream componentStream, + ISingleFileComponentRecorder sbomRecorder, + IComponentRecorder parentComponentRecorder, + IReadOnlyDictionary> ownershipMap, + CancellationToken cancellationToken); +} diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCargoLockParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCargoLockParser.cs index cf4ebd768..aa19fd0e9 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCargoLockParser.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCargoLockParser.cs @@ -17,7 +17,7 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; /// /// Detector for Cargo.lock files. /// -public class RustCargoLockParser +public class RustCargoLockParser : IRustCargoLockParser { //// PkgName[ Version][ (Source)] private static readonly Regex DependencyFormatRegex = new Regex( @@ -29,13 +29,13 @@ public class RustCargoLockParser IgnoreMissingProperties = true, }; - private readonly ILogger logger; + private readonly ILogger logger; /// /// Initializes a new instance of the class. /// /// The logger. - public RustCargoLockParser(ILogger logger) => this.logger = logger; + public RustCargoLockParser(ILogger logger) => this.logger = logger; private static bool IsLocalPackage(CargoPackage package) => package.Source == null; diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs index 9cb8e8835..458ce5a38 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs @@ -14,7 +14,7 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; /// /// Detector for Cargo SBOM (.cargo-sbom.json) files. /// -public class RustSbomParser +public class RustSbomParser : IRustSbomParser { private const string CratesIoSource = "registry+https://github.com/rust-lang/crates.io-index"; @@ -26,13 +26,13 @@ public class RustSbomParser @"^(?[^#]*)#?(?[\w\-]*)[@#]?(?\d[\S]*)?$", RegexOptions.Compiled); - private readonly ILogger logger; + private readonly ILogger logger; /// /// Initializes a new instance of the class. /// /// The logger. - public RustSbomParser(ILogger logger) => this.logger = logger; + public RustSbomParser(ILogger logger) => this.logger = logger; /// /// Parses a Cargo SBOM file and records components. diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs index 3885f7cfd..bfd46425c 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs @@ -20,7 +20,7 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; public class RustCliDetector : FileComponentDetector { private readonly IRustCliParser cliParser; - private readonly RustCargoLockParser cargoLockParser; + private readonly IRustCargoLockParser cargoLockParser; /// /// Initializes a new instance of the class. @@ -31,19 +31,21 @@ public class RustCliDetector : FileComponentDetector /// The environment variable reader service. /// The logger. /// Rust cli parser. + /// Rust cargo lock parser. public RustCliDetector( IComponentStreamEnumerableFactory componentStreamEnumerableFactory, IObservableDirectoryWalkerFactory walkerFactory, ICommandLineInvocationService cliService, IEnvironmentVariableService envVarService, ILogger logger, - IRustCliParser cliParser) + IRustCliParser cliParser, + IRustCargoLockParser cargoLockParser) { this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; this.Scanner = walkerFactory; this.Logger = logger; this.cliParser = cliParser; - this.cargoLockParser = new RustCargoLockParser(logger); + this.cargoLockParser = cargoLockParser; } /// diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs deleted file mode 100644 index 1c1705d6e..000000000 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustComponentDetector.cs +++ /dev/null @@ -1,709 +0,0 @@ -namespace Microsoft.ComponentDetection.Detectors.Rust; - -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Reactive.Linq; -using System.Reactive.Threading.Tasks; -using System.Threading; -using System.Threading.Tasks; -using global::DotNet.Globbing; -using Microsoft.ComponentDetection.Contracts; -using Microsoft.ComponentDetection.Contracts.Internal; -using Microsoft.ComponentDetection.Contracts.TypedComponent; -using Microsoft.Extensions.Logging; -using Tomlyn; -using Tomlyn.Model; - -/// -/// A unified Rust detector that orchestrates SBOM, CLI, and Crate parsing. -/// -public class RustComponentDetector : FileComponentDetector -{ - private static readonly TomlModelOptions TomlOptions = new TomlModelOptions - { - IgnoreMissingProperties = true, - }; - - private readonly IPathUtilityService pathUtilityService; - private readonly RustSbomParser sbomParser; - private readonly IRustCliParser cliParser; - private readonly RustCargoLockParser cargoLockParser; - private readonly IRustMetadataContextBuilder metadataContextBuilder; - - private readonly HashSet visitedDirs; - private readonly List visitedGlobRules; - private readonly StringComparer pathComparer; - private readonly StringComparison pathComparison; - private IReadOnlyDictionary> ownershipMap; - private Dictionary manifestMetadataCache; - private DetectionMode mode; - - public RustComponentDetector( - IComponentStreamEnumerableFactory componentStreamEnumerableFactory, - IObservableDirectoryWalkerFactory walkerFactory, - ICommandLineInvocationService cliService, - IEnvironmentVariableService envVarService, - ILogger logger, - IRustMetadataContextBuilder metadataContextBuilder, - IPathUtilityService pathUtilityService, - IRustCliParser cliParser) - { - this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; - this.Scanner = walkerFactory; - this.Logger = logger; - this.pathUtilityService = pathUtilityService; - - // Initialize parsers - this.sbomParser = new RustSbomParser(logger); - this.cliParser = cliParser; - this.cargoLockParser = new RustCargoLockParser(logger); - this.metadataContextBuilder = metadataContextBuilder; - - // Initialize with uniform case-insensitive comparison across all platforms - this.pathComparer = StringComparer.OrdinalIgnoreCase; - this.pathComparison = StringComparison.OrdinalIgnoreCase; - this.visitedDirs = new HashSet(this.pathComparer); - this.visitedGlobRules = []; - this.manifestMetadataCache = new Dictionary(this.pathComparer); - } - - /// - /// Detection modes for the unified Rust detector. - /// - private enum DetectionMode - { - /// - /// Only use SBOM files for detection. - /// - SBOM_ONLY, - - /// - /// Use fallback strategy (Cargo CLI and/or Cargo.lock parsing). - /// - FALLBACK, - } - - /// - /// File kinds for skip logic. - /// - private enum FileKind - { - /// - /// Cargo.toml file. - /// - CargoToml, - - /// - /// Cargo.lock file. - /// - CargoLock, - - /// - /// Cargo SBOM file. - /// - CargoSbom, - } - - /// - public override string Id => nameof(RustComponentDetector); - - /// - public override IEnumerable Categories { get; } = ["Rust"]; - - /// - public override IEnumerable SupportedComponentTypes => [ComponentType.Cargo]; - - /// - public override int Version => 1; - - /// - public override IList SearchPatterns { get; } = ["Cargo.toml", "Cargo.lock", "*.cargo-sbom.json"]; - - /// - protected override async Task> OnPrepareDetectionAsync( - IObservable processRequests, - IDictionary detectorArgs, - CancellationToken cancellationToken = default) - { - this.Logger.LogInformation("Preparing Rust component detection"); - - // Step 1: Collect all process requests into a list - var allRequests = await processRequests.ToList().ToTask(cancellationToken); - - // Step 2: Determine detection mode - var hasSbomFiles = allRequests.Any(r => r.ComponentStream.Location.EndsWith(".cargo-sbom.json", this.pathComparison)); - this.mode = hasSbomFiles ? DetectionMode.SBOM_ONLY : DetectionMode.FALLBACK; - - this.Logger.LogInformation("Detection mode: {Mode}", this.mode); - - // Collect Cargo.toml paths ordered (depth, then path) - var tomlPaths = allRequests - .Select(r => r.ComponentStream.Location) - .Where(p => string.Equals(Path.GetFileName(p), "Cargo.toml", this.pathComparison)) - .OrderBy(p => this.GetDirectoryDepth(p)) - .ThenBy(p => p, this.pathComparer) - .ToList(); - - if (tomlPaths.Count > 0) - { - try - { - this.Logger.LogInformation("Building Rust ownership map from {Count} Cargo.toml files", tomlPaths.Count); - var ownership = await this.metadataContextBuilder.BuildPackageOwnershipMapAsync(tomlPaths, cancellationToken); - this.ownershipMap = ownership.PackageToTomls; - this.manifestMetadataCache = ownership.ManifestToMetadata; - this.Logger.LogInformation( - "Loaded Rust ownership (packages: {PkgCount}) and metadata cache (manifests: {ManifestCount})", - this.ownershipMap?.Count ?? 0, - this.manifestMetadataCache?.Count ?? 0); - - if (ownership.FailedManifests?.Count > 0) - { - this.Logger.LogInformation( - "Rust metadata failed for {Count} manifests (will rely on lockfiles): {Manifests}", - ownership.FailedManifests.Count, - string.Join(", ", ownership.FailedManifests)); - } - } - catch (Exception ex) - { - this.Logger.LogWarning(ex, "Failed to compute Rust ownership/metadata cache; proceeding without cache"); - this.ownershipMap = null; - this.manifestMetadataCache = null; - } - } - else - { - this.Logger.LogInformation("No Cargo.toml files found; ownership and metadata cache unavailable"); - this.ownershipMap = null; - this.manifestMetadataCache = null; - } - - IEnumerable filteredRequests; - if (this.mode == DetectionMode.SBOM_ONLY) - { - // Only SBOM files, ordered by path ascending - filteredRequests = allRequests - .Where(r => r.ComponentStream.Location.EndsWith(".cargo-sbom.json", this.pathComparison)) - .OrderBy(r => r.ComponentStream.Location, this.pathComparer); - - this.Logger.LogInformation("SBOM_ONLY mode: Processing {Count} SBOM files", filteredRequests.Count()); - } - else - { - // FALLBACK mode: Select Cargo.toml and Cargo.lock files - // Order: TOML before LOCK, then depth ascending, then path ascending - filteredRequests = allRequests - .Where(r => - { - var fileName = Path.GetFileName(r.ComponentStream.Location); - return fileName.Equals("Cargo.toml", this.pathComparison) || - fileName.Equals("Cargo.lock", this.pathComparison); - }) - .OrderBy(r => Path.GetFileName(r.ComponentStream.Location).Equals("Cargo.lock", this.pathComparison) ? 1 : 0) // TOML before LOCK - .ThenBy(r => this.GetDirectoryDepth(r.ComponentStream.Location)) - .ThenBy(r => r.ComponentStream.Location, this.pathComparer); - this.Logger.LogInformation("FALLBACK mode: Processing {Count} Cargo.toml/Cargo.lock files", filteredRequests.Count()); - } - - // Step 4: Return the ordered sequence as an observable - return filteredRequests.ToObservable(); - } - - /// - protected override async Task OnFileFoundAsync( - ProcessRequest processRequest, - IDictionary detectorArgs, - CancellationToken cancellationToken = default) - { - var componentStream = processRequest.ComponentStream; - var location = componentStream.Location; - var normLocation = this.pathUtilityService.NormalizePath(location); - var directory = Path.GetDirectoryName(location); - var normDirectory = this.pathUtilityService.NormalizePath(directory); - var fileName = Path.GetFileName(location); - - this.Logger.LogInformation("Processing file: {Location}", normLocation); - - // Determine file kind - FileKind fileKind; - if (fileName.Equals("Cargo.toml", this.pathComparison)) - { - fileKind = FileKind.CargoToml; - } - else if (fileName.Equals("Cargo.lock", this.pathComparison)) - { - fileKind = FileKind.CargoLock; - } - else - { - fileKind = FileKind.CargoSbom; - } - - // Check if directory should be skipped - if (this.ShouldSkip(directory, fileKind, location)) - { - this.Logger.LogInformation("Skipping file due to skip rules: {Location}", normLocation); - return; - } - - if (this.mode == DetectionMode.SBOM_ONLY) - { - await this.ProcessSbomFileAsync(processRequest, cancellationToken); - } - else - { - // FALLBACK mode - if (fileKind == FileKind.CargoToml) - { - await this.ProcessCargoTomlAsync(processRequest, directory, cancellationToken); - } - else if (fileKind == FileKind.CargoLock) - { - await this.ProcessCargoLockAsync(processRequest, directory, cancellationToken); - } - } - } - - /// - /// Calculates the depth of a directory path by counting the number of directory separators. - /// - /// The file or directory path to analyze. - /// - /// The number of directory separators in the normalized path, representing the depth. - /// Returns 0 if the path is null or empty. - /// - /// - /// The path is normalized to use forward slashes before counting separators. - /// This ensures consistent depth calculation across different operating systems. - /// - private int GetDirectoryDepth(string path) - { - if (string.IsNullOrEmpty(path)) - { - return 0; - } - - var normalizedPath = this.pathUtilityService.NormalizePath(path); - return normalizedPath.Count(c => c == Path.AltDirectorySeparatorChar); - } - - /// - /// Determines whether a file should be skipped based on visited directories and workspace glob rules. - /// - /// The directory path containing the file. - /// The kind of file being processed (CargoToml, CargoLock, or CargoSbom). - /// The full path to the file being evaluated. - /// - /// true if the file should be skipped; otherwise, false. - /// - /// - /// The skip logic follows these rules: - /// - /// If the directory has already been processed, skip immediately. - /// Workspace-only Cargo.toml files (with [workspace] but no [package] section) are never skipped. - /// Files are checked against workspace glob rules for inclusion/exclusion patterns. - /// - /// - private bool ShouldSkip(string directory, FileKind fileKind, string fullPath) - { - var normalizedDir = this.pathUtilityService.NormalizePath(directory); - - // 1. If directory already processed, skip immediately - if (this.visitedDirs.Contains(normalizedDir)) - { - return true; - } - - // 2. Workspace-only Cargo.toml should always be processed (never skipped) - if (fileKind == FileKind.CargoToml && this.IsWorkspaceOnlyToml(fullPath)) - { - return false; - } - - var normalizedFullPath = this.pathUtilityService.NormalizePath(fullPath); - - // 3. Check each workspace rule for inclusion/exclusion - foreach (var rule in this.visitedGlobRules) - { - if (!this.IsDescendantOf(normalizedDir, rule.Root)) - { - continue; - } - - var relativePath = this.GetRelativePath(rule.Root, normalizedDir); - - // Match against include globs - var matchesInclude = rule.IncludeGlobs.Any(g => - g.IsMatch(relativePath) || g.IsMatch(normalizedFullPath)); - - if (!matchesInclude) - { - continue; - } - - // Match against exclude globs - var matchesExclude = rule.ExcludeGlobs.Any(g => - g.IsMatch(relativePath) || g.IsMatch(normalizedFullPath)); - - if (matchesExclude) - { - continue; - } - - // If included and not excluded, skip this directory - return true; - } - - return false; - } - - /// - /// Adds a glob rule for workspace member filtering based on include and exclude patterns. - /// - /// The root directory path where the workspace is defined. - /// Collection of glob patterns to include workspace members (e.g., "member1", "path/*"). - /// Collection of glob patterns to exclude workspace members (e.g., "examples/*", "tests/*"). - /// - /// This method normalizes all paths and patterns for cross-platform compatibility. - /// On Windows, patterns are evaluated case-insensitively, while on other platforms they are case-sensitive. - /// The glob rule is used to determine whether files in descendant directories should be skipped during detection. - /// - private void AddGlobRule(string root, IEnumerable includes, IEnumerable excludes) - { - var normalizedRoot = this.pathUtilityService.NormalizePath(root); - var includesList = includes?.ToList() ?? []; - var excludesList = excludes?.ToList() ?? []; - - var globOptions = new GlobOptions - { - Evaluation = new EvaluationOptions - { - CaseInsensitive = true, - }, - }; - - var includeGlobs = new List(); - foreach (var pattern in includesList) - { - var normalizedPattern = this.pathUtilityService.NormalizePath(pattern); - includeGlobs.Add(Glob.Parse(normalizedPattern, globOptions)); - } - - var excludeGlobs = new List(); - foreach (var pattern in excludesList) - { - var normalizedPattern = this.pathUtilityService.NormalizePath(pattern); - excludeGlobs.Add(Glob.Parse(normalizedPattern, globOptions)); - } - - var rule = new GlobRule - { - Root = normalizedRoot, - Includes = includesList, - Excludes = excludesList, - IncludeGlobs = includeGlobs, - ExcludeGlobs = excludeGlobs, - }; - - this.visitedGlobRules.Add(rule); - this.Logger.LogDebug("Added glob rule with root {Root}, {IncludeCount} includes, {ExcludeCount} excludes", normalizedRoot, includesList.Count, excludesList.Count); - } - - /// - /// Determines if the specified path is a descendant of the potential parent directory. - /// - /// The path to check. - /// The potential parent directory path. - /// - /// true if is a descendant of or equal to ; otherwise, false. - /// - /// - /// This method normalizes both paths for cross-platform comparison and handles case sensitivity based on the operating system. - /// The comparison treats paths with and without trailing separators as equivalent. - /// - private bool IsDescendantOf(string path, string potentialParent) - { - var normalizedPath = this.pathUtilityService.NormalizePath(path); - var normalizedParent = this.pathUtilityService.NormalizePath(potentialParent); - - // Ensure parent path ends with separator for proper comparison - if (!normalizedParent.EndsWith('/')) - { - normalizedParent += "/"; - } - - return normalizedPath.StartsWith(normalizedParent, this.pathComparison) || - normalizedPath.Equals(normalizedParent.TrimEnd('/'), this.pathComparison); - } - - /// - /// Calculates the relative path from a base path to a full path. - /// - /// The base directory path to calculate relative to. - /// The full path to convert to a relative path. - /// - /// The relative path from to . - /// If is not under , returns the normalized full path. - /// - /// - /// The method normalizes both paths for cross-platform comparison and handles case sensitivity based on the operating system. - /// The base path is automatically appended with a trailing separator if not present to ensure correct path comparison. - /// - private string GetRelativePath(string basePath, string fullPath) - { - var normalizedBase = this.pathUtilityService.NormalizePath(basePath); - var normalizedFull = this.pathUtilityService.NormalizePath(fullPath); - - if (!normalizedBase.EndsWith('/')) - { - normalizedBase += "/"; - } - - if (normalizedFull.StartsWith(normalizedBase, this.pathComparison)) - { - return normalizedFull[normalizedBase.Length..]; - } - - return normalizedFull; - } - - /// - /// Determines whether the specified Cargo.toml file is a workspace-only configuration file. - /// - /// The full path to the Cargo.toml file to analyze. - /// - /// true if the file contains a [workspace] section but no [package] section, indicating it is a workspace-only file; - /// otherwise, false. - /// - /// - /// A workspace-only Cargo.toml file defines workspace configuration and members but does not define a package itself. - /// Such files should always be processed during detection and never skipped, as they provide critical workspace structure information. - /// If the file cannot be parsed, this method logs a warning and returns false to allow continued processing. - /// - private bool IsWorkspaceOnlyToml(string cargoTomlPath) - { - try - { - var content = File.ReadAllText(cargoTomlPath); - var tomlTable = Toml.ToModel(content, options: TomlOptions); - - // Check if it has a [workspace] section but no [package] section - var hasWorkspace = tomlTable.ContainsKey("workspace"); - var hasPackage = tomlTable.ContainsKey("package"); - - return hasWorkspace && !hasPackage; - } - catch (Exception e) - { - this.Logger.LogWarning(e, "Failed to check if {Path} is workspace-only", cargoTomlPath); - return false; - } - } - - /// - /// Processes a Cargo SBOM file asynchronously by parsing it and recording the lockfile version if available. - /// - /// The process request containing the component stream for the SBOM file. - /// A cancellation token to observe while waiting for the task to complete. - /// A task that represents the asynchronous operation. - /// - /// This method delegates parsing to the and records the lockfile version - /// in telemetry if the parsing operation returns a version number. - /// - private async Task ProcessSbomFileAsync(ProcessRequest processRequest, CancellationToken cancellationToken) - { - // Just before calling ParseAsync - this.Logger.LogDebug( - "SBOM parse starting. Recorder manifest location = {ManifestLocation}; SBOM stream location = {StreamLocation}", - processRequest.SingleFileComponentRecorder.ManifestFileLocation, - processRequest.ComponentStream.Location); - - int? version; - if (this.ownershipMap != null) - { - version = await this.sbomParser.ParseWithOwnershipAsync( - processRequest.ComponentStream, - processRequest.SingleFileComponentRecorder, - this.ComponentRecorder, - this.ownershipMap, - cancellationToken); - } - else - { - version = await this.sbomParser.ParseAsync( - processRequest.ComponentStream, - processRequest.SingleFileComponentRecorder, - cancellationToken); - } - - if (version.HasValue) - { - this.RecordLockfileVersion(version.Value); - } - } - - /// - /// Processes a Cargo.toml file asynchronously by attempting to execute the Cargo CLI for metadata extraction. - /// - /// The process request containing the component stream for the Cargo.toml file. - /// The directory path where the Cargo.toml file is located. - /// A cancellation token to observe while waiting for the task to complete. - /// A task that represents the asynchronous operation. - /// - /// This method delegates parsing to the which executes the 'cargo metadata' command. - /// If the CLI parsing is successful, the method: - /// - /// Adds all local package directories found in the workspace to the visited directories set to prevent duplicate processing. - /// Marks the current directory as visited. - /// - /// If the CLI parsing fails, the directory is not marked as visited, allowing fallback to Cargo.lock parsing if available. - /// - private async Task ProcessCargoTomlAsync(ProcessRequest processRequest, string directory, CancellationToken cancellationToken) - { - var normalized = this.pathUtilityService.NormalizePath(processRequest.ComponentStream.Location); - if (this.manifestMetadataCache != null && - this.manifestMetadataCache.TryGetValue(normalized, out var cachedMetadata)) - { - this.Logger.LogDebug("Using cached cargo metadata for {Location}", normalized); - - var parentRecorder = processRequest.SingleFileComponentRecorder.GetParentComponentRecorder(); - var result = await this.cliParser.ParseFromMetadataAsync( - processRequest.ComponentStream, - processRequest.SingleFileComponentRecorder, - cachedMetadata, - this.ComponentRecorder, - this.ownershipMap, - cancellationToken); - - if (result.Success) - { - foreach (var dir in result.LocalPackageDirectories) - { - this.visitedDirs.Add(dir); - } - - this.visitedDirs.Add(this.pathUtilityService.NormalizePath(directory)); - } - } - else - { - this.Logger.LogWarning("No cached cargo metadata for {Location}", processRequest.ComponentStream.Location); - } - } - - /// - /// Processes a Cargo.lock file asynchronously by parsing it and extracting component information. - /// - /// The process request containing the component stream for the Cargo.lock file. - /// The directory path where the Cargo.lock file is located. - /// A cancellation token to observe while waiting for the task to complete. - /// A task that represents the asynchronous operation. - /// - /// This method performs the following steps: - /// - /// Delegates parsing of the Cargo.lock file to the . - /// If parsing is successful and returns a lockfile version, records the version in telemetry. - /// Checks if a corresponding Cargo.toml file exists in the same directory. - /// If Cargo.toml exists, parses its workspace tables to extract member and exclude patterns. - /// Marks the current directory as visited to prevent duplicate processing. - /// - /// The workspace table processing enables proper glob-based filtering of workspace members in subsequent detection operations. - /// - private async Task ProcessCargoLockAsync(ProcessRequest processRequest, string directory, CancellationToken cancellationToken) - { - var version = await this.cargoLockParser.ParseAsync( - processRequest.ComponentStream, - processRequest.SingleFileComponentRecorder, - cancellationToken); - - if (version.HasValue) - { - this.RecordLockfileVersion(version.Value); - - // Check if Cargo.toml exists in same directory to parse workspace tables - var cargoTomlPath = Path.Combine(directory, "Cargo.toml"); - if (File.Exists(cargoTomlPath)) - { - await this.ProcessWorkspaceTablesAsync(cargoTomlPath, directory); - } - - // Add current directory to visitedDirs - this.visitedDirs.Add(this.pathUtilityService.NormalizePath(directory)); - } - } - - /// - /// Processes the workspace tables from a Cargo.toml file asynchronously to extract member and exclude patterns. - /// - /// The full path to the Cargo.toml file to parse. - /// The directory path where the Cargo.toml file is located, used as the root for glob patterns. - /// A task that represents the asynchronous operation. - /// - /// This method parses the [workspace] section of a Cargo.toml file to extract: - /// - /// default-members or members arrays as include patterns for workspace members. - /// exclude array as exclude patterns for workspace members. - /// - /// If include patterns are found, they are added as a glob rule with the specified directory as the root. - /// This enables proper filtering of workspace members during subsequent detection operations. - /// If parsing fails, a warning is logged and the method continues without throwing an exception. - /// - private async Task ProcessWorkspaceTablesAsync(string cargoTomlPath, string directory) - { - try - { - var content = await File.ReadAllTextAsync(cargoTomlPath); - var tomlTable = Toml.ToModel(content, options: TomlOptions); - - if (tomlTable.ContainsKey("workspace") && tomlTable["workspace"] is TomlTable workspaceTable) - { - var includes = new List(); - var excludes = new List(); - - // Parse default-members or members - if (workspaceTable.ContainsKey("default-members") && workspaceTable["default-members"] is TomlArray defaultMembers) - { - includes.AddRange(defaultMembers.Cast()); - } - else if (workspaceTable.ContainsKey("members") && workspaceTable["members"] is TomlArray members) - { - includes.AddRange(members.Cast()); - } - - // Parse exclude - if (workspaceTable.ContainsKey("exclude") && workspaceTable["exclude"] is TomlArray excludeArray) - { - excludes.AddRange(excludeArray.Cast()); - } - - if (includes.Count > 0) - { - this.AddGlobRule(directory, includes, excludes); - } - } - } - catch (Exception e) - { - this.Logger.LogWarning(e, "Failed to parse workspace tables from {Path}", cargoTomlPath); - } - } - - /// - /// Represents a glob rule with root directory and include/exclude patterns. - /// - private class GlobRule - { - public string Root { get; set; } - - public List Includes { get; set; } - - public List Excludes { get; set; } - - public List IncludeGlobs { get; set; } - - public List ExcludeGlobs { get; set; } - } -} diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs index 62a425783..d9f170d12 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs @@ -11,17 +11,18 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; public class RustCrateDetector : FileComponentDetector { private const string CargoLockSearchPattern = "Cargo.lock"; - private readonly RustCargoLockParser parser; + private readonly IRustCargoLockParser parser; public RustCrateDetector( IComponentStreamEnumerableFactory componentStreamEnumerableFactory, IObservableDirectoryWalkerFactory walkerFactory, - ILogger logger) + ILogger logger, + IRustCargoLockParser parser) { this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; this.Scanner = walkerFactory; this.Logger = logger; - this.parser = new RustCargoLockParser(logger); + this.parser = parser; } public override string Id => "RustCrateDetector"; diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs index 19d2d2ccd..0d686f5c2 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs @@ -27,9 +27,9 @@ public class RustSbomDetector : FileComponentDetector }; private readonly IPathUtilityService pathUtilityService; - private readonly RustSbomParser sbomParser; + private readonly IRustSbomParser sbomParser; private readonly IRustCliParser cliParser; - private readonly RustCargoLockParser cargoLockParser; + private readonly IRustCargoLockParser cargoLockParser; private readonly IRustMetadataContextBuilder metadataContextBuilder; private readonly HashSet visitedDirs; @@ -43,12 +43,12 @@ public class RustSbomDetector : FileComponentDetector public RustSbomDetector( IComponentStreamEnumerableFactory componentStreamEnumerableFactory, IObservableDirectoryWalkerFactory walkerFactory, - ICommandLineInvocationService cliService, - IEnvironmentVariableService envVarService, ILogger logger, IRustMetadataContextBuilder metadataContextBuilder, IPathUtilityService pathUtilityService, - IRustCliParser cliParser) + IRustCliParser cliParser, + IRustSbomParser sbomParser, + IRustCargoLockParser cargoLockParser) { this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; this.Scanner = walkerFactory; @@ -56,9 +56,9 @@ public RustSbomDetector( this.pathUtilityService = pathUtilityService; // Initialize parsers - this.sbomParser = new RustSbomParser(logger); + this.sbomParser = sbomParser; this.cliParser = cliParser; - this.cargoLockParser = new RustCargoLockParser(logger); + this.cargoLockParser = cargoLockParser; this.metadataContextBuilder = metadataContextBuilder; // Initialize with uniform case-insensitive comparison across all platforms diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs index 42b6e1f7f..1e3806ff2 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Extensions/ServiceCollectionExtensions.cs @@ -55,7 +55,9 @@ public static IServiceCollection AddComponentDetection(this IServiceCollection s services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Command line services services.AddSingleton(); From bc6a7b2ddd4ca75f7895fb08d52f76d4b0fde2aa Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Thu, 16 Oct 2025 17:34:46 -0700 Subject: [PATCH 14/23] Fix rust UTs --- .../RustCliDetectorTests.cs | 1 + .../RustCrateDetectorTests.cs | 8 ++++++++ .../RustSbomDetectorTests.cs | 13 +++++++++++++ 3 files changed, 22 insertions(+) diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs index d21c2f037..2549c72f1 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs @@ -524,6 +524,7 @@ public void InitMocks() this.mockEnvVarService = new Mock(); this.DetectorTestUtility.AddServiceMock(this.mockEnvVarService); this.DetectorTestUtility.AddService(new RustCliParser(this.mockCliService.Object, this.mockEnvVarService.Object, new PathUtilityService(new Mock>().Object), this.mockRustCliParserLogger.Object)); + this.DetectorTestUtility.AddService(new RustCargoLockParser(new Mock>().Object)); } [TestMethod] diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCrateDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCrateDetectorTests.cs index 07e7abdc8..c71cc4bd7 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCrateDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCrateDetectorTests.cs @@ -12,7 +12,9 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using Microsoft.ComponentDetection.Detectors.Rust; using Microsoft.ComponentDetection.Detectors.Tests.Utilities; using Microsoft.ComponentDetection.TestsUtilities; +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; [TestClass] [TestCategory("Governance/All")] @@ -230,6 +232,12 @@ public class RustCrateDetectorTests : BaseDetectorTest source = ""registry+https://github.com/rust-lang/crates.io-index"" "; + [TestInitialize] + public void TestInitialize() + { + this.DetectorTestUtility.AddService(new RustCargoLockParser(new Mock>().Object)); + } + [TestMethod] public async Task TestGraphIsCorrectAsync() { diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs index 7e01e2cf6..a9115ba26 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs @@ -4,11 +4,14 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using System.Linq; using System.Threading.Tasks; using FluentAssertions; +using Microsoft.ComponentDetection.Common; using Microsoft.ComponentDetection.Contracts; using Microsoft.ComponentDetection.Detectors.Rust; using Microsoft.ComponentDetection.Detectors.Rust.Sbom.Contracts; using Microsoft.ComponentDetection.TestsUtilities; +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; [TestClass] [TestCategory("Governance/All")] @@ -202,6 +205,16 @@ public class RustSbomDetectorTests : BaseDetectorTest ""target"": ""x86_64-pc-windows-msvc"" }"; + [TestInitialize] + public void Initialize() + { + this.DetectorTestUtility.AddService(new PathUtilityService(new Mock>().Object)); + this.DetectorTestUtility.AddService(new Mock().Object); + this.DetectorTestUtility.AddService(new Mock().Object); + this.DetectorTestUtility.AddService(new Mock().Object); + this.DetectorTestUtility.AddService(new RustSbomParser(new Mock>().Object)); + } + [TestMethod] public async Task TestGraphIsCorrectAsync() { From 50706b6a729186fd5cf972a608c77e90ffe963d9 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Fri, 17 Oct 2025 13:19:40 -0700 Subject: [PATCH 15/23] Add UTs for rust parsers --- .../RustCargoLockParserTests.cs | 733 +++++++++++++++ .../RustCliParserTests.cs | 871 ++++++++++++++++++ .../RustSbomParserTests.cs | 718 +++++++++++++++ 3 files changed, 2322 insertions(+) create mode 100644 test/Microsoft.ComponentDetection.Detectors.Tests/RustCargoLockParserTests.cs create mode 100644 test/Microsoft.ComponentDetection.Detectors.Tests/RustCliParserTests.cs create mode 100644 test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomParserTests.cs diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCargoLockParserTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCargoLockParserTests.cs new file mode 100644 index 000000000..4a2c398b7 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCargoLockParserTests.cs @@ -0,0 +1,733 @@ +namespace Microsoft.ComponentDetection.Detectors.Tests; + +using System; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.ComponentDetection.Common; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Detectors.Rust; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +[TestClass] +[TestCategory("Governance/All")] +[TestCategory("Governance/ComponentDetection")] +public class RustCargoLockParserTests +{ + private const string CratesIo = "registry+https://github.com/rust-lang/crates.io-index"; + private RustCargoLockParser parser; + private Mock> logger; + + [TestInitialize] + public void Init() + { + this.logger = new Mock>(); + this.parser = new RustCargoLockParser(this.logger.Object); + } + + private static IComponentStream MakeStream(string name, string toml) + { + return new ComponentStream + { + Location = name, + Pattern = "Cargo.lock", + Stream = new MemoryStream(Encoding.UTF8.GetBytes(toml)), + }; + } + + private static (int Usages, int ExplicitRoots, int Edges, int Failures) Analyze(Mock recorder) + { + var usageInvocations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + var explicitRoots = 0; + var edges = 0; + foreach (var inv in usageInvocations) + { + // Signature: RegisterUsage(DetectedComponent dc, bool isExplicitReferencedDependency = false, string parentComponentId = null, bool isDevelopmentDependency = false) + if (inv.Arguments.Count >= 2 && inv.Arguments[1] is bool explicitFlag && explicitFlag) + { + explicitRoots++; + } + + if (inv.Arguments.Count >= 3 && inv.Arguments[2] is string parentId) + { + edges++; + } + } + + var failures = recorder.Invocations.Count(i => i.Method.Name == "RegisterPackageParseFailure"); + return (usageInvocations.Count, explicitRoots, edges, failures); + } + + [TestMethod] + public async Task ParseAsync_NoPackages_ReturnsVersion_NoUsage() + { + var toml = """ + version = 3 + # No [[package]] tables + """; + + var recorder = new Mock(MockBehavior.Loose); + var version = await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object, CancellationToken.None); + + version.Should().Be(3); + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + usages.Should().Be(0); + explicitRoots.Should().Be(0); + edges.Should().Be(0); + failures.Should().Be(0); + } + + [TestMethod] + public async Task SingleRemotePackage_RootMarked() + { + var toml = $""" + version = 3 + + [[package]] + name = "foo" + version = "1.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + + // Pass1 initial + Pass3 explicit root + usages.Should().Be(2); + explicitRoots.Should().Be(1); + edges.Should().Be(0); + failures.Should().Be(0); + } + + [TestMethod] + public async Task LocalOnlyPackage_NoUsage() + { + var toml = """ + [[package]] + name = "local" + version = "0.1.0" + # no source => local + """; + + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + usages.Should().Be(0); + explicitRoots.Should().Be(0); + edges.Should().Be(0); + failures.Should().Be(0); + } + + [TestMethod] + public async Task RemoteParentDependsOnRemoteChild_EdgeAndRoot() + { + var toml = $""" + [[package]] + name = "parent" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [ + "child 2.0.0 ({CratesIo})" + ] + + [[package]] + name = "child" + version = "2.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + + // parent + child initial =2, child edge registration =1, plus root marking for parent =1 => total 4 + usages.Should().Be(4); + explicitRoots.Should().Be(1); // only parent (child seen as dependency) + edges.Should().Be(1); + failures.Should().Be(0); + } + + [TestMethod] + public async Task LocalParentDependsOnRemoteChild_ChildMarkedExplicitThroughEdge() + { + var toml = $""" + [[package]] + name = "local_parent" + version = "0.1.0" + + [[package]] + name = "child" + version = "1.2.3" + source = "{CratesIo}" + + [[package]] + name = "local_parent" + version = "0.1.0" + # duplicate logically (different table not needed but dependencies on another instance is ok) + dependencies = [ + "child 1.2.3 ({CratesIo})" + ] + """; + + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + + // child initial + explicit (from local parent edge) =2 usages, no Pass3 root (child is dependency) + usages.Should().BeGreaterOrEqualTo(2); + explicitRoots.Should().Be(1); // the explicit edge registration + edges.Should().Be(0); // edge from local parent recorded as root usage (no parentComponentId) + failures.Should().Be(0); + } + + [TestMethod] + public async Task DependencyOnLocalChild_Ignored() + { + var toml = $""" + [[package]] + name = "parent" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [ + "local 0.1.0" + ] + + [[package]] + name = "local" + version = "0.1.0" + """; + + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + + // parent initial + root marking + usages.Should().Be(2); + explicitRoots.Should().Be(1); + edges.Should().Be(0); // local child edge skipped + failures.Should().Be(0); + } + + [TestMethod] + public async Task AmbiguousDependency_ParseFailure() + { + var toml = $""" + [[package]] + name = "parent" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [ + "dup" + ] + + [[package]] + name = "dup" + version = "1.0.0" + source = "{CratesIo}" + + [[package]] + name = "dup" + version = "2.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + + // Initial registrations: parent + two dup variants = 3 + // Root markings (all three remote, none seen as dependency due to failure) => +3 = 6 total + usages.Should().Be(6); + explicitRoots.Should().Be(3); + edges.Should().Be(0); + failures.Should().Be(1); // one failure for ambiguous dep + } + + [TestMethod] + public async Task DependencyVersionMismatch_ParseFailure() + { + var toml = $""" + [[package]] + name = "parent" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [ + "child 2.0.0" + ] + + [[package]] + name = "child" + version = "1.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (_, explicitRoots, edges, failures) = Analyze(recorder); + explicitRoots.Should().Be(2); // parent + child (child not seen as dependency because mismatch) + edges.Should().Be(0); + failures.Should().Be(1); + } + + [TestMethod] + public async Task DependencySourceMismatch_ParseFailure() + { + var toml = $""" + [[package]] + name = "parent" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [ + "child 1.0.0 (some+other+registry)" + ] + + [[package]] + name = "child" + version = "1.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (_, explicitRoots, edges, failures) = Analyze(recorder); + explicitRoots.Should().Be(2); // parent + child + edges.Should().Be(0); + failures.Should().Be(1); + } + + [TestMethod] + public async Task MalformedDependencyString_ParseFailure() + { + var toml = $""" + [[package]] + name = "parent" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [ + "child 1.0.0 ({CratesIo}", # missing closing paren => no match + " " # whitespace + ] + + [[package]] + name = "child" + version = "1.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (_, explicitRoots, edges, failures) = Analyze(recorder); + explicitRoots.Should().Be(2); // parent + child (child not seen) + edges.Should().Be(0); + failures.Should().Be(2); + } + + [TestMethod] + public async Task DependencyRegexVariants_AllResolved() + { + // Valid dependency forms (per Cargo.lock expectations): + // name + // name version + // name version (source) + // (name + source without version is intentionally NOT present) + var toml = $""" + [[package]] + name = "parent" + version = "0.1.0" + source = "{CratesIo}" + dependencies = [ + "onlyname", + "withver 1.0.0", + "withboth 2.0.0 ({CratesIo})" + ] + + [[package]] + name = "onlyname" + version = "9.9.9" + source = "{CratesIo}" + + [[package]] + name = "withver" + version = "1.0.0" + source = "{CratesIo}" + + [[package]] + name = "withboth" + version = "2.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (_, explicitRoots, edges, failures) = Analyze(recorder); + failures.Should().Be(0); + + // parent is the only explicit root; all children referenced as dependencies + explicitRoots.Should().Be(1); + edges.Should().Be(3); + } + + [TestMethod] + public async Task DuplicatePackageEntries_SecondIgnored() + { + var toml = $""" + [[package]] + name = "dup" + version = "1.0.0" + source = "{CratesIo}" + + [[package]] + name = "dup" + version = "1.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + + // First registration + root marking = 2 + usages.Should().Be(2); + explicitRoots.Should().Be(1); + edges.Should().Be(0); + failures.Should().Be(0); + } + + [TestMethod] + public async Task ParseAsync_TomlParseFailure_ReturnsNull() + { + var badToml = """ + [[package + name = "broken" + """; // malformed + + var recorder = new Mock(MockBehavior.Loose); + var version = await this.parser.ParseAsync(MakeStream("Cargo.lock", badToml), recorder.Object); + version.Should().BeNull(); + + // No usage attempts on parse failure + Analyze(recorder).Usages.Should().Be(0); + } + + [TestMethod] + public async Task MultipleRemotePackages_WithoutDependencies_AllMarkedAsRoots() + { + // Tests that multiple independent remote packages are all marked as explicit roots + var toml = $""" + version = 3 + + [[package]] + name = "package-a" + version = "1.0.0" + source = "{CratesIo}" + + [[package]] + name = "package-b" + version = "2.0.0" + source = "{CratesIo}" + + [[package]] + name = "package-c" + version = "3.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + + // Each package: initial registration + root marking = 2 * 3 = 6 + usages.Should().Be(6); + explicitRoots.Should().Be(3); + edges.Should().Be(0); + failures.Should().Be(0); + } + + [TestMethod] + public async Task ComplexDependencyGraph_MultipleParentsOneChild() + { + // Tests that a child with multiple parents is handled correctly + var toml = $""" + [[package]] + name = "parent-a" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [ + "shared 1.0.0 ({CratesIo})" + ] + + [[package]] + name = "parent-b" + version = "2.0.0" + source = "{CratesIo}" + dependencies = [ + "shared 1.0.0 ({CratesIo})" + ] + + [[package]] + name = "shared" + version = "1.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + + // Initial: parent-a, parent-b, shared = 3 + // Edges: 2 (one from each parent) + // Roots: parent-a, parent-b = 2 (shared is seen as dependency) + usages.Should().Be(7); // 3 initial + 2 edges + 2 roots + explicitRoots.Should().Be(2); + edges.Should().Be(2); + failures.Should().Be(0); + } + + [TestMethod] + public async Task TransitiveDependencies_ThreeLevels() + { + // Tests a chain: root -> intermediate -> leaf + var toml = $""" + [[package]] + name = "root" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [ + "intermediate 1.0.0 ({CratesIo})" + ] + + [[package]] + name = "intermediate" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [ + "leaf 1.0.0 ({CratesIo})" + ] + + [[package]] + name = "leaf" + version = "1.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + + // Initial: 3 packages + // Edges: 2 (root->intermediate, intermediate->leaf) + // Roots: 1 (only root) + usages.Should().Be(6); // 3 initial + 2 edges + 1 root + explicitRoots.Should().Be(1); + edges.Should().Be(2); + failures.Should().Be(0); + } + + [TestMethod] + public async Task MixedLocalAndRemote_ComplexGraph() + { + // Tests a mix of local and remote packages in a more complex graph + var toml = $""" + [[package]] + name = "local-root" + version = "0.1.0" + dependencies = [ + "remote-dep 1.0.0 ({CratesIo})" + ] + + [[package]] + name = "remote-dep" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [ + "local-intermediate 0.2.0" + ] + + [[package]] + name = "local-intermediate" + version = "0.2.0" + dependencies = [ + "remote-leaf 2.0.0 ({CratesIo})" + ] + + [[package]] + name = "remote-leaf" + version = "2.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + + // remote-dep: initial + explicit (from local-root) = 2 + // remote-leaf: initial (local-intermediate ignored as parent) = 1 + // remote-leaf should be marked as root since local parent edge doesn't count + usages.Should().BeGreaterOrEqualTo(3); + explicitRoots.Should().BeGreaterOrEqualTo(1); + failures.Should().Be(0); + } + + [TestMethod] + public async Task DependencyWithEmptyString_ParseFailure() + { + // Tests handling of empty dependency strings + var toml = $""" + [[package]] + name = "parent" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [ + "" + ] + + [[package]] + name = "child" + version = "1.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (_, explicitRoots, edges, failures) = Analyze(recorder); + + explicitRoots.Should().Be(2); // Both marked as roots (no valid edge created) + edges.Should().Be(0); + failures.Should().Be(1); + } + + [TestMethod] + public async Task NonExistentDependency_ParseFailure() + { + // Tests when a dependency references a package that doesn't exist in the lock file + var toml = $""" + [[package]] + name = "parent" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [ + "nonexistent 1.0.0 ({CratesIo})" + ] + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (_, explicitRoots, edges, failures) = Analyze(recorder); + + explicitRoots.Should().Be(1); // Only parent + edges.Should().Be(0); + failures.Should().Be(1); + } + + [TestMethod] + public async Task ProcessCargoLock_ExceptionHandling_ContinuesGracefully() + { + // Tests that exceptions in ProcessCargoLock are caught and logged + // This would require a specially crafted TOML that parses but causes issues in processing + var toml = $""" + version = 3 + + [[package]] + name = "valid" + version = "1.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + + // Should not throw even if processing has issues + var act = async () => await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + await act.Should().NotThrowAsync(); + } + + [TestMethod] + public async Task NullOrEmptyDependenciesArray_NoFailures() + { + // Tests package with explicit empty dependencies array + var toml = $""" + [[package]] + name = "package" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [] + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + + usages.Should().Be(2); // Initial + root marking + explicitRoots.Should().Be(1); + edges.Should().Be(0); + failures.Should().Be(0); + } + + [TestMethod] + public async Task DifferentVersionsSamePackage_BothRegistered() + { + // Tests that different versions of the same package are treated separately + var toml = $""" + [[package]] + name = "parent" + version = "1.0.0" + source = "{CratesIo}" + dependencies = [ + "dep 1.0.0 ({CratesIo})", + "dep 2.0.0 ({CratesIo})" + ] + + [[package]] + name = "dep" + version = "1.0.0" + source = "{CratesIo}" + + [[package]] + name = "dep" + version = "2.0.0" + source = "{CratesIo}" + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeStream("Cargo.lock", toml), recorder.Object); + + var (usages, explicitRoots, edges, failures) = Analyze(recorder); + + // parent + dep v1 + dep v2 initial = 3 + // 2 edges (parent -> dep v1, parent -> dep v2) = 2 + // 1 root (parent) = 1 + usages.Should().Be(6); + explicitRoots.Should().Be(1); + edges.Should().Be(2); + failures.Should().Be(0); + } +} diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliParserTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliParserTests.cs new file mode 100644 index 000000000..76c350889 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliParserTests.cs @@ -0,0 +1,871 @@ +namespace Microsoft.ComponentDetection.Detectors.Tests; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.ComponentDetection.Common; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.BcdeModels; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.ComponentDetection.Detectors.Rust; +using Microsoft.ComponentDetection.Detectors.Rust.Contracts; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; +using static Microsoft.ComponentDetection.Detectors.Rust.IRustCliParser; + +[TestClass] +[TestCategory("Governance/All")] +[TestCategory("Governance/ComponentDetection")] +public class RustCliParserTests +{ + private RustCliParser parser; + private Mock cli; + private Mock env; + private Mock> logger; + + [TestInitialize] + public void Init() + { + this.cli = new Mock(MockBehavior.Strict); + this.env = new Mock(MockBehavior.Strict); + this.logger = new Mock>(MockBehavior.Loose); + this.env.Setup(e => e.IsEnvironmentVariableValueTrue(It.IsAny())).Returns(false); + + this.parser = new RustCliParser(this.cli.Object, this.env.Object, new PathUtilityService(new Mock>().Object), this.logger.Object); + } + + private static IComponentStream MakeTomlStream(string path) => + new ComponentStream { Location = path, Pattern = "Cargo.toml", Stream = new MemoryStream(Encoding.UTF8.GetBytes("[package]\nname=\"x\"")) }; + + // kind: build (non-dev), kind: dev (development), or absent/null. + private static string BuildNormalRootMetadataJson() => """ + { + "packages": [ + { "name":"rootpkg", "version":"1.0.0", "id":"rootpkg 1.0.0", "authors":[""], "license":"", "source":null, "manifest_path":"C:/repo/root/Cargo.toml" }, + { "name":"childA", "version":"2.0.0", "id":"childA 2.0.0", "authors":["Alice"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/root/childA/Cargo.toml" }, + { "name":"childDev", "version":"3.0.0", "id":"childDev 3.0.0", "authors":["Bob"], "license":"Apache-2.0", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/root/childDev/Cargo.toml" } + ], + "resolve": { + "root":"rootpkg 1.0.0", + "nodes":[ + { "id":"rootpkg 1.0.0", + "deps":[ + { "pkg":"childA 2.0.0", "dep_kinds":[{"kind":"build"}] }, + { "pkg":"childDev 3.0.0", "dep_kinds":[{"kind":"dev"}] } + ] + }, + { "id":"childA 2.0.0", "deps":[] }, + { "id":"childDev 3.0.0", "deps":[] } + ] + } + } + """; + + private static string BuildVirtualManifestMetadataJson() => """ + { + "packages": [ + { "name":"virtA", "version":"0.2.0", "id":"virtA 0.2.0", "authors":["Ann"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/virtA/Cargo.toml" }, + { "name":"virtB", "version":"0.3.0", "id":"virtB 0.3.0", "authors":["Ben"], "license":"Apache-2.0", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/virtB/Cargo.toml" } + ], + "resolve": { + "root": null, + "nodes":[ + { "id":"virtA 0.2.0", "deps":[ { "pkg":"virtB 0.3.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"virtB 0.3.0", "deps":[] } + ] + } + } + """; + + private static CargoMetadata ParseMetadata(string json) => CargoMetadata.FromJson(json); + + [TestMethod] + public async Task ParseAsync_ManuallyDisabled_ReturnsFailure() + { + this.env.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(true); + + var result = await this.parser.ParseAsync(MakeTomlStream("C:/repo/Cargo.toml"), new Mock().Object); + result.Success.Should().BeFalse(); + result.FailureReason.Should().Be("Manually Disabled"); + + this.cli.Verify(c => c.CanCommandBeLocatedAsync(It.IsAny(), null), Times.Never); + } + + [TestMethod] + public async Task ParseAsync_CargoNotFound_ReturnsFailure() + { + this.cli.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(false); + + var result = await this.parser.ParseAsync(MakeTomlStream("C:/repo/Cargo.toml"), new Mock().Object); + result.Success.Should().BeFalse(); + result.FailureReason.Should().Be("Could not locate cargo command"); + } + + [TestMethod] + public async Task ParseAsync_MetadataCommandFailure_ReturnsFailure() + { + this.cli.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + this.cli.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + "C:/repo/Cargo.toml", + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 1, StdErr = "error msg" }); + + var result = await this.parser.ParseAsync(MakeTomlStream("C:/repo/Cargo.toml"), new Mock().Object); + result.Success.Should().BeFalse(); + result.FailureReason.Should().Be("`cargo metadata` failed"); + result.ErrorMessage.Should().Be("error msg"); + } + + [TestMethod] + public async Task ParseAsync_MetadataCommandThrows_ReturnsFailure() + { + this.cli.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + this.cli.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + "C:/repo/Cargo.toml", + "--format-version=1", + "--locked")) + .ThrowsAsync(new InvalidOperationException("boom")); + + var result = await this.parser.ParseAsync(MakeTomlStream("C:/repo/Cargo.toml"), new Mock().Object); + result.Success.Should().BeFalse(); + result.FailureReason.Should().Be("Exception during cargo metadata"); + result.ErrorMessage.Should().Be("boom"); + } + + [TestMethod] + public async Task ParseAsync_Success_NormalRoot_RegistersChildrenAndFlags() + { + this.cli.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + var json = BuildNormalRootMetadataJson(); + this.cli.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + "C:/repo/Cargo.toml", + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json }); + + var recorder = new Mock(MockBehavior.Loose); + + var result = await this.parser.ParseAsync(MakeTomlStream("C:/repo/Cargo.toml"), recorder.Object); + result.Success.Should().BeTrue(); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().HaveCount(2); + + registrations.Should().OnlyContain(i => i.Arguments[1] != null && (bool)i.Arguments[1]); + + static string NameOf(IInvocation inv) => ((CargoComponent)((DetectedComponent)inv.Arguments[0]).Component).Name; + + var childDevInvocation = registrations.Single(r => NameOf(r) == "childDev"); + childDevInvocation.Arguments[3].Should().BeOfType().Which.Should().BeTrue(); + + var childAInvocation = registrations.Single(r => NameOf(r) == "childA"); + childAInvocation.Arguments[3].Should().Be(false); + } + + [TestMethod] + public async Task ParseAsync_Success_VirtualManifest_NoExplicitFlags() + { + this.cli.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + var json = BuildVirtualManifestMetadataJson(); + this.cli.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + "C:/repo/Cargo.toml", + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json }); + + var recorder = new Mock(MockBehavior.Loose); + var graph = new Mock(); + graph.Setup(g => g.Contains(It.IsAny())).Returns(false); + recorder.Setup(r => r.DependencyGraph).Returns(graph.Object); + + var result = await this.parser.ParseAsync(MakeTomlStream("C:/repo/Cargo.toml"), recorder.Object); + result.Success.Should().BeTrue(); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + + // NOTE: In virtual manifest scenarios the traversal uses two different visited keys: + // 1. Initial loop in ProcessMetadata: componentKey = dep.Id + // 2. Recursive traversal: componentKey = $"{detectedComponent.Component.Id}{dep.Pkg} {isTomlRoot}" + // This can yield an additional (duplicate) registration for a child when it is reached via another package. + // We now assert on distinct components rather than raw invocation count. + registrations.Count.Should().BeGreaterThanOrEqualTo(2); // Raw calls may be > distinct components + var distinctNames = registrations + .Select(r => ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name) + .Distinct() + .ToList(); + + distinctNames.Should().BeEquivalentTo(["virtA", "virtB"]); + + // All registrations in a virtual manifest should have explicit flag = false. + registrations.Should().OnlyContain(i => i.Arguments[1] != null && i.Arguments[1] is bool && !(bool)i.Arguments[1]); + } + + [TestMethod] + public async Task ParseFromMetadataAsync_NullMetadata_Failure() + { + var fallback = new Mock().Object; + var result = await this.parser.ParseFromMetadataAsync(MakeTomlStream("C:/repo/Cargo.toml"), fallback, null, null, null); + result.Success.Should().BeFalse(); + result.FailureReason.Should().Be("Cached metadata unavailable"); + } + + [TestMethod] + public async Task ParseFromMetadataAsync_ManuallyDisabled_Failure() + { + this.env.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(true); + var metadata = ParseMetadata(BuildNormalRootMetadataJson()); + var fallback = new Mock().Object; + var result = await this.parser.ParseFromMetadataAsync(MakeTomlStream("C:/repo/Cargo.toml"), fallback, metadata, null, null); + result.Success.Should().BeFalse(); + result.FailureReason.Should().Be("Manually Disabled"); + } + + [TestMethod] + public async Task ParseFromMetadataAsync_OwnershipMultipleOwners_RegistersForEach() + { + var metadata = ParseMetadata(BuildNormalRootMetadataJson()); + + var parentRecorder = new Mock(MockBehavior.Strict); + var owner1 = new Mock(MockBehavior.Loose); + var owner2 = new Mock(MockBehavior.Loose); + var fallback = new Mock(MockBehavior.Loose); + + parentRecorder.Setup(p => p.CreateSingleFileComponentRecorder("manifests/one")).Returns(owner1.Object); + parentRecorder.Setup(p => p.CreateSingleFileComponentRecorder("manifests/two")).Returns(owner2.Object); + + var ownershipMap = new Dictionary> + { + { "childA 2.0.0", new HashSet { "manifests/one", "manifests/two" } }, + { "childDev 3.0.0", new HashSet { "manifests/one", "manifests/two" } }, + }; + + var result = await this.parser.ParseFromMetadataAsync(MakeTomlStream("C:/repo/Cargo.toml"), fallback.Object, metadata, parentRecorder.Object, ownershipMap); + result.Success.Should().BeTrue(); + + owner1.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(2); + owner2.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(2); + fallback.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(0); + } + + [TestMethod] + public async Task ParseFromMetadataAsync_OwnershipFallback_NoOwners_UsesFallbackRecorderAsync() + { + var metadata = ParseMetadata(BuildNormalRootMetadataJson()); + + var parentRecorder = new Mock(MockBehavior.Strict); + var fallback = new Mock(MockBehavior.Loose); + + var ownershipMap = new Dictionary> { }; + + var result = await this.parser.ParseFromMetadataAsync(MakeTomlStream("C:/repo/Cargo.toml"), fallback.Object, metadata, parentRecorder.Object, ownershipMap); + result.Success.Should().BeTrue(); + + fallback.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(2); + parentRecorder.Invocations.Should().BeEmpty(); + } + + [TestMethod] + public async Task ProcessMetadata_LocalPackageDirectoriesCollectedAsync() + { + var metadata = ParseMetadata(BuildNormalRootMetadataJson()); + var fallback = new Mock(MockBehavior.Loose); + + var r = await this.InvokeProcessMetadataAsync("C:/repo/Cargo.toml", fallback.Object, metadata); + r.Success.Should().BeTrue(); + r.LocalPackageDirectories.Should().Contain("C:/repo/root"); + r.LocalPackageDirectories.Should().ContainSingle(); + } + + [TestMethod] + public async Task Traverse_MissingPackage_WarnsAndSkipsAsync() + { + var json = """ + { + "packages": [ + { "name":"pkgA","version":"1.0.0","id":"pkgA 1.0.0","authors":["A"],"license":"MIT","source":"registry+https://github.com/rust-lang/crates.io-index","manifest_path":"C:/p/A/Cargo.toml" } + ], + "resolve": { + "root":"pkgA 1.0.0", + "nodes":[ + { "id":"pkgA 1.0.0", "deps":[ { "pkg":"missing 2.0.0", "dep_kinds":[{"kind":"build"}] } ] } + ] + } + } + """; + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + var r = await this.InvokeProcessMetadataAsync("C:/p/Cargo.toml", fallback.Object, metadata); + r.Success.Should().BeTrue(); + fallback.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(0); + } + + [TestMethod] + public async Task Traverse_MissingGraphNode_WarnsAndSkipsAsync() + { + var json = """ + { + "packages": [ + { "name":"rootpkg","version":"1.0.0","id":"rootpkg 1.0.0","authors":["A"],"license":"MIT","source":null,"manifest_path":"C:/p/root/Cargo.toml" }, + { "name":"lonely","version":"2.0.0","id":"lonely 2.0.0","authors":["B"],"license":"MIT","source":"registry+https://github.com/rust-lang/crates.io-index","manifest_path":"C:/p/lonely/Cargo.toml" } + ], + "resolve": { + "root":"rootpkg 1.0.0", + "nodes":[ + { "id":"rootpkg 1.0.0", "deps":[ { "pkg":"lonely 2.0.0", "dep_kinds":[{"kind":"build"}] } ] } + ] + } + } + """; + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + var r = await this.InvokeProcessMetadataAsync("C:/p/Cargo.toml", fallback.Object, metadata); + r.Success.Should().BeTrue(); + fallback.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(0); + } + + [TestMethod] + public async Task Traverse_DuplicateDependency_OnlySingleRegistrationAsync() + { + var json = """ + { + "packages": [ + { "name":"rootpkg","version":"1.0.0","id":"rootpkg 1.0.0","authors":["A"],"license":"MIT","source":null,"manifest_path":"C:/p/root/Cargo.toml" }, + { "name":"childX","version":"2.0.0","id":"childX 2.0.0","authors":["C"],"license":"MIT","source":"registry+https://github.com/rust-lang/crates.io-index","manifest_path":"C:/p/childX/Cargo.toml" } + ], + "resolve": { + "root":"rootpkg 1.0.0", + "nodes":[ + { "id":"rootpkg 1.0.0", + "deps":[ + { "pkg":"childX 2.0.0", "dep_kinds":[{"kind":"build"}] }, + { "pkg":"childX 2.0.0", "dep_kinds":[{"kind":"build"}] } + ] + }, + { "id":"childX 2.0.0", "deps":[] } + ] + } + } + """; + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + var r = await this.InvokeProcessMetadataAsync("C:/p/Cargo.toml", fallback.Object, metadata); + r.Success.Should().BeTrue(); + fallback.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(1); + } + + // Updated expectation: any blank author causes overall Author=null per current implementation. + [TestMethod] + public async Task AuthorsAndLicenseNormalization_BlankAuthorsOrLicenseBecomeNullAsync() + { + var json = """ + { + "packages": [ + { "name":"rootpkg","version":"1.0.0","id":"rootpkg 1.0.0","authors":[""],"license":"", "source":null,"manifest_path":"C:/p/root/Cargo.toml" }, + { "name":"child","version":"2.0.0","id":"child 2.0.0","authors":["Alice",""],"license":"MIT","source":"registry+https://github.com/rust-lang/crates.io-index","manifest_path":"C:/p/child/Cargo.toml" } + ], + "resolve": { + "root":"rootpkg 1.0.0", + "nodes":[ + { "id":"rootpkg 1.0.0", "deps":[ { "pkg":"child 2.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"child 2.0.0", "deps":[] } + ] + } + } + """; + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + await this.InvokeProcessMetadataAsync("C:/p/Cargo.toml", fallback.Object, metadata); + + var reg = fallback.Invocations.Single(i => i.Method.Name == "RegisterUsage"); + var comp = ((DetectedComponent)reg.Arguments[0]).Component as CargoComponent; + comp.Author.Should().BeNull(); // Behavior: any blank entry nulls entire author set + comp.License.Should().Be("MIT"); + } + + [TestMethod] + public async Task AuthorsNormalization_NoBlanks_PreservesAuthorAsync() + { + var json = """ + { + "packages": [ + { "name":"root","version":"1.0.0","id":"root 1.0.0","authors":["Root"],"license":"MIT","source":null,"manifest_path":"C:/p/root/Cargo.toml" }, + { "name":"child","version":"2.0.0","id":"child 2.0.0","authors":["Alice"],"license":"Apache-2.0","source":"registry+https://github.com/rust-lang/crates.io-index","manifest_path":"C:/p/child/Cargo.toml" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[ { "pkg":"child 2.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"child 2.0.0", "deps":[] } + ] + } + } + """; + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + await this.InvokeProcessMetadataAsync("C:/p/Cargo.toml", fallback.Object, metadata); + + var reg = fallback.Invocations.Single(i => i.Method.Name == "RegisterUsage"); + var comp = ((DetectedComponent)reg.Arguments[0]).Component as CargoComponent; + comp.Author.Should().Be("Alice"); + comp.License.Should().Be("Apache-2.0"); + } + + [TestMethod] + public async Task AuthorsNormalization_AllBlanks_NullsAuthorAsync() + { + var json = """ + { + "packages": [ + { "name":"root","version":"1.0.0","id":"root 1.0.0","authors":[""],"license":"MIT","source":null,"manifest_path":"C:/p/root/Cargo.toml" }, + { "name":"child","version":"2.0.0","id":"child 2.0.0","authors":[""," "],"license":"MIT","source":"registry+https://github.com/rust-lang/crates.io-index","manifest_path":"C:/p/child/Cargo.toml" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[ { "pkg":"child 2.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"child 2.0.0", "deps":[] } + ] + } + } + """; + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + await this.InvokeProcessMetadataAsync("C:/p/Cargo.toml", fallback.Object, metadata); + + var reg = fallback.Invocations.Single(i => i.Method.Name == "RegisterUsage"); + var comp = ((DetectedComponent)reg.Arguments[0]).Component as CargoComponent; + comp.Author.Should().BeNull(); + comp.License.Should().Be("MIT"); + } + + [TestMethod] + public async Task AuthorsNormalization_EmptyOrNullArray_NullsAuthorAsync() + { + // Empty authors array + var jsonEmpty = """ + { + "packages": [ + { "name":"root","version":"1.0.0","id":"root 1.0.0","authors":[],"license":"MIT","source":null,"manifest_path":"C:/p/root/Cargo.toml" }, + { "name":"childEmpty","version":"2.0.0","id":"childEmpty 2.0.0","authors":[],"license":"MIT","source":"registry+https://github.com/rust-lang/crates.io-index","manifest_path":"C:/p/childEmpty/Cargo.toml" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[ { "pkg":"childEmpty 2.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"childEmpty 2.0.0", "deps":[] } + ] + } + } + """; + var metadataEmpty = ParseMetadata(jsonEmpty); + var fallbackEmpty = new Mock(MockBehavior.Loose); + await this.InvokeProcessMetadataAsync("C:/p/Cargo.toml", fallbackEmpty.Object, metadataEmpty); + var regEmpty = fallbackEmpty.Invocations.Single(i => i.Method.Name == "RegisterUsage"); + var compEmpty = ((DetectedComponent)regEmpty.Arguments[0]).Component as CargoComponent; + compEmpty.Author.Should().BeNull(); + + // Missing authors (null) + var jsonNull = """ + { + "packages": [ + { "name":"root","version":"1.0.0","id":"root 1.0.0","license":"MIT","source":null,"manifest_path":"C:/p/root/Cargo.toml" }, + { "name":"childNull","version":"2.0.0","id":"childNull 2.0.0","license":"MIT","source":"registry+https://github.com/rust-lang/crates.io-index","manifest_path":"C:/p/childNull/Cargo.toml" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[ { "pkg":"childNull 2.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"childNull 2.0.0", "deps":[] } + ] + } + } + """; + var metadataNull = ParseMetadata(jsonNull); + var fallbackNull = new Mock(MockBehavior.Loose); + await this.InvokeProcessMetadataAsync("C:/p/Cargo2.toml", fallbackNull.Object, metadataNull); + var regNull = fallbackNull.Invocations.Single(i => i.Method.Name == "RegisterUsage"); + var compNull = ((DetectedComponent)regNull.Arguments[0]).Component as CargoComponent; + compNull.Author.Should().BeNull(); + } + + [TestMethod] + public async Task LicenseNormalization_BlankLicense_NullsLicenseAsync() + { + var json = """ + { + "packages": [ + { "name":"rootpkg","version":"1.0.0","id":"rootpkg 1.0.0","authors":["A"],"license":"", "source":null,"manifest_path":"C:/p/root/Cargo.toml" }, + { "name":"child","version":"2.0.0","id":"child 2.0.0","authors":["B"],"license":"MIT","source":"registry+https://github.com/rust-lang/crates.io-index","manifest_path":"C:/p/child/Cargo.toml" } + ], + "resolve": { + "root":"rootpkg 1.0.0", + "nodes":[ + { "id":"rootpkg 1.0.0", "deps":[ { "pkg":"child 2.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"child 2.0.0", "deps":[] } + ] + } + } + """; + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + await this.InvokeProcessMetadataAsync("C:/p/Cargo.toml", fallback.Object, metadata); + + var reg = fallback.Invocations.Single(i => i.Method.Name == "RegisterUsage"); + var comp = ((DetectedComponent)reg.Arguments[0]).Component as CargoComponent; + comp.License.Should().Be("MIT"); // Root not registered (source=null); child has non-blank license. + } + + [TestMethod] + public async Task ParseAsync_MultipleTransitiveLevels_CorrectParentChildRelationships() + { + // Tests deep dependency chains with proper parent-child relationships + this.cli.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + var json = """ + { + "packages": [ + { "name":"root", "version":"1.0.0", "id":"root 1.0.0", "authors":["R"], "license":"MIT", "source":null, "manifest_path":"C:/repo/root/Cargo.toml" }, + { "name":"level1", "version":"1.0.0", "id":"level1 1.0.0", "authors":["L1"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/level1/Cargo.toml" }, + { "name":"level2", "version":"1.0.0", "id":"level2 1.0.0", "authors":["L2"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/level2/Cargo.toml" }, + { "name":"level3", "version":"1.0.0", "id":"level3 1.0.0", "authors":["L3"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/level3/Cargo.toml" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[ { "pkg":"level1 1.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"level1 1.0.0", "deps":[ { "pkg":"level2 1.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"level2 1.0.0", "deps":[ { "pkg":"level3 1.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"level3 1.0.0", "deps":[] } + ] + } + } + """; + + this.cli.Setup(c => c.ExecuteCommandAsync("cargo", null, null, It.IsAny(), "metadata", "--manifest-path", "C:/repo/Cargo.toml", "--format-version=1", "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json }); + + var recorder = new Mock(MockBehavior.Loose); + var graph = new Mock(); + + // Setup graph to validate parent-child relationships + var registeredComponents = new HashSet(); + graph.Setup(g => g.Contains(It.IsAny())).Returns(id => registeredComponents.Contains(id)); + recorder.Setup(r => r.DependencyGraph).Returns(graph.Object); + recorder.Setup(r => r.RegisterUsage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback( + (dc, explicitRef, parentId, isDevDep, dependencyScope, targetFramework) => registeredComponents.Add(dc.Component.Id)); + + var result = await this.parser.ParseAsync(MakeTomlStream("C:/repo/Cargo.toml"), recorder.Object); + result.Success.Should().BeTrue(); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + + // Should have registrations with proper parent relationships + registrations.Should().HaveCountGreaterOrEqualTo(3); + + // Verify at least one registration has a parent component ID + registrations.Should().Contain(r => r.Arguments[2] != null && !string.IsNullOrEmpty((string)r.Arguments[2])); + } + + [TestMethod] + public async Task ParseAsync_DiamondDependency_HandledCorrectly() + { + // Tests diamond dependency pattern: root -> A, B; A -> C; B -> C + this.cli.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + var json = """ + { + "packages": [ + { "name":"root", "version":"1.0.0", "id":"root 1.0.0", "authors":["R"], "license":"MIT", "source":null, "manifest_path":"C:/repo/root/Cargo.toml" }, + { "name":"depA", "version":"1.0.0", "id":"depA 1.0.0", "authors":["A"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/depA/Cargo.toml" }, + { "name":"depB", "version":"1.0.0", "id":"depB 1.0.0", "authors":["B"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/depB/Cargo.toml" }, + { "name":"shared", "version":"1.0.0", "id":"shared 1.0.0", "authors":["S"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/shared/Cargo.toml" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[ + { "pkg":"depA 1.0.0", "dep_kinds":[{"kind":"build"}] }, + { "pkg":"depB 1.0.0", "dep_kinds":[{"kind":"build"}] } + ] }, + { "id":"depA 1.0.0", "deps":[ { "pkg":"shared 1.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"depB 1.0.0", "deps":[ { "pkg":"shared 1.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"shared 1.0.0", "deps":[] } + ] + } + } + """; + + this.cli.Setup(c => c.ExecuteCommandAsync("cargo", null, null, It.IsAny(), "metadata", "--manifest-path", "C:/repo/Cargo.toml", "--format-version=1", "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json }); + + var recorder = new Mock(MockBehavior.Loose); + var graph = new Mock(); + var registeredComponents = new HashSet(); + graph.Setup(g => g.Contains(It.IsAny())).Returns(id => registeredComponents.Contains(id)); + recorder.Setup(r => r.DependencyGraph).Returns(graph.Object); + recorder.Setup(r => r.RegisterUsage(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Callback( + (dc, explicitRef, parentId, isDevDep, dependencyScope, targetFramework) => registeredComponents.Add(dc.Component.Id)); + + var result = await this.parser.ParseAsync(MakeTomlStream("C:/repo/Cargo.toml"), recorder.Object); + result.Success.Should().BeTrue(); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + + // Should register shared component multiple times (once per path) + var sharedRegistrations = registrations.Where(r => + ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name == "shared").ToList(); + + sharedRegistrations.Should().HaveCountGreaterOrEqualTo(1); + } + + [TestMethod] + public async Task ParseFromMetadataAsync_OwnershipPartialMapping_MixesFallbackAndOwners() + { + // Tests scenario where some components have owners and others don't + var metadata = ParseMetadata(BuildNormalRootMetadataJson()); + + var parentRecorder = new Mock(MockBehavior.Strict); + var owner1 = new Mock(MockBehavior.Loose); + var fallback = new Mock(MockBehavior.Loose); + + parentRecorder.Setup(p => p.CreateSingleFileComponentRecorder("manifests/one")).Returns(owner1.Object); + + var ownershipMap = new Dictionary> + { + { "childA 2.0.0", new HashSet { "manifests/one" } }, + + // childDev 3.0.0 deliberately not in ownership map + }; + + var result = await this.parser.ParseFromMetadataAsync(MakeTomlStream("C:/repo/Cargo.toml"), fallback.Object, metadata, parentRecorder.Object, ownershipMap); + result.Success.Should().BeTrue(); + + // childA should use owner1 + owner1.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(1); + + // childDev should use fallback + fallback.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(1); + } + + [TestMethod] + public async Task ProcessMetadata_MultipleLocalPackages_AllDirectoriesCollected() + { + // Tests that multiple local packages all have their directories collected + var json = """ + { + "packages": [ + { "name":"local1", "version":"1.0.0", "id":"local1 1.0.0", "authors":[""], "license":"", "source":null, "manifest_path":"C:/repo/local1/Cargo.toml" }, + { "name":"local2", "version":"2.0.0", "id":"local2 2.0.0", "authors":[""], "license":"", "source":null, "manifest_path":"C:/repo/local2/Cargo.toml" }, + { "name":"remote", "version":"3.0.0", "id":"remote 3.0.0", "authors":["R"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/remote/Cargo.toml" } + ], + "resolve": { + "root":"local1 1.0.0", + "nodes":[ + { "id":"local1 1.0.0", "deps":[ { "pkg":"local2 2.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"local2 2.0.0", "deps":[ { "pkg":"remote 3.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"remote 3.0.0", "deps":[] } + ] + } + } + """; + + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + + var result = await this.InvokeProcessMetadataAsync("C:/repo/Cargo.toml", fallback.Object, metadata); + + result.Success.Should().BeTrue(); + result.LocalPackageDirectories.Should().HaveCount(2); + result.LocalPackageDirectories.Should().Contain(d => d.Contains("local1")); + result.LocalPackageDirectories.Should().Contain(d => d.Contains("local2")); + } + + [TestMethod] + public async Task Traverse_MixedDevAndBuildDependencies_CorrectFlagsSet() + { + // Tests that both dev and build dependencies are correctly flagged + var json = """ + { + "packages": [ + { "name":"root", "version":"1.0.0", "id":"root 1.0.0", "authors":[""], "license":"", "source":null, "manifest_path":"C:/repo/root/Cargo.toml" }, + { "name":"devDep", "version":"1.0.0", "id":"devDep 1.0.0", "authors":["D"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/devDep/Cargo.toml" }, + { "name":"buildDep", "version":"1.0.0", "id":"buildDep 1.0.0", "authors":["B"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/buildDep/Cargo.toml" }, + { "name":"normalDep", "version":"1.0.0", "id":"normalDep 1.0.0", "authors":["N"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/normalDep/Cargo.toml" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[ + { "pkg":"devDep 1.0.0", "dep_kinds":[{"kind":"dev"}] }, + { "pkg":"buildDep 1.0.0", "dep_kinds":[{"kind":"build"}] }, + { "pkg":"normalDep 1.0.0", "dep_kinds":[{"kind":null}] } + ] }, + { "id":"devDep 1.0.0", "deps":[] }, + { "id":"buildDep 1.0.0", "deps":[] }, + { "id":"normalDep 1.0.0", "deps":[] } + ] + } + } + """; + + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + + await this.InvokeProcessMetadataAsync("C:/repo/Cargo.toml", fallback.Object, metadata); + + var registrations = fallback.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + + // Verify devDep has isDevelopmentDependency = true + var devDepReg = registrations.Single(r => + ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name == "devDep"); + devDepReg.Arguments[3].Should().Be(true); + + // Verify buildDep has isDevelopmentDependency = false + var buildDepReg = registrations.Single(r => + ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name == "buildDep"); + buildDepReg.Arguments[3].Should().Be(false); + } + + [TestMethod] + public async Task VirtualManifest_WithTransitiveDependencies_AllRegistered() + { + // Tests virtual manifest with deeper dependency chains + var json = """ + { + "packages": [ + { "name":"virtPkg", "version":"1.0.0", "id":"virtPkg 1.0.0", "authors":["V"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/virtPkg/Cargo.toml" }, + { "name":"transitive", "version":"2.0.0", "id":"transitive 2.0.0", "authors":["T"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/transitive/Cargo.toml" } + ], + "resolve": { + "root": null, + "nodes":[ + { "id":"virtPkg 1.0.0", "deps":[ { "pkg":"transitive 2.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"transitive 2.0.0", "deps":[] } + ] + } + } + """; + + this.cli.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + this.cli.Setup(c => c.ExecuteCommandAsync("cargo", null, null, It.IsAny(), "metadata", "--manifest-path", "C:/repo/Cargo.toml", "--format-version=1", "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json }); + + var recorder = new Mock(MockBehavior.Loose); + var graph = new Mock(); + graph.Setup(g => g.Contains(It.IsAny())).Returns(false); + recorder.Setup(r => r.DependencyGraph).Returns(graph.Object); + + var result = await this.parser.ParseAsync(MakeTomlStream("C:/repo/Cargo.toml"), recorder.Object); + result.Success.Should().BeTrue(); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + + // Should register both packages + var distinctComponents = registrations + .Select(r => ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name) + .Distinct() + .ToList(); + + distinctComponents.Should().Contain("virtPkg"); + distinctComponents.Should().Contain("transitive"); + } + + [TestMethod] + public async Task ApplyOwners_ParentComponentId_OnlySetWhenInGraph() + { + // Tests that parentComponentId is only passed when it exists in the target recorder's graph + var metadata = ParseMetadata(BuildNormalRootMetadataJson()); + + var parentRecorder = new Mock(MockBehavior.Strict); + var owner = new Mock(MockBehavior.Loose); + var ownerGraph = new Mock(); + + // Setup graph to NOT contain parent + ownerGraph.Setup(g => g.Contains(It.IsAny())).Returns(false); + owner.Setup(r => r.DependencyGraph).Returns(ownerGraph.Object); + + parentRecorder.Setup(p => p.CreateSingleFileComponentRecorder("manifests/one")).Returns(owner.Object); + + var ownershipMap = new Dictionary> + { + { "childA 2.0.0", new HashSet { "manifests/one" } }, + }; + + var result = await this.parser.ParseFromMetadataAsync(MakeTomlStream("C:/repo/Cargo.toml"), new Mock().Object, metadata, parentRecorder.Object, ownershipMap); + result.Success.Should().BeTrue(); + + var registrations = owner.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + + // Parent ID should be null since it's not in the graph + registrations.Should().OnlyContain(r => r.Arguments[2] == null); + } + + [TestMethod] + public async Task AuthorsNormalization_MultipleNonBlankAuthors_JoinsWithComma() + { + // Tests that multiple valid authors are joined correctly + var json = """ + { + "packages": [ + { "name":"root", "version":"1.0.0", "id":"root 1.0.0", "authors":[""], "license":"MIT", "source":null, "manifest_path":"C:/repo/root/Cargo.toml" }, + { "name":"child", "version":"2.0.0", "id":"child 2.0.0", "authors":["Alice Smith","Bob Jones","Charlie Brown"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/child/Cargo.toml" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[ { "pkg":"child 2.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"child 2.0.0", "deps":[] } + ] + } + } + """; + + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + await this.InvokeProcessMetadataAsync("C:/repo/Cargo.toml", fallback.Object, metadata); + + var reg = fallback.Invocations.Single(i => i.Method.Name == "RegisterUsage"); + var comp = ((DetectedComponent)reg.Arguments[0]).Component as CargoComponent; + + comp.Author.Should().Be("Alice Smith, Bob Jones, Charlie Brown"); + } + + private async Task InvokeProcessMetadataAsync(string manifestLocation, ISingleFileComponentRecorder fallbackRecorder, CargoMetadata metadata) => + await this.parser.ParseFromMetadataAsync( + new ComponentStream { Location = manifestLocation, Pattern = "Cargo.toml", Stream = new MemoryStream([]) }, + fallbackRecorder, + metadata, + parentComponentRecorder: null, + ownershipMap: null); +} diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomParserTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomParserTests.cs new file mode 100644 index 000000000..9eea05c4e --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomParserTests.cs @@ -0,0 +1,718 @@ +namespace Microsoft.ComponentDetection.Detectors.Tests; + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.ComponentDetection.Common; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.TypedComponent; +using Microsoft.ComponentDetection.Detectors.Rust; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +[TestClass] +[TestCategory("Governance/All")] +[TestCategory("Governance/ComponentDetection")] +public class RustSbomParserTests +{ + private const string CratesIo = "registry+https://github.com/rust-lang/crates.io-index"; + private RustSbomParser parser; + private Mock> logger; + + [TestInitialize] + public void Init() + { + this.logger = new Mock>(MockBehavior.Loose); + this.parser = new RustSbomParser(this.logger.Object); + } + + private static IComponentStream MakeSbomStream(string location, string json) => + new ComponentStream + { + Location = location, + Pattern = "*.cargo-sbom.json", + Stream = new MemoryStream(Encoding.UTF8.GetBytes(json)), + }; + + private static string BuildSimpleSbomJson() => $$""" + { + "version": 1, + "root": 0, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [ + { "index": 1, "kind": "normal" } + ] + }, + { + "id": "{{CratesIo}}#dep1@1.0.0", + "features": [], + "dependencies": [] + } + ] + } + """; + + private static string BuildNestedSbomJson() => $$""" + { + "version": 1, + "root": 0, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [ + { "index": 1, "kind": "normal" } + ] + }, + { + "id": "{{CratesIo}}#parent@2.0.0", + "features": [], + "dependencies": [ + { "index": 2, "kind": "normal" } + ] + }, + { + "id": "{{CratesIo}}#child@3.0.0", + "features": [], + "dependencies": [] + } + ] + } + """; + + [TestMethod] + public async Task ParseAsync_ValidSimpleSbom_RegistersComponents() + { + var json = BuildSimpleSbomJson(); + var recorder = new Mock(MockBehavior.Loose); + + var version = await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + version.Should().Be(1); + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().ContainSingle(); + + var component = ((DetectedComponent)registrations[0].Arguments[0]).Component as CargoComponent; + component.Name.Should().Be("dep1"); + component.Version.Should().Be("1.0.0"); + + // Depth 0 from root => explicit + registrations[0].Arguments[1].Should().Be(true); + } + + [TestMethod] + public async Task ParseAsync_NestedDependencies_CorrectDepthFlags() + { + var json = BuildNestedSbomJson(); + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().HaveCount(2); + + // parent at depth 0 => explicit + var parentReg = registrations.Single(r => + ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name == "parent"); + parentReg.Arguments[1].Should().Be(true); + + // child at depth 1 => not explicit + var childReg = registrations.Single(r => + ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name == "child"); + childReg.Arguments[1].Should().Be(false); + } + + [TestMethod] + public async Task ParseAsync_CycleInDependencies_DoesNotInfiniteLoop() + { + var json = $$""" + { + "version": 1, + "root": 0, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [ + { "index": 1, "kind": "normal" } + ] + }, + { + "id": "{{CratesIo}}#cycle@1.0.0", + "features": [], + "dependencies": [ + { "index": 1, "kind": "normal" } + ] + } + ] + } + """; + + var recorder = new Mock(MockBehavior.Loose); + + var version = await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + version.Should().Be(1); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().HaveCount(2); // Dependency is marked visited after registering + } + + [TestMethod] + public async Task ParseAsync_GitSourceComponent_Ignored() + { + var json = """ + { + "version": 1, + "root": 0, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [ + { "index": 1, "kind": "normal" } + ] + }, + { + "id": "git+https://github.com/org/repo.git#gitdep@1.0.0", + "features": [], + "dependencies": [] + } + ] + } + """; + + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().BeEmpty(); // Git source not registered + } + + [TestMethod] + public async Task ParseAsync_PathSourceComponent_Ignored() + { + var json = """ + { + "version": 1, + "root": 0, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [ + { "index": 1, "kind": "normal" } + ] + }, + { + "id": "path+file:///repo/local#localdep@1.0.0", + "features": [], + "dependencies": [] + } + ] + } + """; + + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().BeEmpty(); // Path source not registered + } + + [TestMethod] + public async Task ParseAsync_InvalidPackageIdSpec_RecordsFailure() + { + var json = $$""" + { + "version": 1, + "root": 0, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [ + { "index": 1, "kind": "normal" } + ] + }, + { + "id": "this is completely invalid", + "features": [], + "dependencies": [] + } + ] + } + """; + + var recorder = new Mock(MockBehavior.Loose); + + var version = await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + // ProcessCargoSbom catches the exception, so ParseAsync returns version successfully + version.Should().Be(1); + + // No RegisterPackageParseFailure is called because the exception is caught at ProcessCargoSbom level + var failures = recorder.Invocations.Where(i => i.Method.Name == "RegisterPackageParseFailure").ToList(); + failures.Should().BeEmpty(); + + // No components are registered due to the exception + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().BeEmpty(); + } + + [TestMethod] + public async Task ParseAsync_MalformedJson_ReturnsNull() + { + var badJson = "{ this is not valid json }"; + var recorder = new Mock(MockBehavior.Loose); + + var version = await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", badJson), recorder.Object); + + version.Should().BeNull(); + recorder.Invocations.Should().BeEmpty(); + } + + [TestMethod] + public async Task ParseAsync_EmptyDependenciesArray_NoRegistrations() + { + var json = """ + { + "version": 1, + "root": 0, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [] + } + ] + } + """; + + var recorder = new Mock(MockBehavior.Loose); + + var version = await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + version.Should().Be(1); + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().BeEmpty(); + } + + [TestMethod] + public async Task ParsePackageIdSpec_NameInferredFromSource() + { + var json = $$""" + { + "version": 1, + "root": 0, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [ + { "index": 1, "kind": "normal" } + ] + }, + { + "id": "{{CratesIo}}#1.0.0", + "features": [], + "dependencies": [] + } + ] + } + """; + + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().ContainSingle(); + + var component = ((DetectedComponent)registrations[0].Arguments[0]).Component as CargoComponent; + component.Name.Should().Be("crates.io-index"); // Inferred from last segment + component.Version.Should().Be("1.0.0"); + } + + [TestMethod] + public async Task ParsePackageIdSpec_BlankSource_BecomesNull() + { + var json = """ + { + "version": 1, + "root": 0, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [ + { "index": 1, "kind": "normal" } + ] + }, + { + "id": "#localname@1.0.0", + "features": [], + "dependencies": [] + } + ] + } + """; + + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().BeEmpty(); // Blank/null source => not crates.io => ignored + } + + [TestMethod] + public async Task ParseWithOwnershipAsync_SingleOwner_RegistersViaOwnerRecorder() + { + var json = BuildSimpleSbomJson(); + var sbomRecorder = new Mock(MockBehavior.Loose); + var parentRecorder = new Mock(MockBehavior.Strict); + var ownerRecorder = new Mock(MockBehavior.Loose); + + parentRecorder.Setup(p => p.CreateSingleFileComponentRecorder("manifests/owner1")).Returns(ownerRecorder.Object); + + var ownershipMap = new Dictionary> + { + { $"{CratesIo}#dep1@1.0.0", new HashSet { "manifests/owner1" } }, + }; + + var version = await this.parser.ParseWithOwnershipAsync( + MakeSbomStream("test.cargo-sbom.json", json), + sbomRecorder.Object, + parentRecorder.Object, + ownershipMap); + + version.Should().Be(1); + + ownerRecorder.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(1); + sbomRecorder.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(0); + } + + [TestMethod] + public async Task ParseWithOwnershipAsync_MultipleOwners_RegistersForEach() + { + var json = BuildSimpleSbomJson(); + var sbomRecorder = new Mock(MockBehavior.Loose); + var parentRecorder = new Mock(MockBehavior.Strict); + var owner1 = new Mock(MockBehavior.Loose); + var owner2 = new Mock(MockBehavior.Loose); + + parentRecorder.Setup(p => p.CreateSingleFileComponentRecorder("manifests/owner1")).Returns(owner1.Object); + parentRecorder.Setup(p => p.CreateSingleFileComponentRecorder("manifests/owner2")).Returns(owner2.Object); + + var ownershipMap = new Dictionary> + { + { $"{CratesIo}#dep1@1.0.0", new HashSet { "manifests/owner1", "manifests/owner2" } }, + }; + + await this.parser.ParseWithOwnershipAsync( + MakeSbomStream("test.cargo-sbom.json", json), + sbomRecorder.Object, + parentRecorder.Object, + ownershipMap); + + owner1.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(1); + owner2.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(1); + sbomRecorder.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(0); + } + + [TestMethod] + public async Task ParseWithOwnershipAsync_NoOwnership_FallsBackToSbomRecorder() + { + var json = BuildSimpleSbomJson(); + var sbomRecorder = new Mock(MockBehavior.Loose); + var parentRecorder = new Mock(MockBehavior.Strict); + + var ownershipMap = new Dictionary>(); // Empty + + await this.parser.ParseWithOwnershipAsync( + MakeSbomStream("test.cargo-sbom.json", json), + sbomRecorder.Object, + parentRecorder.Object, + ownershipMap); + + sbomRecorder.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(1); + parentRecorder.Invocations.Should().BeEmpty(); + } + + [TestMethod] + public async Task ParseWithOwnershipAsync_NullOwnershipMap_FallsBack() + { + var json = BuildSimpleSbomJson(); + var sbomRecorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseWithOwnershipAsync( + MakeSbomStream("test.cargo-sbom.json", json), + sbomRecorder.Object, + parentComponentRecorder: null, + ownershipMap: null); + + sbomRecorder.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(1); + } + + [TestMethod] + public async Task ParseWithOwnershipAsync_NullParentRecorder_FallsBack() + { + var json = BuildSimpleSbomJson(); + var sbomRecorder = new Mock(MockBehavior.Loose); + + var ownershipMap = new Dictionary> + { + { $"{CratesIo}#dep1@1.0.0", new HashSet { "manifests/owner1" } }, + }; + + await this.parser.ParseWithOwnershipAsync( + MakeSbomStream("test.cargo-sbom.json", json), + sbomRecorder.Object, + parentComponentRecorder: null, + ownershipMap); + + sbomRecorder.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(1); + } + + [TestMethod] + public async Task ParseWithOwnershipAsync_PartialOwnership_MixesFallbackAndOwners() + { + var json = BuildNestedSbomJson(); + var sbomRecorder = new Mock(MockBehavior.Loose); + var parentRecorder = new Mock(MockBehavior.Strict); + var owner1 = new Mock(MockBehavior.Loose); + + parentRecorder.Setup(p => p.CreateSingleFileComponentRecorder("manifests/owner1")).Returns(owner1.Object); + + var ownershipMap = new Dictionary> + { + { $"{CratesIo}#parent@2.0.0", new HashSet { "manifests/owner1" } }, + + // child not in ownership map => fallback + }; + + await this.parser.ParseWithOwnershipAsync( + MakeSbomStream("test.cargo-sbom.json", json), + sbomRecorder.Object, + parentRecorder.Object, + ownershipMap); + + owner1.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(1); + sbomRecorder.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(1); + } + + [TestMethod] + public async Task ParseWithOwnershipAsync_MalformedJson_ReturnsNull() + { + var badJson = "{ malformed }"; + var sbomRecorder = new Mock(MockBehavior.Loose); + + var version = await this.parser.ParseWithOwnershipAsync( + MakeSbomStream("test.cargo-sbom.json", badJson), + sbomRecorder.Object, + parentComponentRecorder: null, + ownershipMap: null); + + version.Should().BeNull(); + } + + [TestMethod] + public async Task ParseAsync_MultipleDepthLevels_CorrectExplicitFlags() + { + var json = $$""" + { + "version": 1, + "root": 0, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [ + { "index": 1, "kind": "normal" } + ] + }, + { + "id": "{{CratesIo}}#level1@1.0.0", + "features": [], + "dependencies": [ + { "index": 2, "kind": "normal" } + ] + }, + { + "id": "{{CratesIo}}#level2@2.0.0", + "features": [], + "dependencies": [ + { "index": 3, "kind": "normal" } + ] + }, + { + "id": "{{CratesIo}}#level3@3.0.0", + "features": [], + "dependencies": [] + } + ] + } + """; + + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().HaveCount(3); + + // level1 at depth 0 => explicit + var level1 = registrations.Single(r => + ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name == "level1"); + level1.Arguments[1].Should().Be(true); + + // level2 at depth 1 => not explicit + var level2 = registrations.Single(r => + ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name == "level2"); + level2.Arguments[1].Should().Be(false); + + // level3 at depth 2 => not explicit + var level3 = registrations.Single(r => + ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name == "level3"); + level3.Arguments[1].Should().Be(false); + } + + [TestMethod] + public async Task ParseAsync_ParentComponentId_SetCorrectly() + { + var json = BuildNestedSbomJson(); + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + + // parent registration should have null parent (depth 0) + var parentReg = registrations.Single(r => + ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name == "parent"); + parentReg.Arguments[2].Should().BeNull(); + + // child registration should have parent's ID + var childReg = registrations.Single(r => + ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name == "child"); + var parentId = ((DetectedComponent)parentReg.Arguments[0]).Component.Id; + childReg.Arguments[2].Should().Be(parentId); + } + + [TestMethod] + public async Task ParseAsync_DiamondDependency_HandledCorrectly() + { + var json = $$""" + { + "version": 1, + "root": 0, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [ + { "index": 1, "kind": "normal" }, + { "index": 2, "kind": "normal" } + ] + }, + { + "id": "{{CratesIo}}#depA@1.0.0", + "features": [], + "dependencies": [ + { "index": 3, "kind": "normal" } + ] + }, + { + "id": "{{CratesIo}}#depB@1.0.0", + "features": [], + "dependencies": [ + { "index": 3, "kind": "normal" } + ] + }, + { + "id": "{{CratesIo}}#shared@1.0.0", + "features": [], + "dependencies": [] + } + ] + } + """; + + var recorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + + // Should register shared multiple times (once per path) + var sharedRegistrations = registrations.Where(r => + ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name == "shared").ToList(); + + sharedRegistrations.Should().HaveCountGreaterOrEqualTo(1); + } + + [TestMethod] + public async Task ParsePackageIdSpec_VariousFormats_ParsedCorrectly() + { + var testCases = new[] + { + ($"{CratesIo}#name@1.0.0", "name", "1.0.0", CratesIo), + ("git+https://github.com/org/repo.git#gitname@2.0.0", "gitname", "2.0.0", "git+https://github.com/org/repo.git"), + ("path+file:///local/path#localname@3.0.0", "localname", "3.0.0", "path+file:///local/path"), + }; + + foreach (var (id, expectedName, expectedVersion, expectedSource) in testCases) + { + var json = $$""" + { + "version": 1, + "root": 0, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [ + { "index": 1, "kind": "normal" } + ] + }, + { + "id": "{{id}}", + "features": [], + "dependencies": [] + } + ] + } + """; + + var recorder = new Mock(MockBehavior.Loose); + await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + if (expectedSource == CratesIo) + { + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().ContainSingle(); + + var component = ((DetectedComponent)registrations[0].Arguments[0]).Component as CargoComponent; + component.Name.Should().Be(expectedName); + component.Version.Should().Be(expectedVersion); + component.Source.Should().Be(expectedSource); + } + else + { + // Non-crates.io sources should be ignored + var registrations = recorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().BeEmpty(); + } + } + } +} From 4197ea2e383ccdac419061978366ce37919dc3d0 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Fri, 17 Oct 2025 13:37:14 -0700 Subject: [PATCH 16/23] Respect DisableRustCli flag in RustMetadataContextBuilder --- .../rust/RustMetadataContextBuilder.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs index c8784cb29..9283e954b 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustMetadataContextBuilder.cs @@ -15,15 +15,18 @@ public class RustMetadataContextBuilder : IRustMetadataContextBuilder private readonly ILogger logger; private readonly ICommandLineInvocationService cliService; private readonly IPathUtilityService pathUtilityService; + private readonly IEnvironmentVariableService envVarService; public RustMetadataContextBuilder( ILogger logger, ICommandLineInvocationService cliService, - IPathUtilityService pathUtilityService) + IPathUtilityService pathUtilityService, + IEnvironmentVariableService envVarService) { this.logger = logger; this.cliService = cliService; this.pathUtilityService = pathUtilityService; + this.envVarService = envVarService; } public async Task BuildPackageOwnershipMapAsync( @@ -31,6 +34,14 @@ public async Task BuildPackageOwnershipMapAsync( CancellationToken cancellationToken = default) { var aggregate = new OwnershipResult(); + + // Check if Rust CLI scanning is manually disabled + if (this.IsRustCliManuallyDisabled()) + { + this.logger.LogInformation("Rust CLI manually disabled, skipping package ownership map build"); + return aggregate; + } + var visitedManifests = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var toml in orderedTomlPaths ?? []) @@ -88,6 +99,9 @@ public async Task BuildPackageOwnershipMapAsync( return aggregate; } + private bool IsRustCliManuallyDisabled() => + this.envVarService.IsEnvironmentVariableValueTrue("DisableRustCliScan"); + private OwnershipResult BuildOwnershipFromMetadata(CargoMetadata metadata) { // Step 0: Build dependency graph (package -> deps) From c47ac7ea89c711556811b0cb343448162438a241 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Fri, 17 Oct 2025 15:28:16 -0700 Subject: [PATCH 17/23] Add UTs for RustMetadataContextBuilder --- .../RustMetadataContextBuilderTests.cs | 476 ++++++++++++++++++ 1 file changed, 476 insertions(+) create mode 100644 test/Microsoft.ComponentDetection.Detectors.Tests/RustMetadataContextBuilderTests.cs diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustMetadataContextBuilderTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustMetadataContextBuilderTests.cs new file mode 100644 index 000000000..7dfbd3657 --- /dev/null +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustMetadataContextBuilderTests.cs @@ -0,0 +1,476 @@ +namespace Microsoft.ComponentDetection.Detectors.Tests; + +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.ComponentDetection.Common; +using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Detectors.Rust; +using Microsoft.Extensions.Logging; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +[TestClass] +[TestCategory("Governance/All")] +[TestCategory("Governance/ComponentDetection")] +public class RustMetadataContextBuilderTests +{ + private RustMetadataContextBuilder builder; + private Mock> logger; + private Mock cliService; + private Mock envVarService; + + [TestInitialize] + public void Init() + { + this.logger = new Mock>(MockBehavior.Loose); + this.cliService = new Mock(MockBehavior.Strict); + this.envVarService = new Mock(MockBehavior.Strict); + + this.builder = new RustMetadataContextBuilder( + this.logger.Object, + this.cliService.Object, + new PathUtilityService(new Mock>().Object), + this.envVarService.Object); + } + + private static string BuildSimpleMetadataJson(string rootManifest, string rootId) => $$""" + { + "packages": [ + { "name":"rootpkg", "version":"1.0.0", "id":"{{rootId}}", "authors":[""], "license":"", "source":null, "manifest_path":"{{rootManifest}}" }, + { "name":"dep1", "version":"2.0.0", "id":"dep1 2.0.0", "authors":["A"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/dep1/Cargo.toml" } + ], + "resolve": { + "root":"{{rootId}}", + "nodes":[ + { "id":"{{rootId}}", "deps":[ { "pkg":"dep1 2.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"dep1 2.0.0", "deps":[] } + ] + } + } + """; + + private static string BuildWorkspaceMetadataJson() => """ + { + "packages": [ + { "name":"workspace", "version":"0.1.0", "id":"workspace 0.1.0", "authors":[""], "license":"", "source":null, "manifest_path":"C:/repo/Cargo.toml" }, + { "name":"member1", "version":"0.2.0", "id":"member1 0.2.0", "authors":[""], "license":"", "source":null, "manifest_path":"C:/repo/member1/Cargo.toml" }, + { "name":"member2", "version":"0.3.0", "id":"member2 0.3.0", "authors":[""], "license":"", "source":null, "manifest_path":"C:/repo/member2/Cargo.toml" }, + { "name":"shared", "version":"1.0.0", "id":"shared 1.0.0", "authors":["S"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/shared/Cargo.toml" } + ], + "resolve": { + "root":"workspace 0.1.0", + "nodes":[ + { "id":"workspace 0.1.0", "deps":[] }, + { "id":"member1 0.2.0", "deps":[ { "pkg":"shared 1.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"member2 0.3.0", "deps":[ { "pkg":"shared 1.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"shared 1.0.0", "deps":[] } + ] + } + } + """; + + private static string BuildDiamondDependencyJson() => """ + { + "packages": [ + { "name":"root", "version":"1.0.0", "id":"root 1.0.0", "authors":[""], "license":"", "source":null, "manifest_path":"C:/repo/Cargo.toml" }, + { "name":"depA", "version":"1.0.0", "id":"depA 1.0.0", "authors":["A"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/depA/Cargo.toml" }, + { "name":"depB", "version":"1.0.0", "id":"depB 1.0.0", "authors":["B"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/depB/Cargo.toml" }, + { "name":"shared", "version":"1.0.0", "id":"shared 1.0.0", "authors":["S"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/shared/Cargo.toml" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[ + { "pkg":"depA 1.0.0", "dep_kinds":[{"kind":"build"}] }, + { "pkg":"depB 1.0.0", "dep_kinds":[{"kind":"build"}] } + ] }, + { "id":"depA 1.0.0", "deps":[ { "pkg":"shared 1.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"depB 1.0.0", "deps":[ { "pkg":"shared 1.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"shared 1.0.0", "deps":[] } + ] + } + } + """; + + [TestMethod] + public async Task BuildPackageOwnershipMapAsync_ManuallyDisabled_ReturnsEmptyResult() + { + this.envVarService.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(true); + + var result = await this.builder.BuildPackageOwnershipMapAsync(["C:/repo/Cargo.toml"]); + + result.Should().NotBeNull(); + result.PackageToTomls.Should().BeEmpty(); + result.LocalPackageManifests.Should().BeEmpty(); + result.ManifestToMetadata.Should().BeEmpty(); + result.FailedManifests.Should().BeEmpty(); + + // Verify cargo was never invoked + this.cliService.Verify(c => c.CanCommandBeLocatedAsync(It.IsAny(), null), Times.Never); + } + + [TestMethod] + public async Task BuildPackageOwnershipMapAsync_NullTomlPaths_ReturnsEmptyResult() + { + this.envVarService.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(false); + + var result = await this.builder.BuildPackageOwnershipMapAsync(null); + + result.Should().NotBeNull(); + result.PackageToTomls.Should().BeEmpty(); + result.LocalPackageManifests.Should().BeEmpty(); + result.ManifestToMetadata.Should().BeEmpty(); + result.FailedManifests.Should().BeEmpty(); + } + + [TestMethod] + public async Task BuildPackageOwnershipMapAsync_EmptyTomlPaths_ReturnsEmptyResult() + { + this.envVarService.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(false); + + var result = await this.builder.BuildPackageOwnershipMapAsync([]); + + result.Should().NotBeNull(); + result.PackageToTomls.Should().BeEmpty(); + result.LocalPackageManifests.Should().BeEmpty(); + result.ManifestToMetadata.Should().BeEmpty(); + result.FailedManifests.Should().BeEmpty(); + } + + [TestMethod] + public async Task BuildPackageOwnershipMapAsync_CargoNotFound_AddsToFailedManifests() + { + var tomlPath = "C:/repo/Cargo.toml"; + this.envVarService.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(false); + this.cliService.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(false); + + var result = await this.builder.BuildPackageOwnershipMapAsync([tomlPath]); + + result.FailedManifests.Should().Contain(tomlPath); + result.PackageToTomls.Should().BeEmpty(); + result.LocalPackageManifests.Should().BeEmpty(); + result.ManifestToMetadata.Should().BeEmpty(); + } + + [TestMethod] + public async Task BuildPackageOwnershipMapAsync_CargoMetadataFails_AddsToFailedManifests() + { + var tomlPath = "C:/repo/Cargo.toml"; + this.envVarService.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(false); + this.cliService.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + this.cliService.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + tomlPath, + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 1, StdErr = "error" }); + + var result = await this.builder.BuildPackageOwnershipMapAsync([tomlPath]); + + result.FailedManifests.Should().Contain(tomlPath); + result.PackageToTomls.Should().BeEmpty(); + result.LocalPackageManifests.Should().BeEmpty(); + result.ManifestToMetadata.Should().BeEmpty(); + } + + [TestMethod] + public async Task BuildPackageOwnershipMapAsync_SimpleDependency_BuildsOwnershipMap() + { + var tomlPath = "C:/repo/Cargo.toml"; + var json = BuildSimpleMetadataJson(tomlPath, "rootpkg 1.0.0"); + + this.envVarService.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(false); + this.cliService.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + this.cliService.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + tomlPath, + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json }); + + var result = await this.builder.BuildPackageOwnershipMapAsync([tomlPath]); + + result.FailedManifests.Should().BeEmpty(); + result.LocalPackageManifests.Should().Contain(tomlPath); + result.PackageToTomls.Should().ContainKey("rootpkg 1.0.0"); + result.PackageToTomls["rootpkg 1.0.0"].Should().Contain(tomlPath); + result.PackageToTomls.Should().ContainKey("dep1 2.0.0"); + result.PackageToTomls["dep1 2.0.0"].Should().Contain(tomlPath); + result.ManifestToMetadata.Should().ContainKey(tomlPath); + } + + [TestMethod] + public async Task BuildPackageOwnershipMapAsync_DuplicateManifest_ProcessedOnce() + { + var tomlPath = "C:/repo/Cargo.toml"; + var json = BuildSimpleMetadataJson(tomlPath, "rootpkg 1.0.0"); + + this.envVarService.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(false); + this.cliService.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + this.cliService.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + tomlPath, + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json }); + + // Submit the same manifest twice + var result = await this.builder.BuildPackageOwnershipMapAsync([tomlPath, tomlPath]); + + // Should only execute cargo once + this.cliService.Verify( + c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + tomlPath, + "--format-version=1", + "--locked"), + Times.Once); + + result.LocalPackageManifests.Should().ContainSingle(); + } + + [TestMethod] + public async Task BuildPackageOwnershipMapAsync_WorkspaceWithMultipleMembers_PropagatesOwnership() + { + var workspaceToml = "C:/repo/Cargo.toml"; + var json = BuildWorkspaceMetadataJson(); + + this.envVarService.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(false); + + this.cliService.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + this.cliService.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + workspaceToml, + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json }); + + var result = await this.builder.BuildPackageOwnershipMapAsync([workspaceToml]); + + // Workspace and both members are local + result.LocalPackageManifests.Should().HaveCount(3); + result.LocalPackageManifests.Should().Contain("C:/repo/Cargo.toml"); + result.LocalPackageManifests.Should().Contain("C:/repo/member1/Cargo.toml"); + result.LocalPackageManifests.Should().Contain("C:/repo/member2/Cargo.toml"); + + // Shared dependency should be owned by both members + result.PackageToTomls.Should().ContainKey("shared 1.0.0"); + result.PackageToTomls["shared 1.0.0"].Should().Contain("C:/repo/member1/Cargo.toml"); + result.PackageToTomls["shared 1.0.0"].Should().Contain("C:/repo/member2/Cargo.toml"); + } + + [TestMethod] + public async Task BuildPackageOwnershipMapAsync_DiamondDependency_PropagatesOwnershipCorrectly() + { + var tomlPath = "C:/repo/Cargo.toml"; + var json = BuildDiamondDependencyJson(); + + this.envVarService.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(false); + this.cliService.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + this.cliService.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + tomlPath, + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json }); + + var result = await this.builder.BuildPackageOwnershipMapAsync([tomlPath]); + + // All dependencies should be owned by root + result.PackageToTomls.Should().ContainKey("depA 1.0.0"); + result.PackageToTomls["depA 1.0.0"].Should().Contain(tomlPath); + + result.PackageToTomls.Should().ContainKey("depB 1.0.0"); + result.PackageToTomls["depB 1.0.0"].Should().Contain(tomlPath); + + result.PackageToTomls.Should().ContainKey("shared 1.0.0"); + result.PackageToTomls["shared 1.0.0"].Should().Contain(tomlPath); + } + + [TestMethod] + public async Task BuildPackageOwnershipMapAsync_MultipleManifests_AggregatesResults() + { + var toml1 = "C:/repo1/Cargo.toml"; + var toml2 = "C:/repo2/Cargo.toml"; + var json1 = BuildSimpleMetadataJson(toml1, "root1 1.0.0"); + var json2 = BuildSimpleMetadataJson(toml2, "root2 1.0.0"); + + this.envVarService.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(false); + this.cliService.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + + this.cliService.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + toml1, + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json1 }); + + this.cliService.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + toml2, + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json2 }); + + var result = await this.builder.BuildPackageOwnershipMapAsync([toml1, toml2]); + + result.LocalPackageManifests.Should().HaveCount(2); + result.ManifestToMetadata.Should().HaveCount(2); + result.PackageToTomls.Should().ContainKey("root1 1.0.0"); + result.PackageToTomls.Should().ContainKey("root2 1.0.0"); + + // dep1 2.0.0 is owned by both manifests + result.PackageToTomls.Should().ContainKey("dep1 2.0.0"); + result.PackageToTomls["dep1 2.0.0"].Should().HaveCount(2); + result.PackageToTomls["dep1 2.0.0"].Should().Contain(toml1); + result.PackageToTomls["dep1 2.0.0"].Should().Contain(toml2); + } + + [TestMethod] + public async Task BuildPackageOwnershipMapAsync_MixedSuccessAndFailure_ProcessesSuccessful() + { + var toml1 = "C:/repo1/Cargo.toml"; + var toml2 = "C:/repo2/Cargo.toml"; + var json1 = BuildSimpleMetadataJson(toml1, "root1 1.0.0"); + + this.envVarService.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(false); + this.cliService.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + + this.cliService.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + toml1, + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json1 }); + + this.cliService.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + toml2, + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 1, StdErr = "error" }); + + var result = await this.builder.BuildPackageOwnershipMapAsync([toml1, toml2]); + + result.FailedManifests.Should().Contain(toml2); + result.LocalPackageManifests.Should().Contain(toml1); + result.ManifestToMetadata.Should().ContainKey(toml1); + result.ManifestToMetadata.Should().NotContainKey(toml2); + } + + [TestMethod] + public async Task BuildPackageOwnershipMapAsync_CancellationToken_PassedThrough() + { + var tomlPath = "C:/repo/Cargo.toml"; + var cts = new CancellationTokenSource(); + var json = BuildSimpleMetadataJson(tomlPath, "rootpkg 1.0.0"); + + this.envVarService.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(false); + this.cliService.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + this.cliService.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + cts.Token, + "metadata", + "--manifest-path", + tomlPath, + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json }); + + await this.builder.BuildPackageOwnershipMapAsync([tomlPath], cts.Token); + + this.cliService.Verify( + c => c.ExecuteCommandAsync( + "cargo", + null, + null, + cts.Token, + "metadata", + "--manifest-path", + tomlPath, + "--format-version=1", + "--locked"), + Times.Once); + } + + [TestMethod] + public async Task BuildPackageOwnershipMapAsync_PathNormalization_UsesNormalizedPaths() + { + var rawPath = "C:\\repo\\Cargo.toml"; + var normalizedPath = "C:/repo/Cargo.toml"; + var json = BuildSimpleMetadataJson(normalizedPath, "rootpkg 1.0.0"); + + this.envVarService.Setup(e => e.IsEnvironmentVariableValueTrue("DisableRustCliScan")).Returns(false); + this.cliService.Setup(c => c.CanCommandBeLocatedAsync("cargo", null)).ReturnsAsync(true); + this.cliService.Setup(c => c.ExecuteCommandAsync( + "cargo", + null, + null, + It.IsAny(), + "metadata", + "--manifest-path", + rawPath, + "--format-version=1", + "--locked")) + .ReturnsAsync(new CommandLineExecutionResult { ExitCode = 0, StdOut = json }); + + var result = await this.builder.BuildPackageOwnershipMapAsync([rawPath]); + + // Verify normalized path is used in results + result.LocalPackageManifests.Should().Contain(normalizedPath); + result.ManifestToMetadata.Should().ContainKey(normalizedPath); + } +} From b7dbfcba77d0788a1742ba8becbe3296b656fa44 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Mon, 20 Oct 2025 12:13:12 -0700 Subject: [PATCH 18/23] Add UTs for RustSbomDetector --- .../rust/RustSbomDetector.cs | 21 +- .../RustSbomDetectorTests.cs | 741 +++++++++++++++++- 2 files changed, 738 insertions(+), 24 deletions(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs index 0d686f5c2..62e0e863f 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs @@ -19,7 +19,7 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; /// /// A unified Rust detector that orchestrates SBOM, CLI, and Crate parsing. /// -public class RustSbomDetector : FileComponentDetector +public class RustSbomDetector : FileComponentDetector, IExperimentalDetector { private static readonly TomlModelOptions TomlOptions = new TomlModelOptions { @@ -31,7 +31,7 @@ public class RustSbomDetector : FileComponentDetector private readonly IRustCliParser cliParser; private readonly IRustCargoLockParser cargoLockParser; private readonly IRustMetadataContextBuilder metadataContextBuilder; - + private readonly IFileUtilityService fileUtilityService; private readonly HashSet visitedDirs; private readonly List visitedGlobRules; private readonly StringComparer pathComparer; @@ -48,7 +48,8 @@ public RustSbomDetector( IPathUtilityService pathUtilityService, IRustCliParser cliParser, IRustSbomParser sbomParser, - IRustCargoLockParser cargoLockParser) + IRustCargoLockParser cargoLockParser, + IFileUtilityService fileUtilityService) { this.ComponentStreamEnumerableFactory = componentStreamEnumerableFactory; this.Scanner = walkerFactory; @@ -60,6 +61,7 @@ public RustSbomDetector( this.cliParser = cliParser; this.cargoLockParser = cargoLockParser; this.metadataContextBuilder = metadataContextBuilder; + this.fileUtilityService = fileUtilityService; // Initialize with uniform case-insensitive comparison across all platforms this.pathComparer = StringComparer.OrdinalIgnoreCase; @@ -107,7 +109,7 @@ private enum FileKind } /// - public override string Id => nameof(RustSbomDetector); + public override string Id => "RustSbom"; /// public override IEnumerable Categories { get; } = ["Rust"]; @@ -487,7 +489,7 @@ private bool IsWorkspaceOnlyToml(string cargoTomlPath) { try { - var content = File.ReadAllText(cargoTomlPath); + var content = this.fileUtilityService.ReadAllText(cargoTomlPath); var tomlTable = Toml.ToModel(content, options: TomlOptions); // Check if it has a [workspace] section but no [package] section @@ -625,9 +627,9 @@ private async Task ProcessCargoLockAsync(ProcessRequest processRequest, string d // Check if Cargo.toml exists in same directory to parse workspace tables var cargoTomlPath = Path.Combine(directory, "Cargo.toml"); - if (File.Exists(cargoTomlPath)) + if (this.fileUtilityService.Exists(cargoTomlPath)) { - await this.ProcessWorkspaceTablesAsync(cargoTomlPath, directory); + this.ProcessWorkspaceTables(cargoTomlPath, directory); } // Add current directory to visitedDirs @@ -640,7 +642,6 @@ private async Task ProcessCargoLockAsync(ProcessRequest processRequest, string d /// /// The full path to the Cargo.toml file to parse. /// The directory path where the Cargo.toml file is located, used as the root for glob patterns. - /// A task that represents the asynchronous operation. /// /// This method parses the [workspace] section of a Cargo.toml file to extract: /// @@ -651,11 +652,11 @@ private async Task ProcessCargoLockAsync(ProcessRequest processRequest, string d /// This enables proper filtering of workspace members during subsequent detection operations. /// If parsing fails, a warning is logged and the method continues without throwing an exception. /// - private async Task ProcessWorkspaceTablesAsync(string cargoTomlPath, string directory) + private void ProcessWorkspaceTables(string cargoTomlPath, string directory) { try { - var content = await File.ReadAllTextAsync(cargoTomlPath); + var content = this.fileUtilityService.ReadAllText(cargoTomlPath); var tomlTable = Toml.ToModel(content, options: TomlOptions); if (tomlTable.ContainsKey("workspace") && tomlTable["workspace"] is TomlTable workspaceTable) diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs index a9115ba26..016a1ffde 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs @@ -1,13 +1,17 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; +using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using FluentAssertions; using Microsoft.ComponentDetection.Common; using Microsoft.ComponentDetection.Contracts; +using Microsoft.ComponentDetection.Contracts.TypedComponent; using Microsoft.ComponentDetection.Detectors.Rust; -using Microsoft.ComponentDetection.Detectors.Rust.Sbom.Contracts; +using Microsoft.ComponentDetection.Detectors.Rust.Contracts; using Microsoft.ComponentDetection.TestsUtilities; using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -18,6 +22,12 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; [TestCategory("Governance/ComponentDetection")] public class RustSbomDetectorTests : BaseDetectorTest { + private readonly Mock mockMetadataContextBuilder; + private readonly Mock mockCliParser; + private readonly Mock mockCargoLockParser; + private readonly Mock mockSbomParser; + private readonly Mock> mockLogger; + private readonly string testSbom = /*lang=json,strict*/ @" { ""version"": 1, @@ -205,20 +215,40 @@ public class RustSbomDetectorTests : BaseDetectorTest ""target"": ""x86_64-pc-windows-msvc"" }"; + private IPathUtilityService pathUtilityService; + + public RustSbomDetectorTests() + { + this.mockMetadataContextBuilder = new Mock(); + this.mockCliParser = new Mock(); + this.mockCargoLockParser = new Mock(); + this.mockSbomParser = new Mock(); + this.mockLogger = new Mock>(); + } + [TestInitialize] public void Initialize() { - this.DetectorTestUtility.AddService(new PathUtilityService(new Mock>().Object)); - this.DetectorTestUtility.AddService(new Mock().Object); - this.DetectorTestUtility.AddService(new Mock().Object); - this.DetectorTestUtility.AddService(new Mock().Object); - this.DetectorTestUtility.AddService(new RustSbomParser(new Mock>().Object)); + this.mockMetadataContextBuilder.Reset(); + this.mockCliParser.Reset(); + this.mockCargoLockParser.Reset(); + this.mockSbomParser.Reset(); + this.mockLogger.Reset(); + + this.pathUtilityService = new PathUtilityService(new Mock>().Object); + + this.DetectorTestUtility.AddService(this.pathUtilityService); + this.DetectorTestUtility.AddService(this.mockMetadataContextBuilder.Object); + this.DetectorTestUtility.AddService(this.mockCliParser.Object); + this.DetectorTestUtility.AddService(this.mockCargoLockParser.Object); + this.DetectorTestUtility.AddService(this.mockSbomParser.Object); } [TestMethod] public async Task TestGraphIsCorrectAsync() { - var sbom = CargoSbom.FromJson(this.testSbom); + // Use real parser for this test + this.DetectorTestUtility.AddService(new RustSbomParser(new Mock>().Object)); var (result, componentRecorder) = await this.DetectorTestUtility .WithFile("main.exe.cargo-sbom.json", this.testSbom) @@ -227,9 +257,8 @@ public async Task TestGraphIsCorrectAsync() result.ResultCode.Should().Be(ProcessingResultCode.Success); componentRecorder.GetDetectedComponents().Should().HaveCount(3); - var graph = componentRecorder.GetDependencyGraphsByLocation().Values.First(); // There should only be 1 + var graph = componentRecorder.GetDependencyGraphsByLocation().Values.First(); - // Verify explicitly referenced roots var rootComponents = new List { "my_dependency 1.0.0 - Cargo", @@ -238,10 +267,8 @@ public async Task TestGraphIsCorrectAsync() rootComponents.ForEach(rootComponentId => graph.IsComponentExplicitlyReferenced(rootComponentId).Should().BeTrue()); - // Verify dependencies for my_dependency graph.GetDependenciesForComponent("my_dependency 1.0.0 - Cargo").Should().BeEmpty(); - // Verify dependencies for other_dependency var other_dependencyDependencies = new List { "other_dependency_dependency 0.1.12-alpha.6 - Cargo", @@ -253,7 +280,8 @@ public async Task TestGraphIsCorrectAsync() [TestMethod] public async Task TestGraphIsCorrectWithGitDeps() { - var sbom = CargoSbom.FromJson(this.testSbomWithGitDeps); + // Use real parser for this test + this.DetectorTestUtility.AddService(new RustSbomParser(new Mock>().Object)); var (result, componentRecorder) = await this.DetectorTestUtility .WithFile("main.exe.cargo-sbom.json", this.testSbomWithGitDeps) @@ -262,9 +290,694 @@ public async Task TestGraphIsCorrectWithGitDeps() result.ResultCode.Should().Be(ProcessingResultCode.Success); componentRecorder.GetDetectedComponents().Should().HaveCount(2); - var graph = componentRecorder.GetDependencyGraphsByLocation().Values.First(); // There should only be 1 + var graph = componentRecorder.GetDependencyGraphsByLocation().Values.First(); - // Verify dependencies graph.GetDependenciesForComponent("aho-corasick 1.1.3 - Cargo").Should().BeEquivalentTo("memchr 2.7.4 - Cargo"); } + + [TestMethod] + public void TestDetectorProperties() + { + var detector = new RustSbomDetector( + null, + null, + this.mockLogger.Object, + this.mockMetadataContextBuilder.Object, + this.pathUtilityService, + this.mockCliParser.Object, + this.mockSbomParser.Object, + this.mockCargoLockParser.Object, + null); + + detector.Id.Should().Be("RustSbom"); + detector.Categories.Should().BeEquivalentTo(["Rust"]); + detector.SupportedComponentTypes.Should().BeEquivalentTo([ComponentType.Cargo]); + detector.Version.Should().Be(1); + detector.SearchPatterns.Should().BeEquivalentTo(["Cargo.toml", "Cargo.lock", "*.cargo-sbom.json"]); + } + + [TestMethod] + public async Task TestSbomOnlyMode_WithOwnershipMap_VerifiesOwnershipUsed() + { + var sbomContent = /*lang=json,strict*/ @" +{ + ""version"": 1, + ""root"": 0, + ""crates"": [] +}"; + + var ownershipResult = new IRustMetadataContextBuilder.OwnershipResult + { + PackageToTomls = new Dictionary> + { + ["test"] = ["/path/Cargo.toml"], + }, + }; + + this.mockMetadataContextBuilder + .Setup(x => x.BuildPackageOwnershipMapAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(ownershipResult); + + // Track whether ownership was passed to the parser + IReadOnlyDictionary> capturedOwnership = null; + this.mockSbomParser + .Setup(x => x.ParseWithOwnershipAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Callback>, CancellationToken>( + (stream, recorder, parentRecorder, ownership, token) => + { + capturedOwnership = ownership; + }) + .ReturnsAsync(1); + + var (result, componentRecorder) = await this.DetectorTestUtility + .WithFile("test.cargo-sbom.json", sbomContent) + .WithFile("Cargo.toml", "[package]\nname = \"test\"\nversion = \"1.0.0\"") + .ExecuteDetectorAsync(); + + result.ResultCode.Should().Be(ProcessingResultCode.Success); + + // Verify metadata context was built + this.mockMetadataContextBuilder.Verify( + x => x.BuildPackageOwnershipMapAsync( + It.IsAny>(), + It.IsAny()), + Times.Once); + + // Verify ParseWithOwnershipAsync was called (not ParseAsync) + this.mockSbomParser.Verify( + x => x.ParseWithOwnershipAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny()), + Times.Once); + + // Verify the ownership map was actually passed + capturedOwnership.Should().NotBeNull(); + capturedOwnership.Should().ContainKey("test"); + capturedOwnership["test"].Should().Contain("/path/Cargo.toml"); + } + + [TestMethod] + public async Task TestMultipleSbomFiles_ProcessedInAlphabeticalOrder_VerifiesOrder() + { + var sbom1 = Path.Combine(Path.GetTempPath(), "a.cargo-sbom.json"); + var sbom2 = Path.Combine(Path.GetTempPath(), "z.cargo-sbom.json"); + + var sbomContent = "{\"version\":1,\"root\":0,\"crates\":[]}"; + + var processedFiles = new List(); + + this.mockSbomParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (stream, recorder, token) => + { + processedFiles.Add(stream.Location); + }) + .ReturnsAsync(1); + + await this.DetectorTestUtility + .WithFile("z.cargo-sbom.json", sbomContent, fileLocation: sbom2) + .WithFile("a.cargo-sbom.json", sbomContent, fileLocation: sbom1) + .ExecuteDetectorAsync(); + + // Verify both files were processed + processedFiles.Should().HaveCount(2); + + // Verify they were processed in alphabetical order + processedFiles[0].Should().Be(sbom1); + processedFiles[1].Should().Be(sbom2); + } + + [TestMethod] + public async Task TestWorkspaceGlobRules_MemberDirectoriesSkipped() + { + var workspaceDir = Path.Combine(Path.GetTempPath(), "workspace"); + var rootLock = Path.Combine(workspaceDir, "Cargo.lock"); + var rootToml = Path.Combine(workspaceDir, "Cargo.toml"); + var member1Lock = Path.Combine(workspaceDir, "member1", "Cargo.lock"); + + var cargoLockContent = @" +[[package]] +name = ""test"" +version = ""1.0.0"" +source = ""registry+https://github.com/rust-lang/crates.io-index"" +"; + + // Workspace-only TOML (no [package] section) + var workspaceTomlContent = @" +[workspace] +members = [""member1"", ""member2""] +exclude = [""tests/*""] +"; + + // Mock IFileUtilityService to simulate file system operations + var mockFileUtilityService = new Mock(); + + // Mock Exists to return true for the workspace Cargo.toml + mockFileUtilityService + .Setup(x => x.Exists(rootToml)) + .Returns(true); + + // Mock ReadAllText to return the workspace TOML content + mockFileUtilityService + .Setup(x => x.ReadAllText(rootToml)) + .Returns(workspaceTomlContent); + + var lockCallCount = 0; + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (stream, recorder, token) => lockCallCount++) + .ReturnsAsync(2); + + var (result, componentRecorder) = await this.DetectorTestUtility + .AddService(mockFileUtilityService.Object) + .WithFile("Cargo.lock", cargoLockContent, fileLocation: rootLock) + .WithFile("Cargo.toml", workspaceTomlContent, fileLocation: rootToml) + .WithFile("Cargo.lock", cargoLockContent, fileLocation: member1Lock) + .ExecuteDetectorAsync(); + + result.ResultCode.Should().Be(ProcessingResultCode.Success); + + // Root Cargo.lock should be processed + this.mockCargoLockParser.Verify( + x => x.ParseAsync( + It.Is(s => s.Location == rootLock), + It.IsAny(), + It.IsAny()), + Times.Once); + + // Member Cargo.lock should be skipped due to workspace glob rules + this.mockCargoLockParser.Verify( + x => x.ParseAsync( + It.Is(s => s.Location == member1Lock), + It.IsAny(), + It.IsAny()), + Times.Never); + + // Only one lock file should have been processed + lockCallCount.Should().Be(1, "Only the root workspace Cargo.lock should be processed"); + + // Verify File.Exists was called to check for Cargo.toml + mockFileUtilityService.Verify( + x => x.Exists(rootToml), + Times.Once, + "Should check if Cargo.toml exists in same directory as Cargo.lock"); + + // Verify ReadAllText was called to read the workspace TOML content + mockFileUtilityService.Verify( + x => x.ReadAllText(rootToml), + Times.AtLeastOnce); + } + + [TestMethod] + public async Task TestFallbackMode_CargoTomlWithoutMetadataCache_LogsWarning() + { + var cargoTomlContent = "[package]\nname = \"test\"\nversion = \"1.0.0\""; + var tomlPath = Path.Combine(Path.GetTempPath(), "missing", "Cargo.toml"); + + this.mockMetadataContextBuilder + .Setup(x => x.BuildPackageOwnershipMapAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new IRustMetadataContextBuilder.OwnershipResult + { + ManifestToMetadata = [], + }); + + // Create a new mock logger to capture warnings + var mockLoggerForDetector = new Mock>(); + + var (result, componentRecorder) = await this.DetectorTestUtility + .AddServiceMock(mockLoggerForDetector) + .WithFile("Cargo.toml", cargoTomlContent, fileLocation: tomlPath) + .ExecuteDetectorAsync(); + + result.ResultCode.Should().Be(ProcessingResultCode.Success); + + // CLI parser should not be called since there's no cache entry + this.mockCliParser.Verify( + x => x.ParseFromMetadataAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny()), + Times.Never); + + // Verify warning was logged + mockLoggerForDetector.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("No cached cargo metadata")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [TestMethod] + public async Task TestFileProcessingOrder_TomlBeforeLock_AndLockSkipped() + { + var rootToml = Path.Combine(Path.GetTempPath(), "project", "Cargo.toml"); + var rootLock = Path.Combine(Path.GetTempPath(), "project", "Cargo.lock"); + + var callOrder = new List(); + + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback((stream, recorder, token) => + { + callOrder.Add("lock:" + stream.Location); + }) + .ReturnsAsync(1); + + var normalizedRootDir = this.pathUtilityService.NormalizePath(Path.GetDirectoryName(rootToml)); + var metadata = new CargoMetadata + { + Packages = [], + Resolve = new Resolve { Nodes = [] }, + }; + + var ownershipResult = new IRustMetadataContextBuilder.OwnershipResult + { + ManifestToMetadata = new Dictionary + { + [this.pathUtilityService.NormalizePath(rootToml)] = metadata, + }, + }; + + this.mockMetadataContextBuilder + .Setup(x => x.BuildPackageOwnershipMapAsync( + It.IsAny>(), + It.IsAny())) + .Callback, CancellationToken>((paths, token) => + { + foreach (var path in paths) + { + callOrder.Add("toml:" + path); + } + }) + .ReturnsAsync(ownershipResult); + + this.mockCliParser + .Setup(x => x.ParseFromMetadataAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .Callback>, CancellationToken>( + (stream, recorder, metadata, parent, ownership, token) => + { + callOrder.Add("cli:" + stream.Location); + }) + .ReturnsAsync(new IRustCliParser.ParseResult { Success = true }); + + await this.DetectorTestUtility + .WithFile("Cargo.toml", "[package]\nname = \"root\"", fileLocation: rootToml) + .WithFile("Cargo.lock", "[[package]]\nname = \"root\"", fileLocation: rootLock) + .ExecuteDetectorAsync(); + + // Verify processing order + callOrder.Should().HaveCountGreaterThanOrEqualTo(2); + + // First should be TOML metadata building + callOrder[0].Should().StartWith("toml:"); + + // Second should be CLI parser processing the TOML + callOrder[1].Should().StartWith("cli:"); + + // Cargo.lock should NOT be processed because the directory was marked as visited + callOrder.Should().NotContain(c => c.Contains("lock:")); + + this.mockCargoLockParser.Verify( + x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task TestSkipLogic_SecondFileInSameDirectorySkipped() + { + var dir = Path.Combine(Path.GetTempPath(), "myproject"); + var lock1 = Path.Combine(dir, "Cargo.lock"); + var toml1 = Path.Combine(dir, "Cargo.toml"); + + var parsedFiles = new List(); + + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (stream, recorder, token) => + { + parsedFiles.Add(stream.Location); + }) + .ReturnsAsync(1); + + var metadata = new CargoMetadata + { + Packages = [], + Resolve = new Resolve { Nodes = [] }, + }; + + this.mockMetadataContextBuilder + .Setup(x => x.BuildPackageOwnershipMapAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new IRustMetadataContextBuilder.OwnershipResult + { + ManifestToMetadata = new Dictionary + { + [this.pathUtilityService.NormalizePath(toml1)] = metadata, + }, + }); + + this.mockCliParser + .Setup(x => x.ParseFromMetadataAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(new IRustCliParser.ParseResult { Success = true }); + + await this.DetectorTestUtility + .WithFile("Cargo.toml", "[package]\nname = \"test\"", fileLocation: toml1) + .WithFile("Cargo.lock", "[[package]]\nname = \"test\"", fileLocation: lock1) + .ExecuteDetectorAsync(); + + // Cargo.toml should be processed via CLI parser + this.mockCliParser.Verify( + x => x.ParseFromMetadataAsync( + It.Is(s => s.Location == toml1), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny()), + Times.Once); + + // Cargo.lock should be skipped because directory was marked as visited + this.mockCargoLockParser.Verify( + x => x.ParseAsync( + It.Is(s => s.Location == lock1), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task TestWorkspaceOnlyToml_NotSkippedEvenWhenDirectoryVisited() + { + var workspaceDir = Path.Combine(Path.GetTempPath(), "workspace"); + var workspaceToml = Path.Combine(workspaceDir, "Cargo.toml"); + var workspaceLock = Path.Combine(workspaceDir, "Cargo.lock"); + + // First process the lock file to mark directory as visited + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(1); + + // Track if workspace TOML was processed + var workspaceTomlProcessed = false; + + this.mockMetadataContextBuilder + .Setup(x => x.BuildPackageOwnershipMapAsync( + It.Is>(paths => paths.Any(p => p == workspaceToml)), + It.IsAny())) + .Callback, CancellationToken>((paths, token) => + { + if (paths.Contains(workspaceToml)) + { + workspaceTomlProcessed = true; + } + }) + .ReturnsAsync(new IRustMetadataContextBuilder.OwnershipResult()); + + var workspaceTomlContent = @" +[workspace] +members = [""member1""] +"; + + // Process files in order: lock first (marks directory as visited), then workspace TOML + await this.DetectorTestUtility + .WithFile("Cargo.lock", "[[package]]\nname = \"test\"", fileLocation: workspaceLock) + .WithFile("Cargo.toml", workspaceTomlContent, fileLocation: workspaceToml) + .ExecuteDetectorAsync(); + + // Workspace-only TOML should still be processed despite directory being visited + workspaceTomlProcessed.Should().BeTrue("Workspace-only Cargo.toml should never be skipped"); + + this.mockMetadataContextBuilder.Verify( + x => x.BuildPackageOwnershipMapAsync( + It.Is>(paths => paths.Contains(workspaceToml)), + It.IsAny()), + Times.Once); + } + + /* Error Handling Tests */ + + [TestMethod] + public async Task TestFailedManifests_LoggedAndOthersContinue() + { + var workingToml = Path.Combine(Path.GetTempPath(), "working", "Cargo.toml"); + var failedToml = Path.Combine(Path.GetTempPath(), "failed", "Cargo.toml"); + var workingLock = Path.Combine(Path.GetTempPath(), "working", "Cargo.lock"); + + var ownershipResult = new IRustMetadataContextBuilder.OwnershipResult + { + FailedManifests = [this.pathUtilityService.NormalizePath(failedToml)], + ManifestToMetadata = new Dictionary + { + [this.pathUtilityService.NormalizePath(workingToml)] = new CargoMetadata + { + Packages = [], + Resolve = new Resolve { Nodes = [] }, + }, + }, + }; + + this.mockMetadataContextBuilder + .Setup(x => x.BuildPackageOwnershipMapAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(ownershipResult); + + this.mockCliParser + .Setup(x => x.ParseFromMetadataAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(new IRustCliParser.ParseResult { Success = true }); + + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(1); + + var (result, componentRecorder) = await this.DetectorTestUtility + .WithFile("Cargo.toml", "[package]\nname = \"working\"", fileLocation: workingToml) + .WithFile("Cargo.toml", "[package]\nname = \"failed\"", fileLocation: failedToml) + .WithFile("Cargo.lock", "[[package]]\nname = \"test\"", fileLocation: workingLock) + .ExecuteDetectorAsync(); + + result.ResultCode.Should().Be(ProcessingResultCode.Success); + + // Working TOML should be processed + this.mockCliParser.Verify( + x => x.ParseFromMetadataAsync( + It.Is(s => s.Location == workingToml), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny()), + Times.Once); + + // Failed TOML should NOT have metadata, so CLI parser shouldn't be called for it + this.mockCliParser.Verify( + x => x.ParseFromMetadataAsync( + It.Is(s => s.Location == failedToml), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task TestMetadataContextBuilder_FailsGracefully() + { + this.mockMetadataContextBuilder + .Setup(x => x.BuildPackageOwnershipMapAsync( + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("cargo metadata command failed: cargo not found")); + + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(1); + + var (result, componentRecorder) = await this.DetectorTestUtility + .WithFile("Cargo.toml", "[package]\nname = \"test\"") + .WithFile("Cargo.lock", "[[package]]\nname = \"test\"") + .ExecuteDetectorAsync(); + + // Detection should continue despite metadata build failure + result.ResultCode.Should().Be(ProcessingResultCode.Success); + + // Lock parser should still be called + this.mockCargoLockParser.Verify( + x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task TestSbomOnlyMode_ProcessesSbomFilesOnly() + { + var sbomContent = /*lang=json,strict*/ @" +{ + ""version"": 1, + ""root"": 0, + ""crates"": [] +}"; + + this.mockSbomParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(1); + + var (result, componentRecorder) = await this.DetectorTestUtility + .WithFile("test.cargo-sbom.json", sbomContent) + .WithFile("Cargo.toml", "[package]\nname = \"test\"") + .WithFile("Cargo.lock", "[[package]]\nname = \"test\"") + .ExecuteDetectorAsync(); + + result.ResultCode.Should().Be(ProcessingResultCode.Success); + + // Verify CLI and lock parsers were NOT called in SBOM_ONLY mode + this.mockCliParser.Verify( + x => x.ParseFromMetadataAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny()), + Times.Never); + + this.mockCargoLockParser.Verify( + x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + } + + [TestMethod] + public async Task TestCargoLockMode_RecordsLockfileVersion() + { + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(2); + + var (result, componentRecorder) = await this.DetectorTestUtility + .WithFile("Cargo.lock", "[[package]]\nname = \"test\"") + .ExecuteDetectorAsync(); + + result.ResultCode.Should().Be(ProcessingResultCode.Success); + + // Telemetry should contain lockfile version + result.AdditionalTelemetryDetails.Should().ContainKey("LockfileVersion"); + } + + [TestMethod] + public async Task TestMixedSbomAndLockFiles_SbomTakesPrecedence() + { + var sbomContent = /*lang=json,strict*/ @" +{ + ""version"": 1, + ""root"": 0, + ""crates"": [] +}"; + + this.mockSbomParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(1); + + await this.DetectorTestUtility + .WithFile("test.cargo-sbom.json", sbomContent) + .WithFile("Cargo.lock", "[[package]]\nname = \"test\"") + .WithFile("Cargo.toml", "[package]\nname = \"test\"") + .ExecuteDetectorAsync(); + + // Only SBOM mode should be active (SBOM_ONLY mode) + this.mockCargoLockParser.Verify( + x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + + this.mockCliParser.Verify( + x => x.ParseFromMetadataAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny()), + Times.Never); + } } From b9124a68bae53f44fc1ba53939bc55d895a19467 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Mon, 20 Oct 2025 14:44:54 -0700 Subject: [PATCH 19/23] Add dependency edges in SBOM mode as well --- .../rust/Parsers/RustSbomParser.cs | 89 ++++++++++++------- 1 file changed, 55 insertions(+), 34 deletions(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs index 458ce5a38..237ad5cea 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustSbomParser.cs @@ -184,7 +184,7 @@ private void ProcessDependencyWithOwnership( IComponentRecorder parentComponentRecorder, IReadOnlyDictionary> ownershipMap, HashSet visitedNodes, - CargoComponent parent, + DetectedComponent parent, int depth) { foreach (var dependency in package.Dependencies) @@ -196,40 +196,19 @@ private void ProcessDependencyWithOwnership( { if (component.Source == CratesIoSource) { - parentComponent = component; - - // Determine ownership - var metadataId = depCrate.Id; - var ownersApplied = false; - - if (ownershipMap != null && - parentComponentRecorder != null && - ownershipMap.TryGetValue(metadataId, out var owners) && - owners != null && owners.Count > 0) - { - ownersApplied = true; - foreach (var manifestPath in owners) - { - var ownerRecorder = parentComponentRecorder.CreateSingleFileComponentRecorder(manifestPath); - ownerRecorder.RegisterUsage( - new DetectedComponent(component), - isExplicitReferencedDependency: depth == 0, - parentComponentId: null, - isDevelopmentDependency: false); - } - } + var detectedComponent = new DetectedComponent(component); + parentComponent = detectedComponent; - if (!ownersApplied) - { - this.logger.LogWarning("Falling back to SBOM recorder for {Id} because no ownership found", metadataId); - - // Fallback to SBOM recorder if no ownership info - sbomRecorder.RegisterUsage( - new DetectedComponent(component), - isExplicitReferencedDependency: depth == 0, - parentComponentId: null, - isDevelopmentDependency: false); - } + // Apply ownership using the new helper method + this.ApplyOwners( + depCrate.Id, + detectedComponent, + isExplicitReferencedDependency: depth == 0, + isDevelopmentDependency: false, + parentComponentRecorder, + ownershipMap, + sbomRecorder, + parent); } } else @@ -254,6 +233,48 @@ private void ProcessDependencyWithOwnership( } } + private void ApplyOwners( + string id, + DetectedComponent detectedComponent, + bool isExplicitReferencedDependency, + bool isDevelopmentDependency, + IComponentRecorder parentComponentRecorder, + IReadOnlyDictionary> ownershipMap, + ISingleFileComponentRecorder fallbackRecorder, + DetectedComponent parent) + { + var ownersApplied = false; + var parentId = parent?.Component.Id; + if (ownershipMap != null && + parentComponentRecorder != null && + ownershipMap.TryGetValue(id, out var owners) && + owners != null && owners.Count > 0) + { + ownersApplied = true; + foreach (var manifestPath in owners) + { + var ownerRecorder = parentComponentRecorder.CreateSingleFileComponentRecorder(manifestPath); + ownerRecorder.RegisterUsage( + detectedComponent, + isExplicitReferencedDependency, + isDevelopmentDependency: isDevelopmentDependency, + parentComponentId: parentId != null && ownerRecorder.DependencyGraph.Contains(parentId) ? parentId : null); + } + } + + if (!ownersApplied) + { + this.logger.LogWarning("Falling back to SBOM recorder for {Id} because no ownership found", id); + + // Fallback to SBOM recorder if no ownership info + fallbackRecorder.RegisterUsage( + detectedComponent, + isExplicitReferencedDependency, + isDevelopmentDependency: isDevelopmentDependency, + parentComponentId: parentId != null && fallbackRecorder.DependencyGraph.Contains(parentId) ? parentId : null); + } + } + private bool ParsePackageIdSpec(string dependency, out CargoComponent component) { var match = CargoPackageIdRegex.Match(dependency); From 89c4967abc98fc7809ce3223a3a9956999e8abe2 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Mon, 20 Oct 2025 14:54:13 -0700 Subject: [PATCH 20/23] Fix sbom parser UT --- .../RustSbomParserTests.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomParserTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomParserTests.cs index 9eea05c4e..fcb00a078 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomParserTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomParserTests.cs @@ -492,6 +492,15 @@ public async Task ParseWithOwnershipAsync_PartialOwnership_MixesFallbackAndOwner var parentRecorder = new Mock(MockBehavior.Strict); var owner1 = new Mock(MockBehavior.Loose); + // Set up DependencyGraph for the recorders + var sbomGraph = new Mock(); + sbomGraph.Setup(g => g.Contains(It.IsAny())).Returns(false); + sbomRecorder.Setup(r => r.DependencyGraph).Returns(sbomGraph.Object); + + var owner1Graph = new Mock(); + owner1Graph.Setup(g => g.Contains(It.IsAny())).Returns(false); + owner1.Setup(r => r.DependencyGraph).Returns(owner1Graph.Object); + parentRecorder.Setup(p => p.CreateSingleFileComponentRecorder("manifests/owner1")).Returns(owner1.Object); var ownershipMap = new Dictionary> From 0ecb941c6837359ad2730a95bfe85d9b76b57e88 Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Tue, 21 Oct 2025 09:21:25 -0700 Subject: [PATCH 21/23] Add more UTs for RustSbomDetector --- .../RustSbomDetectorTests.cs | 491 ++++++++++++++++++ 1 file changed, 491 insertions(+) diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs index 016a1ffde..899711d4d 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs @@ -980,4 +980,495 @@ await this.DetectorTestUtility It.IsAny()), Times.Never); } + + [TestMethod] + public async Task TestShouldSkip_WorkspaceOnlyToml_WithPackageSection_NotSkipped() + { + var workspaceDir = Path.Combine(Path.GetTempPath(), "workspace"); + var workspaceToml = Path.Combine(workspaceDir, "Cargo.toml"); + + // TOML with both [workspace] and [package] sections + var tomlContent = @" +[workspace] +members = [""member1""] + +[package] +name = ""root"" +version = ""1.0.0"" +"; + + var mockFileUtilityService = new Mock(); + mockFileUtilityService.Setup(x => x.Exists(workspaceToml)).Returns(true); + mockFileUtilityService.Setup(x => x.ReadAllText(workspaceToml)).Returns(tomlContent); + + var metadata = new CargoMetadata + { + Packages = [], + Resolve = new Resolve { Nodes = [] }, + }; + + this.mockMetadataContextBuilder + .Setup(x => x.BuildPackageOwnershipMapAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new IRustMetadataContextBuilder.OwnershipResult + { + ManifestToMetadata = new Dictionary + { + [this.pathUtilityService.NormalizePath(workspaceToml)] = metadata, + }, + }); + + this.mockCliParser + .Setup(x => x.ParseFromMetadataAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(new IRustCliParser.ParseResult { Success = true }); + + await this.DetectorTestUtility + .AddService(mockFileUtilityService.Object) + .WithFile("Cargo.toml", tomlContent, fileLocation: workspaceToml) + .ExecuteDetectorAsync(); + + // Should process the TOML since it has [package] section + this.mockCliParser.Verify( + x => x.ParseFromMetadataAsync( + It.Is(s => s.Location == workspaceToml), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task TestProcessCargoLockAsync_NoCargoToml_DirectoryStillMarkedVisited() + { + var lockDir = Path.Combine(Path.GetTempPath(), "project"); + var lockFile = Path.Combine(lockDir, "Cargo.lock"); + var tomlFile = Path.Combine(lockDir, "Cargo.toml"); + + var mockFileUtilityService = new Mock(); + mockFileUtilityService.Setup(x => x.Exists(tomlFile)).Returns(false); + + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(2); + + await this.DetectorTestUtility + .AddService(mockFileUtilityService.Object) + .WithFile("Cargo.lock", "[[package]]\nname = \"test\"", fileLocation: lockFile) + .ExecuteDetectorAsync(); + + this.mockCargoLockParser.Verify( + x => x.ParseAsync( + It.Is(s => s.Location == lockFile), + It.IsAny(), + It.IsAny()), + Times.Once); + + mockFileUtilityService.Verify(x => x.Exists(tomlFile), Times.Once); + } + + [TestMethod] + public async Task TestProcessWorkspaceTables_DefaultMembers_CreatesGlobRule() + { + var workspaceDir = Path.Combine(Path.GetTempPath(), "workspace"); + var rootLock = Path.Combine(workspaceDir, "Cargo.lock"); + var rootToml = Path.Combine(workspaceDir, "Cargo.toml"); + var memberLock = Path.Combine(workspaceDir, "member1", "Cargo.lock"); + + var workspaceTomlContent = @" +[workspace] +default-members = [""member1"", ""member2""] +"; + + var mockFileUtilityService = new Mock(); + mockFileUtilityService.Setup(x => x.Exists(rootToml)).Returns(true); + mockFileUtilityService.Setup(x => x.ReadAllText(rootToml)).Returns(workspaceTomlContent); + + var lockCallCount = 0; + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (stream, recorder, token) => lockCallCount++) + .ReturnsAsync(2); + + await this.DetectorTestUtility + .AddService(mockFileUtilityService.Object) + .WithFile("Cargo.lock", "[[package]]\nname = \"test\"", fileLocation: rootLock) + .WithFile("Cargo.toml", workspaceTomlContent, fileLocation: rootToml) + .WithFile("Cargo.lock", "[[package]]\nname = \"member\"", fileLocation: memberLock) + .ExecuteDetectorAsync(); + + // Root lock should be processed + this.mockCargoLockParser.Verify( + x => x.ParseAsync( + It.Is(s => s.Location == rootLock), + It.IsAny(), + It.IsAny()), + Times.Once); + + mockFileUtilityService.Verify(x => x.ReadAllText(rootToml), Times.AtLeastOnce); + } + + [TestMethod] + public async Task TestProcessWorkspaceTables_InvalidToml_LogsWarningAndContinues() + { + var workspaceDir = Path.Combine(Path.GetTempPath(), "workspace"); + var rootLock = Path.Combine(workspaceDir, "Cargo.lock"); + var rootToml = Path.Combine(workspaceDir, "Cargo.toml"); + + var invalidTomlContent = "[workspace\nmembers = broken"; + + var mockFileUtilityService = new Mock(); + mockFileUtilityService.Setup(x => x.Exists(rootToml)).Returns(true); + mockFileUtilityService.Setup(x => x.ReadAllText(rootToml)).Returns(invalidTomlContent); + + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(2); + + var (result, componentRecorder) = await this.DetectorTestUtility + .AddService(mockFileUtilityService.Object) + .WithFile("Cargo.lock", "[[package]]\nname = \"test\"", fileLocation: rootLock) + .WithFile("Cargo.toml", invalidTomlContent, fileLocation: rootToml) + .ExecuteDetectorAsync(); + + // Should continue despite invalid TOML + result.ResultCode.Should().Be(ProcessingResultCode.Success); + + this.mockCargoLockParser.Verify( + x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task TestIsWorkspaceOnlyToml_ExceptionThrown_ReturnsFalse() + { + var workspaceDir = Path.Combine(Path.GetTempPath(), "workspace"); + var workspaceToml = Path.Combine(workspaceDir, "Cargo.toml"); + var workspaceLock = Path.Combine(workspaceDir, "Cargo.lock"); + + var mockFileUtilityService = new Mock(); + mockFileUtilityService + .Setup(x => x.ReadAllText(workspaceToml)) + .Throws(new IOException("File read error")); + + var metadata = new CargoMetadata + { + Packages = [], + Resolve = new Resolve { Nodes = [] }, + }; + + this.mockMetadataContextBuilder + .Setup(x => x.BuildPackageOwnershipMapAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new IRustMetadataContextBuilder.OwnershipResult + { + ManifestToMetadata = new Dictionary + { + [this.pathUtilityService.NormalizePath(workspaceToml)] = metadata, + }, + }); + + this.mockCliParser + .Setup(x => x.ParseFromMetadataAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(new IRustCliParser.ParseResult { Success = true }); + + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(1); + + var (result, componentRecorder) = await this.DetectorTestUtility + .AddService(mockFileUtilityService.Object) + .WithFile("Cargo.toml", "[workspace]\nmembers = []", fileLocation: workspaceToml) + .WithFile("Cargo.lock", "[[package]]\nname = \"test\"", fileLocation: workspaceLock) + .ExecuteDetectorAsync(); + + // Should continue despite exception + result.ResultCode.Should().Be(ProcessingResultCode.Success); + } + + [TestMethod] + public async Task TestGetDirectoryDepth_NullOrEmptyPath_ReturnsZero() + { + // This test verifies the GetDirectoryDepth method handles edge cases + var sbomContent = /*lang=json,strict*/ @"{""version"":1,""root"":0,""crates"":[]}"; + + this.mockSbomParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(1); + + var (result, componentRecorder) = await this.DetectorTestUtility + .WithFile("test.cargo-sbom.json", sbomContent) + .ExecuteDetectorAsync(); + + result.ResultCode.Should().Be(ProcessingResultCode.Success); + } + + [TestMethod] + public async Task TestCliParser_FailedParsing_DirectoryNotMarkedVisited() + { + var tomlDir = Path.Combine(Path.GetTempPath(), "project"); + var tomlFile = Path.Combine(tomlDir, "Cargo.toml"); + var lockFile = Path.Combine(tomlDir, "Cargo.lock"); + + var metadata = new CargoMetadata + { + Packages = [], + Resolve = new Resolve { Nodes = [] }, + }; + + this.mockMetadataContextBuilder + .Setup(x => x.BuildPackageOwnershipMapAsync( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync(new IRustMetadataContextBuilder.OwnershipResult + { + ManifestToMetadata = new Dictionary + { + [this.pathUtilityService.NormalizePath(tomlFile)] = metadata, + }, + }); + + // CLI parser fails + this.mockCliParser + .Setup(x => x.ParseFromMetadataAsync( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>>(), + It.IsAny())) + .ReturnsAsync(new IRustCliParser.ParseResult { Success = false }); + + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(2); + + await this.DetectorTestUtility + .WithFile("Cargo.toml", "[package]\nname = \"test\"", fileLocation: tomlFile) + .WithFile("Cargo.lock", "[[package]]\nname = \"test\"", fileLocation: lockFile) + .ExecuteDetectorAsync(); + + // Cargo.lock should be processed since CLI parsing failed + this.mockCargoLockParser.Verify( + x => x.ParseAsync( + It.Is(s => s.Location == lockFile), + It.IsAny(), + It.IsAny()), + Times.Once); + } + + [TestMethod] + public async Task TestCargoLockParser_ReturnsNull_NoTelemetryRecorded() + { + var lockFile = Path.Combine(Path.GetTempPath(), "project", "Cargo.lock"); + + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((int?)null); + + var (result, componentRecorder) = await this.DetectorTestUtility + .WithFile("Cargo.lock", "[[package]]\nname = \"test\"", fileLocation: lockFile) + .ExecuteDetectorAsync(); + + result.ResultCode.Should().Be(ProcessingResultCode.Success); + + // Telemetry should not contain lockfile version + result.AdditionalTelemetryDetails.Should().NotContainKey("LockfileVersion"); + } + + [TestMethod] + public async Task TestGlobRuleMatching_IncludeAndExclude_CorrectlyFilters() + { + var workspaceDir = Path.Combine(Path.GetTempPath(), "workspace"); + var rootLock = Path.Combine(workspaceDir, "Cargo.lock"); + var rootToml = Path.Combine(workspaceDir, "Cargo.toml"); + var member1Lock = Path.Combine(workspaceDir, "member1", "Cargo.lock"); + var examplesLock = Path.Combine(workspaceDir, "examples", "Cargo.lock"); + var examplesSubdirLock = Path.Combine(workspaceDir, "examples", "example1", "Cargo.lock"); + + // Realistic workspace: members = ["*"] means all subdirectories are members + // except those explicitly excluded (examples/*) + var workspaceTomlContent = @" +[workspace] +members = [""*""] +exclude = [""examples/*""] +"; + + var mockFileUtilityService = new Mock(); + mockFileUtilityService.Setup(x => x.Exists(rootToml)).Returns(true); + mockFileUtilityService.Setup(x => x.ReadAllText(rootToml)).Returns(workspaceTomlContent); + + var processedFiles = new List(); + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (stream, recorder, token) => processedFiles.Add(stream.Location)) + .ReturnsAsync(2); + + await this.DetectorTestUtility + .AddService(mockFileUtilityService.Object) + .WithFile("Cargo.lock", "[[package]]\nname = \"root\"", fileLocation: rootLock) + .WithFile("Cargo.toml", workspaceTomlContent, fileLocation: rootToml) + .WithFile("Cargo.lock", "[[package]]\nname = \"member1\"", fileLocation: member1Lock) + .WithFile("Cargo.lock", "[[package]]\nname = \"example\"", fileLocation: examplesLock) + .WithFile("Cargo.lock", "[[package]]\nname = \"example1\"", fileLocation: examplesSubdirLock) + .ExecuteDetectorAsync(); + + // Root lock should be processed (workspace root) + processedFiles.Should().Contain(rootLock); + + // member1 lock should be SKIPPED (included in workspace via members = ["*"]) + processedFiles.Should().NotContain(member1Lock); + + // examples/Cargo.lock should be SKIPPED (examples/* doesn't match examples/ itself) + processedFiles.Should().NotContain(examplesLock); + + // examples/example1/Cargo.lock should be SKIPPED (matched by examples/*) + processedFiles.Should().Contain(examplesSubdirLock); + + // Two lock files should have been processed: root and examples + processedFiles.Should().HaveCount(2, "Root workspace Cargo.lock and examples/Cargo.lock should be processed"); + } + + [TestMethod] + public async Task TestGlobRuleMatching_IncludeAllSubdirectories_SkipsAllMembers() + { + var workspaceDir = Path.Combine(Path.GetTempPath(), "workspace"); + var rootLock = Path.Combine(workspaceDir, "Cargo.lock"); + var rootToml = Path.Combine(workspaceDir, "Cargo.toml"); + var member1Lock = Path.Combine(workspaceDir, "member1", "Cargo.lock"); + var examplesLock = Path.Combine(workspaceDir, "examples", "Cargo.lock"); + + var workspaceTomlContent = @" +[workspace] +members = [""*""] +"; + + var mockFileUtilityService = new Mock(); + mockFileUtilityService.Setup(x => x.Exists(rootToml)).Returns(true); + mockFileUtilityService.Setup(x => x.ReadAllText(rootToml)).Returns(workspaceTomlContent); + + var processedFiles = new List(); + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (stream, recorder, token) => processedFiles.Add(stream.Location)) + .ReturnsAsync(2); + + await this.DetectorTestUtility + .AddService(mockFileUtilityService.Object) + .WithFile("Cargo.lock", "[[package]]\nname = \"root\"", fileLocation: rootLock) + .WithFile("Cargo.toml", workspaceTomlContent, fileLocation: rootToml) + .WithFile("Cargo.lock", "[[package]]\nname = \"member1\"", fileLocation: member1Lock) + .WithFile("Cargo.lock", "[[package]]\nname = \"example\"", fileLocation: examplesLock) + .ExecuteDetectorAsync(); + + // Root lock should be processed + processedFiles.Should().Contain(rootLock); + + // All subdirectories (member1, examples) should be skipped + processedFiles.Should().NotContain(member1Lock); + processedFiles.Should().NotContain(examplesLock); + + processedFiles.Should().ContainSingle("Only the root Cargo.lock should be processed since all subdirectories are workspace members."); + } + + [TestMethod] + public async Task TestGlobRuleMatching_MixedExplicitAndGlob_CorrectlyIncludesAndSkips() + { + var workspaceDir = Path.Combine(Path.GetTempPath(), "workspace"); + var rootLock = Path.Combine(workspaceDir, "Cargo.lock"); + var rootToml = Path.Combine(workspaceDir, "Cargo.toml"); + var member1Lock = Path.Combine(workspaceDir, "member1", "Cargo.lock"); + var examplesLock = Path.Combine(workspaceDir, "examples", "Cargo.lock"); + var examplesSubdirLock = Path.Combine(workspaceDir, "examples", "example1", "Cargo.lock"); + + var workspaceTomlContent = @" +[workspace] +members = [""member1"", ""examples/*""] +"; + + var mockFileUtilityService = new Mock(); + mockFileUtilityService.Setup(x => x.Exists(rootToml)).Returns(true); + mockFileUtilityService.Setup(x => x.ReadAllText(rootToml)).Returns(workspaceTomlContent); + + var processedFiles = new List(); + this.mockCargoLockParser + .Setup(x => x.ParseAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Callback( + (stream, recorder, token) => processedFiles.Add(stream.Location)) + .ReturnsAsync(2); + + await this.DetectorTestUtility + .AddService(mockFileUtilityService.Object) + .WithFile("Cargo.lock", "[[package]]\nname = \"root\"", fileLocation: rootLock) + .WithFile("Cargo.toml", workspaceTomlContent, fileLocation: rootToml) + .WithFile("Cargo.lock", "[[package]]\nname = \"member1\"", fileLocation: member1Lock) + .WithFile("Cargo.lock", "[[package]]\nname = \"example\"", fileLocation: examplesLock) + .WithFile("Cargo.lock", "[[package]]\nname = \"example1\"", fileLocation: examplesSubdirLock) + .ExecuteDetectorAsync(); + + // Root processed + processedFiles.Should().Contain(rootLock); + + // member1 in workspace + processedFiles.Should().NotContain(member1Lock); + + // examples/ not matched by glob, should be processed + processedFiles.Should().Contain(examplesLock); + + // examples/example1/ matched by glob, should be skipped + processedFiles.Should().NotContain(examplesSubdirLock); + + processedFiles.Should().HaveCount(2, "Root and examples/ Cargo.locks should be processed, member1 and examples/example1 skipped."); + } } From e738ddcd7737ba1a5ee9e36cab709e14363c2a1b Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Tue, 21 Oct 2025 09:50:11 -0700 Subject: [PATCH 22/23] Add more UTs for Rust Parsers --- .../rust/Parsers/RustCliParser.cs | 2 +- .../RustCliParserTests.cs | 274 ++++++++++++++++++ .../RustSbomParserTests.cs | 267 +++++++++++++++++ 3 files changed, 542 insertions(+), 1 deletion(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs index 734187c4f..0c20d76fb 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/Parsers/RustCliParser.cs @@ -250,7 +250,7 @@ private void TraverseAndRecordComponents( { try { - var isDevelopmentDependency = depInfo?.DepKinds.Any(x => x.Kind is Kind.Dev) ?? false; + var isDevelopmentDependency = depInfo?.DepKinds?.Any(x => x.Kind is Kind.Dev) ?? false; if (!packagesMetadata.TryGetValue($"{id}", out var cargoComponent)) { diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliParserTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliParserTests.cs index 76c350889..18e75b9d4 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliParserTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliParserTests.cs @@ -861,6 +861,280 @@ public async Task AuthorsNormalization_MultipleNonBlankAuthors_JoinsWithComma() comp.Author.Should().Be("Alice Smith, Bob Jones, Charlie Brown"); } + [TestMethod] + public async Task ProcessMetadata_EmptyPackagesAndNodes_Success() + { + // Tests handling of empty packages and nodes + var json = """ + { + "packages": [], + "resolve": { + "root": null, + "nodes":[] + } + } + """; + + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + + var result = await this.InvokeProcessMetadataAsync("C:/repo/Cargo.toml", fallback.Object, metadata); + + result.Success.Should().BeTrue(); + result.LocalPackageDirectories.Should().BeEmpty(); + fallback.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(0); + } + + [TestMethod] + public async Task Traverse_IndexOutOfRangeException_RegistersParseFailure() + { + // Tests IndexOutOfRangeException handling in TraverseAndRecordComponents + var json = """ + { + "packages": [ + { "name":"root", "version":"1.0.0", "id":"root 1.0.0", "authors":[""], "license":"", "source":null, "manifest_path":"C:/repo/root/Cargo.toml" }, + { "name":"child", "version":"2.0.0", "id":"child 2.0.0", "authors":["A"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/child/Cargo.toml" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[ { "pkg":"child 2.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"child 2.0.0", "deps":[] } + ] + } + } + """; + + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + + // Mock RegisterPackageParseFailure to verify it's called + fallback.Setup(f => f.RegisterPackageParseFailure(It.IsAny())); + + var result = await this.InvokeProcessMetadataAsync("C:/repo/Cargo.toml", fallback.Object, metadata); + + result.Success.Should().BeTrue(); + } + + [TestMethod] + public async Task ProcessMetadata_LocalPackageWithEmptyManifestPath_Skipped() + { + // Tests handling of packages with empty manifest paths + var json = """ + { + "packages": [ + { "name":"root", "version":"1.0.0", "id":"root 1.0.0", "authors":[""], "license":"", "source":null, "manifest_path":"" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[] } + ] + } + } + """; + + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + + var result = await this.InvokeProcessMetadataAsync("C:/repo/Cargo.toml", fallback.Object, metadata); + + result.Success.Should().BeTrue(); + result.LocalPackageDirectories.Should().BeEmpty(); + } + + [TestMethod] + public async Task ApplyOwners_WithParentInGraph_PassesParentId() + { + // Tests that parentComponentId is passed when parent exists in graph + var metadata = ParseMetadata(BuildNormalRootMetadataJson()); + + var parentRecorder = new Mock(MockBehavior.Strict); + var owner = new Mock(MockBehavior.Loose); + var ownerGraph = new Mock(); + + // Setup graph to contain the parent + ownerGraph.Setup(g => g.Contains("childA 2.0.0")).Returns(true); + owner.Setup(r => r.DependencyGraph).Returns(ownerGraph.Object); + + parentRecorder.Setup(p => p.CreateSingleFileComponentRecorder("manifests/one")).Returns(owner.Object); + + var ownershipMap = new Dictionary> + { + { "childA 2.0.0", new HashSet { "manifests/one" } }, + }; + + var result = await this.parser.ParseFromMetadataAsync( + MakeTomlStream("C:/repo/Cargo.toml"), + new Mock().Object, + metadata, + parentRecorder.Object, + ownershipMap); + + result.Success.Should().BeTrue(); + + var registrations = owner.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().ContainSingle(); + } + + [TestMethod] + public async Task ApplyOwners_EmptyOwnersSet_UsesFallback() + { + // Tests that empty owners set falls back to fallback recorder + var metadata = ParseMetadata(BuildNormalRootMetadataJson()); + + var parentRecorder = new Mock(MockBehavior.Strict); + var fallback = new Mock(MockBehavior.Loose); + + var ownershipMap = new Dictionary> + { + { "childA 2.0.0", new HashSet() }, // Empty set + }; + + var result = await this.parser.ParseFromMetadataAsync( + MakeTomlStream("C:/repo/Cargo.toml"), + fallback.Object, + metadata, + parentRecorder.Object, + ownershipMap); + + result.Success.Should().BeTrue(); + + // Should use fallback for childA since owners set is empty + fallback.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().BeGreaterOrEqualTo(1); + } + + [TestMethod] + public async Task Traverse_DepKindsNull_NotTreatedAsDevelopmentDependency() + { + // Tests handling of null DepKinds + var json = """ + { + "packages": [ + { "name":"root", "version":"1.0.0", "id":"root 1.0.0", "authors":[""], "license":"", "source":null, "manifest_path":"C:/repo/root/Cargo.toml" }, + { "name":"child", "version":"2.0.0", "id":"child 2.0.0", "authors":["A"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/child/Cargo.toml" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[ { "pkg":"child 2.0.0", "dep_kinds":null } ] }, + { "id":"child 2.0.0", "deps":[] } + ] + } + } + """; + + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + + await this.InvokeProcessMetadataAsync("C:/repo/Cargo.toml", fallback.Object, metadata); + + var registrations = fallback.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + registrations.Should().ContainSingle(); + + // Verify isDevelopmentDependency is false (not true) + registrations[0].Arguments[3].Should().Be(false); + } + + [TestMethod] + public async Task ProcessMetadata_PackageWithNullAuthors_AuthorIsNull() + { + // Tests that null authors array results in null Author + var json = """ + { + "packages": [ + { "name":"root", "version":"1.0.0", "id":"root 1.0.0", "authors":null, "license":"MIT", "source":null, "manifest_path":"C:/repo/root/Cargo.toml" }, + { "name":"child", "version":"2.0.0", "id":"child 2.0.0", "authors":null, "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/child/Cargo.toml" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[ { "pkg":"child 2.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"child 2.0.0", "deps":[] } + ] + } + } + """; + + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + + await this.InvokeProcessMetadataAsync("C:/repo/Cargo.toml", fallback.Object, metadata); + + var reg = fallback.Invocations.Single(i => i.Method.Name == "RegisterUsage"); + var comp = ((DetectedComponent)reg.Arguments[0]).Component as CargoComponent; + + comp.Author.Should().BeNull(); + } + + [TestMethod] + public async Task ProcessMetadata_PackageWithNullLicense_LicenseIsNull() + { + // Tests that null license results in null License + var json = """ + { + "packages": [ + { "name":"root", "version":"1.0.0", "id":"root 1.0.0", "authors":["A"], "license":null, "source":null, "manifest_path":"C:/repo/root/Cargo.toml" }, + { "name":"child", "version":"2.0.0", "id":"child 2.0.0", "authors":["B"], "license":null, "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/child/Cargo.toml" } + ], + "resolve": { + "root":"root 1.0.0", + "nodes":[ + { "id":"root 1.0.0", "deps":[ { "pkg":"child 2.0.0", "dep_kinds":[{"kind":"build"}] } ] }, + { "id":"child 2.0.0", "deps":[] } + ] + } + } + """; + + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + + await this.InvokeProcessMetadataAsync("C:/repo/Cargo.toml", fallback.Object, metadata); + + var reg = fallback.Invocations.Single(i => i.Method.Name == "RegisterUsage"); + var comp = ((DetectedComponent)reg.Arguments[0]).Component as CargoComponent; + + comp.License.Should().BeNull(); + } + + [TestMethod] + public async Task VirtualManifest_MultipleRootNodes_AllProcessed() + { + // Tests virtual manifest with multiple independent root nodes + var json = """ + { + "packages": [ + { "name":"pkgA", "version":"1.0.0", "id":"pkgA 1.0.0", "authors":["A"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/pkgA/Cargo.toml" }, + { "name":"pkgB", "version":"2.0.0", "id":"pkgB 2.0.0", "authors":["B"], "license":"MIT", "source":"registry+https://github.com/rust-lang/crates.io-index", "manifest_path":"C:/repo/pkgB/Cargo.toml" } + ], + "resolve": { + "root": null, + "nodes":[ + { "id":"pkgA 1.0.0", "deps":[] }, + { "id":"pkgB 2.0.0", "deps":[] } + ] + } + } + """; + + var metadata = ParseMetadata(json); + var fallback = new Mock(MockBehavior.Loose); + + var result = await this.InvokeProcessMetadataAsync("C:/repo/Cargo.toml", fallback.Object, metadata); + + result.Success.Should().BeTrue(); + + var registrations = fallback.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + var distinctNames = registrations + .Select(r => ((CargoComponent)((DetectedComponent)r.Arguments[0]).Component).Name) + .Distinct() + .ToList(); + + distinctNames.Should().Contain("pkgA"); + distinctNames.Should().Contain("pkgB"); + } + private async Task InvokeProcessMetadataAsync(string manifestLocation, ISingleFileComponentRecorder fallbackRecorder, CargoMetadata metadata) => await this.parser.ParseFromMetadataAsync( new ComponentStream { Location = manifestLocation, Pattern = "Cargo.toml", Stream = new MemoryStream([]) }, diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomParserTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomParserTests.cs index fcb00a078..9b0d5fcaa 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomParserTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomParserTests.cs @@ -724,4 +724,271 @@ public async Task ParsePackageIdSpec_VariousFormats_ParsedCorrectly() } } } + + [TestMethod] + public async Task ParseWithOwnershipAsync_ParentInDependencyGraph_PassesParentId() + { + var json = BuildNestedSbomJson(); + var sbomRecorder = new Mock(MockBehavior.Loose); + var parentRecorder = new Mock(MockBehavior.Strict); + var ownerRecorder = new Mock(MockBehavior.Loose); + + // Set up DependencyGraph to contain the parent ID + var ownerGraph = new Mock(); + ownerGraph.Setup(g => g.Contains(It.IsAny())).Returns(true); + ownerRecorder.Setup(r => r.DependencyGraph).Returns(ownerGraph.Object); + + parentRecorder.Setup(p => p.CreateSingleFileComponentRecorder("manifests/owner1")).Returns(ownerRecorder.Object); + + var ownershipMap = new Dictionary> + { + { $"{CratesIo}#parent@2.0.0", new HashSet { "manifests/owner1" } }, + { $"{CratesIo}#child@3.0.0", new HashSet { "manifests/owner1" } }, + }; + + await this.parser.ParseWithOwnershipAsync( + MakeSbomStream("test.cargo-sbom.json", json), + sbomRecorder.Object, + parentRecorder.Object, + ownershipMap); + + // Verify that parentComponentId is passed when parent exists in graph + var usages = ownerRecorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + usages.Should().HaveCount(2); + + // Child should have parent ID passed + var childUsage = usages.Last(); + childUsage.Arguments[2].Should().Be("parent 2.0.0 - Cargo"); // parentComponentId should be set + } + + [TestMethod] + public async Task ParseWithOwnershipAsync_ParentNotInDependencyGraph_PassesNullParentId() + { + var json = BuildNestedSbomJson(); + var sbomRecorder = new Mock(MockBehavior.Loose); + var parentRecorder = new Mock(MockBehavior.Strict); + var ownerRecorder = new Mock(MockBehavior.Loose); + + // Set up DependencyGraph to NOT contain the parent ID + var ownerGraph = new Mock(); + ownerGraph.Setup(g => g.Contains(It.IsAny())).Returns(false); + ownerRecorder.Setup(r => r.DependencyGraph).Returns(ownerGraph.Object); + + parentRecorder.Setup(p => p.CreateSingleFileComponentRecorder("manifests/owner1")).Returns(ownerRecorder.Object); + + var ownershipMap = new Dictionary> + { + { $"{CratesIo}#parent@2.0.0", new HashSet { "manifests/owner1" } }, + { $"{CratesIo}#child@3.0.0", new HashSet { "manifests/owner1" } }, + }; + + await this.parser.ParseWithOwnershipAsync( + MakeSbomStream("test.cargo-sbom.json", json), + sbomRecorder.Object, + parentRecorder.Object, + ownershipMap); + + // Verify that parentComponentId is null when parent not in graph + var usages = ownerRecorder.Invocations.Where(i => i.Method.Name == "RegisterUsage").ToList(); + usages.Should().HaveCount(2); + + // Child should have null parent ID + var childUsage = usages.Last(); + childUsage.Arguments[2].Should().BeNull(); // parentComponentId should be null + } + + [TestMethod] + public async Task ParseWithOwnershipAsync_EmptyOwnersSet_FallsBackToSbomRecorder() + { + var json = BuildSimpleSbomJson(); + var sbomRecorder = new Mock(MockBehavior.Loose); + var parentRecorder = new Mock(MockBehavior.Strict); + + // Set up an empty HashSet + var ownershipMap = new Dictionary> + { + { $"{CratesIo}#dep1@1.0.0", new HashSet() }, // Empty set + }; + + await this.parser.ParseWithOwnershipAsync( + MakeSbomStream("test.cargo-sbom.json", json), + sbomRecorder.Object, + parentRecorder.Object, + ownershipMap); + + // Should fall back to SBOM recorder + sbomRecorder.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().Be(1); + parentRecorder.Invocations.Should().BeEmpty(); + + // Verify logger warning was called + this.logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Falling back to SBOM recorder")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [TestMethod] + public async Task ParseWithOwnershipAsync_FallbackScenario_LogsWarning() + { + var json = BuildSimpleSbomJson(); + var sbomRecorder = new Mock(MockBehavior.Loose); + + await this.parser.ParseWithOwnershipAsync( + MakeSbomStream("test.cargo-sbom.json", json), + sbomRecorder.Object, + parentComponentRecorder: null, + ownershipMap: null); + + // Verify logger warning was called + this.logger.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Falling back to SBOM recorder")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [TestMethod] + public async Task ParseAsync_ExceptionInProcessCargoSbom_Caught() + { + // Create a SBOM with invalid root index to trigger exception in ProcessCargoSbom + var json = """ + { + "version": 1, + "root": 999, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [] + } + ] + } + """; + + var recorder = new Mock(MockBehavior.Loose); + + var version = await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + // Should still return version even though ProcessCargoSbom throws + version.Should().Be(1); + + // Verify error was logged + this.logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Failed to process Cargo SBOM file")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [TestMethod] + public async Task ParseWithOwnershipAsync_ExceptionInProcessCargoSbomWithOwnership_Caught() + { + // Create a SBOM with invalid root index + var json = """ + { + "version": 1, + "root": 999, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [] + } + ] + } + """; + + var sbomRecorder = new Mock(MockBehavior.Loose); + + var version = await this.parser.ParseWithOwnershipAsync( + MakeSbomStream("test.cargo-sbom.json", json), + sbomRecorder.Object, + parentComponentRecorder: null, + ownershipMap: null); + + // Should still return version + version.Should().Be(1); + + // Verify error was logged + this.logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => v.ToString().Contains("Failed to process Cargo SBOM (ownership mode)")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [TestMethod] + public async Task ParseAsync_InvalidDependencyIndex_CatchesException() + { + var json = $$""" + { + "version": 1, + "root": 0, + "crates": [ + { + "id": "path+file:///repo/root#0.1.0", + "features": [], + "dependencies": [ + { "index": 999, "kind": "normal" } + ] + } + ] + } + """; + + var recorder = new Mock(MockBehavior.Loose); + + var version = await this.parser.ParseAsync(MakeSbomStream("test.cargo-sbom.json", json), recorder.Object); + + // Should catch exception and log error + version.Should().Be(1); + + this.logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [TestMethod] + public async Task ParseWithOwnershipAsync_FallbackWithParentInGraph_PassesParentId() + { + var json = BuildNestedSbomJson(); + var sbomRecorder = new Mock(MockBehavior.Loose); + + // Set up DependencyGraph to contain parent ID + var sbomGraph = new Mock(); + sbomGraph.Setup(g => g.Contains(It.IsAny())).Returns(true); + sbomRecorder.Setup(r => r.DependencyGraph).Returns(sbomGraph.Object); + + var ownershipMap = new Dictionary> + { + { $"{CratesIo}#parent@2.0.0", new HashSet() }, // Empty - triggers fallback + }; + + await this.parser.ParseWithOwnershipAsync( + MakeSbomStream("test.cargo-sbom.json", json), + sbomRecorder.Object, + parentComponentRecorder: null, + ownershipMap); + + // Verify fallback happened and parentId was checked in graph + sbomRecorder.Invocations.Count(i => i.Method.Name == "RegisterUsage").Should().BePositive(); + } } From 9c5fa0a2b890e86dc1c121ef7f788fee09d28c9a Mon Sep 17 00:00:00 2001 From: Aayush Maini Date: Tue, 21 Oct 2025 10:04:36 -0700 Subject: [PATCH 23/23] CR: Use const string cargo file names, bump detector versions --- .../rust/RustCliDetector.cs | 2 +- .../rust/RustCrateDetector.cs | 2 +- .../rust/RustSbomDetector.cs | 21 +++++++++++-------- .../RustSbomDetectorTests.cs | 1 - 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs index bfd46425c..92d18f0b1 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs @@ -58,7 +58,7 @@ public RustCliDetector( public override IEnumerable SupportedComponentTypes => [ComponentType.Cargo]; /// - public override int Version => 4; + public override int Version => 5; /// public override IList SearchPatterns { get; } = ["Cargo.toml"]; diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs index d9f170d12..b8ddd08f7 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs @@ -31,7 +31,7 @@ public RustCrateDetector( public override IEnumerable SupportedComponentTypes => [ComponentType.Cargo]; - public override int Version { get; } = 8; + public override int Version { get; } = 9; public override IEnumerable Categories => ["Rust"]; diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs index 62e0e863f..d9a366e2e 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustSbomDetector.cs @@ -21,6 +21,9 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; /// public class RustSbomDetector : FileComponentDetector, IExperimentalDetector { + private const string CargoTomlFileName = "Cargo.toml"; + private const string CargoLockFileName = "Cargo.lock"; + private static readonly TomlModelOptions TomlOptions = new TomlModelOptions { IgnoreMissingProperties = true, @@ -118,10 +121,10 @@ private enum FileKind public override IEnumerable SupportedComponentTypes => [ComponentType.Cargo]; /// - public override int Version => 1; + public override int Version => 2; /// - public override IList SearchPatterns { get; } = ["Cargo.toml", "Cargo.lock", "*.cargo-sbom.json"]; + public override IList SearchPatterns { get; } = [CargoTomlFileName, CargoLockFileName, "*.cargo-sbom.json"]; /// protected override async Task> OnPrepareDetectionAsync( @@ -143,7 +146,7 @@ protected override async Task> OnPrepareDetectionAsy // Collect Cargo.toml paths ordered (depth, then path) var tomlPaths = allRequests .Select(r => r.ComponentStream.Location) - .Where(p => string.Equals(Path.GetFileName(p), "Cargo.toml", this.pathComparison)) + .Where(p => string.Equals(Path.GetFileName(p), CargoTomlFileName, this.pathComparison)) .OrderBy(p => this.GetDirectoryDepth(p)) .ThenBy(p => p, this.pathComparer) .ToList(); @@ -201,10 +204,10 @@ protected override async Task> OnPrepareDetectionAsy .Where(r => { var fileName = Path.GetFileName(r.ComponentStream.Location); - return fileName.Equals("Cargo.toml", this.pathComparison) || - fileName.Equals("Cargo.lock", this.pathComparison); + return fileName.Equals(CargoTomlFileName, this.pathComparison) || + fileName.Equals(CargoLockFileName, this.pathComparison); }) - .OrderBy(r => Path.GetFileName(r.ComponentStream.Location).Equals("Cargo.lock", this.pathComparison) ? 1 : 0) // TOML before LOCK + .OrderBy(r => Path.GetFileName(r.ComponentStream.Location).Equals(CargoLockFileName, this.pathComparison) ? 1 : 0) // TOML before LOCK .ThenBy(r => this.GetDirectoryDepth(r.ComponentStream.Location)) .ThenBy(r => r.ComponentStream.Location, this.pathComparer); this.Logger.LogInformation("FALLBACK mode: Processing {Count} Cargo.toml/Cargo.lock files", filteredRequests.Count()); @@ -231,11 +234,11 @@ protected override async Task OnFileFoundAsync( // Determine file kind FileKind fileKind; - if (fileName.Equals("Cargo.toml", this.pathComparison)) + if (fileName.Equals(CargoTomlFileName, this.pathComparison)) { fileKind = FileKind.CargoToml; } - else if (fileName.Equals("Cargo.lock", this.pathComparison)) + else if (fileName.Equals(CargoLockFileName, this.pathComparison)) { fileKind = FileKind.CargoLock; } @@ -626,7 +629,7 @@ private async Task ProcessCargoLockAsync(ProcessRequest processRequest, string d this.RecordLockfileVersion(version.Value); // Check if Cargo.toml exists in same directory to parse workspace tables - var cargoTomlPath = Path.Combine(directory, "Cargo.toml"); + var cargoTomlPath = Path.Combine(directory, CargoTomlFileName); if (this.fileUtilityService.Exists(cargoTomlPath)) { this.ProcessWorkspaceTables(cargoTomlPath, directory); diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs index 899711d4d..7ff86a497 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustSbomDetectorTests.cs @@ -312,7 +312,6 @@ public void TestDetectorProperties() detector.Id.Should().Be("RustSbom"); detector.Categories.Should().BeEquivalentTo(["Rust"]); detector.SupportedComponentTypes.Should().BeEquivalentTo([ComponentType.Cargo]); - detector.Version.Should().Be(1); detector.SearchPatterns.Should().BeEquivalentTo(["Cargo.toml", "Cargo.lock", "*.cargo-sbom.json"]); }