diff --git a/src/Microsoft.ComponentDetection.Common/Telemetry/Records/RustGraphTelemetryRecord.cs b/src/Microsoft.ComponentDetection.Common/Telemetry/Records/RustGraphTelemetryRecord.cs new file mode 100644 index 000000000..0b09cc9e7 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Common/Telemetry/Records/RustGraphTelemetryRecord.cs @@ -0,0 +1,20 @@ +namespace Microsoft.ComponentDetection.Common.Telemetry.Records; + +public class RustGraphTelemetryRecord : BaseDetectionTelemetryRecord +{ + public override string RecordName => "RustGraph"; + + public string CargoTomlLocation { get; set; } + + public bool WasRustFallbackStrategyUsed { get; set; } + + public string FallbackReason { get; set; } + + public bool FallbackCargoLockFound { get; set; } + + public string FallbackCargoLockLocation { get; set; } + + public bool DidRustCliCommandFail { get; set; } + + public string RustCliCommandError { get; set; } +} diff --git a/src/Microsoft.ComponentDetection.Contracts/FileComponentDetector.cs b/src/Microsoft.ComponentDetection.Contracts/FileComponentDetector.cs index eef1817ac..279fa1c86 100644 --- a/src/Microsoft.ComponentDetection.Contracts/FileComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Contracts/FileComponentDetector.cs @@ -57,6 +57,11 @@ public abstract class FileComponentDetector : IComponentDetector protected Dictionary Telemetry { get; set; } = new Dictionary(); + /// + /// List of any any additional properties as key-value pairs that we would like to capture for the detector. + /// + public List<(string PropertyKey, string PropertyValue)> AdditionalProperties { get; set; } = new List<(string PropertyKey, string PropertyValue)>(); + protected IObservable ComponentStreams { get; private set; } /// diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs index 7e6af433a..e915fb3a7 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustCliDetector.cs @@ -2,19 +2,35 @@ namespace Microsoft.ComponentDetection.Detectors.Rust; using System; using System.Collections.Generic; +using System.IO; using System.Linq; +using System.Text.RegularExpressions; using System.Threading.Tasks; +using Microsoft.ComponentDetection.Common; +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 Newtonsoft.Json; +using Tomlyn; /// /// A Rust CLI detector that uses the cargo metadata command to detect Rust components. /// public class RustCliDetector : FileComponentDetector, IExperimentalDetector { + //// PkgName[ Version][ (Source)] + private static readonly Regex DependencyFormatRegex = new Regex( + @"^(?[^ ]+)(?: (?[^ ]+))?(?: \((?[^()]*)\))?$", + RegexOptions.Compiled); + + private static readonly TomlModelOptions TomlOptions = new TomlModelOptions + { + IgnoreMissingProperties = true, + }; + private readonly ICommandLineInvocationService cliService; /// @@ -56,59 +72,98 @@ 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 (!await this.cliService.CanCommandBeLocatedAsync("cargo", null)) { this.Logger.LogWarning("Could not locate cargo command. Skipping Rust CLI detection"); - return; + record.DidRustCliCommandFail = true; + record.WasRustFallbackStrategyUsed = true; + record.FallbackReason = "Could not locate cargo command"; } - - // 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"); - - if (cliResult.ExitCode != 0) + else { - this.Logger.LogWarning("`cargo metadata` failed with {Location}. Ensure the Cargo.lock is up to date. stderr: {StdErr}", processRequest.ComponentStream.Location, cliResult.StdErr); - return; - } + // 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"); - var metadata = CargoMetadata.FromJson(cliResult.StdOut); - var graph = BuildGraph(metadata); + if (cliResult.ExitCode != 0) + { + this.Logger.LogWarning("`cargo metadata` failed with {Location}. Ensure the Cargo.lock is up to date.", processRequest.ComponentStream.Location); + record.DidRustCliCommandFail = true; + record.WasRustFallbackStrategyUsed = true; + record.RustCliCommandError = cliResult.StdErr; + record.FallbackReason = "`cargo metadata` failed"; + } - var packages = metadata.Packages.ToDictionary( - x => $"{x.Name} {x.Version}", - x => ( - (x.Authors == null || x.Authors.Any(a => string.IsNullOrWhiteSpace(a)) || !x.Authors.Any()) ? null : string.Join(", ", x.Authors), - string.IsNullOrWhiteSpace(x.License) ? null : x.License)); + if (!record.DidRustCliCommandFail) + { + var metadata = CargoMetadata.FromJson(cliResult.StdOut); + var graph = BuildGraph(metadata); - var root = metadata.Resolve.Root; + var packages = metadata.Packages.ToDictionary( + x => $"{x.Name} {x.Version}", + x => ( + (x.Authors == null || x.Authors.Any(a => string.IsNullOrWhiteSpace(a)) || !x.Authors.Any()) ? null : string.Join(", ", x.Authors), + string.IsNullOrWhiteSpace(x.License) ? null : x.License)); - // 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}, skipping component mapping", processRequest.ComponentStream.Location); - return; - } + var root = metadata.Resolve.Root; - HashSet visitedDependencies = new(); + // 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}, falling back to cargo.lock parsing", processRequest.ComponentStream.Location); + record.DidRustCliCommandFail = true; + record.WasRustFallbackStrategyUsed = true; + record.FallbackReason = "Virtual Manifest"; + } - this.TraverseAndRecordComponents(processRequest.SingleFileComponentRecorder, componentStream.Location, graph, root, null, null, packages, visitedDependencies); + HashSet visitedDependencies = new(); + if (!record.WasRustFallbackStrategyUsed) + { + this.TraverseAndRecordComponents(processRequest.SingleFileComponentRecorder, componentStream.Location, graph, root, null, null, packages, visitedDependencies); + } + } + } } - catch (InvalidOperationException e) + catch (Exception e) { this.Logger.LogWarning(e, "Failed attempting to call `cargo` with file: {Location}", processRequest.ComponentStream.Location); + record.DidRustCliCommandFail = true; + record.RustCliCommandError = e.Message; + record.WasRustFallbackStrategyUsed = true; + record.FallbackReason = "InvalidOperationException"; + } + finally + { + if (record.WasRustFallbackStrategyUsed) + { + try + { + await this.ProcessCargoLockFallbackAsync(componentStream, processRequest.SingleFileComponentRecorder, record); + } + catch (ArgumentException e) + { + this.Logger.LogWarning(e, "fallback failed for {Location}", processRequest.ComponentStream.Location); + record.DidRustCliCommandFail = true; + record.RustCliCommandError = e.Message; + record.WasRustFallbackStrategyUsed = true; + } + + this.AdditionalProperties.Add(("Rust Fallback", JsonConvert.SerializeObject(record))); + } } } @@ -120,6 +175,27 @@ private static (string Name, string Version) ParseNameAndVersion(string nameAndV return (parts[0], parts[1]); } + private static bool IsLocalPackage(CargoPackage package) => package.Source == null; + + 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 void TraverseAndRecordComponents( ISingleFileComponentRecorder recorder, string location, @@ -170,4 +246,209 @@ private void TraverseAndRecordComponents( 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, new List { "Cargo.lock" }, (name, directoryName) => false, recursivelyScanDirectories: false).FirstOrDefault(); + if (cargoLockStream == null) + { + return null; + } + + if (cargoLockStream.Stream.CanRead) + { + return cargoLockStream; + } + else + { + using var fileStream = new FileStream(cargoLockStream.Location, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + return new ComponentStream() + { + Location = cargoLockStream.Location, + Pattern = cargoLockStream.Pattern, + Stream = fileStream, + }; + } + } + + private async Task ProcessCargoLockFallbackAsync(IComponentStream cargoTomlFile, ISingleFileComponentRecorder singleFileComponentRecorder, RustGraphTelemetryRecord record) + { + var cargoLockFileStream = this.FindCorrespondingCargoLock(cargoTomlFile, singleFileComponentRecorder); + if (cargoLockFileStream == null) + { + this.Logger.LogWarning("Could not find Cargo.lock file for {CargoTomlLocation}, skipping processing", cargoTomlFile.Location); + record.FallbackCargoLockFound = false; + return; + } + + 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 = new List<(CargoPackage, CargoComponent)>(); + 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); + } + } + + private void ProcessDependency( + IComponentStream cargoLockFile, + ISingleFileComponentRecorder singleFileComponentRecorder, + HashSet seenAsDependency, + Dictionary> packagesByName, + CargoPackage parentPackage, + CargoComponent parentComponent, + string dependency) + { + try + { + // 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); + } + } } diff --git a/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs b/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs index 2483a189f..7ee6def41 100644 --- a/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/rust/RustCrateDetector.cs @@ -54,7 +54,7 @@ private static bool ParseDependency(string dependency, out string packageName, o version = versionMatch.Success ? versionMatch.Value : null; source = sourceMatch.Success ? sourceMatch.Value : null; - if (string.IsNullOrEmpty(source)) + if (string.IsNullOrWhiteSpace(source)) { source = null; } @@ -68,17 +68,20 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID { 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(), options: options); + this.RecordLockfileVersion(cargoLock.Version); + this.ProcessCargoLock(cargoLock, singleFileComponentRecorder, cargoLockFile); + } + private void ProcessCargoLock(CargoLock cargoLock, ISingleFileComponentRecorder singleFileComponentRecorder, IComponentStream cargoLockFile) + { try { - var reader = new StreamReader(cargoLockFile.Stream); - var options = new TomlModelOptions - { - IgnoreMissingProperties = true, - }; - var cargoLock = Toml.ToModel(await reader.ReadToEndAsync(), options: options); - this.RecordLockfileVersion(cargoLock.Version); - var seenAsDependency = new HashSet(); // Pass 1: Create typed components and allow lookup by name. @@ -152,8 +155,6 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID // If something went wrong, just ignore the file this.Logger.LogError(e, "Failed to process Cargo.lock file '{CargoLockLocation}'", cargoLockFile.Location); } - - await Task.CompletedTask; } private void ProcessDependency( diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Experiments/ExperimentService.cs b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/ExperimentService.cs index 0cbf17d41..d49ab31b3 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Experiments/ExperimentService.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/ExperimentService.cs @@ -112,6 +112,11 @@ public void RecordDetectorRun( detector.Id, config.Name); } + + if (detector is FileComponentDetector fileDetector) + { + experimentResults.AddAdditionalPropertiesToExperiment(fileDetector.AdditionalProperties); + } } } catch (Exception e) @@ -169,6 +174,7 @@ public async Task FinishAsync() var experimentComponents = experiment.ExperimentGroupComponents; var controlDetectors = experiment.ControlDetectors; var experimentDetectors = experiment.ExperimentalDetectors; + var additionalProperties = experiment.AdditionalProperties; this.logger.LogInformation( "Experiment {Experiment} finished with {ControlCount} components in the control group and {ExperimentCount} components in the experiment group", config.Name, @@ -185,7 +191,7 @@ public async Task FinishAsync() try { - var diff = new ExperimentDiff(controlComponents, experimentComponents, controlDetectors, experimentDetectors); + var diff = new ExperimentDiff(controlComponents, experimentComponents, controlDetectors, experimentDetectors, additionalProperties); var tasks = this.experimentProcessors.Select(x => x.ProcessExperimentAsync(config, diff)); await Task.WhenAll(tasks); } diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentDiff.cs b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentDiff.cs index 5ac0b8843..1896d3764 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentDiff.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentDiff.cs @@ -17,14 +17,18 @@ public class ExperimentDiff /// A set of components from the experimental group. /// The set of control detectors. /// The set of experimental detectors. + /// The set of additional metrics to be captured. public ExperimentDiff( IEnumerable controlGroupComponents, IEnumerable experimentGroupComponents, IEnumerable<(string DetectorId, TimeSpan DetectorRunTime)> controlDetectors = null, - IEnumerable<(string DetectorId, TimeSpan DetectorRunTime)> experimentalDetectors = null) + IEnumerable<(string DetectorId, TimeSpan DetectorRunTime)> experimentalDetectors = null, + IEnumerable<(string PropertyKey, string PropertyValue)> additionalProperties = null) { var oldComponentDictionary = controlGroupComponents.DistinctBy(x => x.Id).ToDictionary(x => x.Id); var newComponentDictionary = experimentGroupComponents.DistinctBy(x => x.Id).ToDictionary(x => x.Id); + additionalProperties ??= Array.Empty<(string PropertyKey, string PropertyValue)>(); + this.AdditionalProperties = additionalProperties?.Select(kv => new KeyValuePair(kv.PropertyKey, kv.PropertyValue)).ToImmutableList(); this.AddedIds = newComponentDictionary.Keys.Except(oldComponentDictionary.Keys).ToImmutableList(); this.RemovedIds = oldComponentDictionary.Keys.Except(newComponentDictionary.Keys).ToImmutableList(); @@ -132,6 +136,11 @@ public ExperimentDiff() /// public IReadOnlyDictionary> RemovedRootIds { get; init; } + /// + /// Any additional metrics that were captured for the experiment. + /// + public IReadOnlyCollection> AdditionalProperties { get; init; } + /// /// Stores information about a change to the development dependency status of a component. /// diff --git a/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentResults.cs b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentResults.cs index c76f85f92..b3d655f4a 100644 --- a/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentResults.cs +++ b/src/Microsoft.ComponentDetection.Orchestrator/Experiments/Models/ExperimentResults.cs @@ -23,6 +23,8 @@ public class ExperimentResults private readonly ConcurrentDictionary experimentalDetectors = new(); + private readonly ConcurrentBag<(string, string)> additionalProperties = new(); + /// /// The set of components in the control group. /// @@ -47,6 +49,12 @@ public class ExperimentResults public IImmutableSet<(string DetectorId, TimeSpan DetectorRunTime)> ExperimentalDetectors => this.experimentalDetectors.Select(x => (x.Key, x.Value)).ToImmutableHashSet(); + /// + /// The set of experimental detectors. + /// + public IImmutableSet<(string PropertyKey, string PropertyValue)> AdditionalProperties => + this.additionalProperties.ToImmutableHashSet(); + /// /// Adds the components to the control group. /// @@ -77,6 +85,18 @@ public void AddExperimentalDetectorTime(string experimentalDetectorId, TimeSpan public void AddComponentsToExperimentalGroup(IEnumerable components) => AddComponents(this.experimentGroupComponents, components); + /// + /// Adds a custom metric to the experiment. + /// + /// list of (key, value) tuples to be captured as additionalProperties. + public void AddAdditionalPropertiesToExperiment(IEnumerable<(string PropertyKey, string PropertyValue)> properties) + { + foreach (var (propertyKey, propertyValue) in properties) + { + AddAdditionalProperty(this.additionalProperties, propertyKey, propertyValue); + } + } + private static void AddComponents(ConcurrentDictionary group, IEnumerable components) { foreach (var experimentComponent in components.Select(x => new ExperimentComponent(x))) @@ -89,4 +109,9 @@ private static void AddRunTime(ConcurrentDictionary group, str { _ = group.TryAdd(detectorId, runTime); } + + private static void AddAdditionalProperty(ConcurrentBag<(string PropertyKey, string PropertyValue)> group, string propertyKey, string propertyValue) + { + group.Add((propertyKey, propertyValue)); + } } diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs index 38a0d93b0..66c8f7d72 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/RustCliDetectorTests.cs @@ -2,12 +2,12 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Threading.Tasks; using FluentAssertions; -using Microsoft.ComponentDetection.Common.DependencyGraph; +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.TestsUtilities; @@ -20,12 +20,15 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; public class RustCliDetectorTests : BaseDetectorTest { private Mock mockCliService; + private Mock mockComponentStreamEnumerableFactory; [TestInitialize] public void InitCliMock() { this.mockCliService = new Mock(); this.DetectorTestUtility.AddServiceMock(this.mockCliService); + this.mockComponentStreamEnumerableFactory = new Mock(); + this.DetectorTestUtility.AddServiceMock(this.mockComponentStreamEnumerableFactory); } [TestMethod] @@ -692,4 +695,446 @@ public async Task RustCliDetector_AuthorAndLicenseEmptyStringAsync() } } } + + [TestMethod] + public async Task RustCliDetector_FallBackLogicTriggeredOnVirtualManifestAsync() + { + var cargoMetadata = @" +{ + ""packages"": [], + ""workspace_members"": [ + ""rust-test 0.1.0 (path+file:///home/justin/rust-test)"" + ], + ""resolve"": { + ""nodes"": [ + { + ""id"": ""libc 0.2.147 (registry+https://github.com/rust-lang/crates.io-index)"", + ""dependencies"": [], + ""deps"": [], + ""features"": [ + ""default"", + ""std"" + ] + }, + { + ""id"": ""rust-test 0.1.0 (path+file:///home/justin/rust-test)"", + ""dependencies"": [ + ""libc 0.2.147 (registry+https://github.com/rust-lang/crates.io-index)"" + ], + ""deps"": [ + { + ""name"": ""libc"", + ""pkg"": ""libc 0.2.147 (registry+https://github.com/rust-lang/crates.io-index)"", + ""dep_kinds"": [ + { + ""kind"": null, + ""target"": null + } + ] + } + ], + ""features"": [] + } + ], + ""root"": null + }, + ""packages"": [ + { + ""name"": ""libc"", + ""version"": ""0.2.147"", + ""id"": ""libc 0.2.147 (registry+https://github.com/rust-lang/crates.io-index)"", + ""license"": """", + ""license_file"": null, + ""description"": """", + ""source"": ""registry+https://github.com/rust-lang/crates.io-index"", + ""dependencies"": [], + ""targets"": [], + ""features"": {}, + ""manifest_path"": """", + ""metadata"": {}, + ""publish"": null, + ""authors"": [ + """" + ], + ""categories"": [], + ""keywords"": [], + ""readme"": ""README.md"", + ""repository"": ""https://github.com/tkaitchuck/ahash"", + ""homepage"": null, + ""documentation"": """", + ""edition"": ""00"", + ""links"": null, + ""default_run"": null, + ""rust_version"": null + }, + { + ""name"": ""rust-test"", + ""version"": ""0.1.0"", + ""id"": ""rust-test (registry+https://github.com/rust-lang/crates.io-index)"", + ""license"": """", + ""license_file"": null, + ""description"": ""A non-cryptographic hash function using AES-NI for high performance"", + ""source"": ""registry+https://github.com/rust-lang/crates.io-index"", + ""dependencies"": [], + ""targets"": [], + ""features"": {}, + ""manifest_path"": """", + ""metadata"": {}, + ""publish"": null, + ""authors"": [ + """" + ], + ""categories"": [], + ""keywords"": [], + ""readme"": ""README.md"", + ""repository"": ""https://github.com/tkaitchuck/ahash"", + ""homepage"": null, + ""documentation"": """", + ""edition"": ""000"", + ""links"": null, + ""default_run"": null, + ""rust_version"": null + } +], + ""target_directory"": ""/home/justin/rust-test/target"", + ""version"": 1, + ""workspace_root"": ""/home/justin/rust-test"", + ""metadata"": null +}"; + + var testCargoLockString = @" +[[package]] +name = ""my_dependency"" +version = ""1.0.0"" +source = ""registry+https://github.com/rust-lang/crates.io-index"" +dependencies = [ + ""same_package 1.0.0"" +] + +[[package]] +name = ""other_dependency"" +version = ""0.4.0"" +source = ""registry+https://github.com/rust-lang/crates.io-index"" +dependencies = [ + ""other_dependency_dependency"", +] + +[[package]] +name = ""other_dependency_dependency"" +version = ""0.1.12-alpha.6"" +source = ""registry+https://github.com/rust-lang/crates.io-index"" + +[[package]] +name = ""my_dev_dependency"" +version = ""1.0.0"" +source = ""registry+https://github.com/rust-lang/crates.io-index"" +dependencies = [ + ""other_dependency_dependency 0.1.12-alpha.6 (registry+https://github.com/rust-lang/crates.io-index)"", + ""dev_dependency_dependency 0.2.23 (registry+https://github.com/rust-lang/crates.io-index)"", +]"; + this.mockCliService.Setup(x => x.CanCommandBeLocatedAsync("cargo", It.IsAny>())).ReturnsAsync(true); + this.mockCliService.Setup(x => x.ExecuteCommandAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(new CommandLineExecutionResult { StdOut = cargoMetadata }); + + using var stream = new MemoryStream(); + using var writer = new StreamWriter(stream); + await writer.WriteAsync(testCargoLockString); + await writer.FlushAsync(); + stream.Position = 0; + this.mockComponentStreamEnumerableFactory.Setup(x => x.GetComponentStreams(It.IsAny(), new List { "Cargo.lock" }, It.IsAny(), false)) + .Returns(new[] { new ComponentStream() { Location = "Cargo.toml", Stream = stream } }); + + testCargoLockString = testCargoLockString.Replace("\r", string.Empty); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("Cargo.toml", string.Empty) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + componentRecorder.GetDetectedComponents().Should().HaveCount(4); + + componentRecorder + .GetDetectedComponents() + .Select(x => x.Component.Id) + .Should() + .BeEquivalentTo("other_dependency_dependency 0.1.12-alpha.6 - Cargo", "my_dev_dependency 1.0.0 - Cargo", "my_dependency 1.0.0 - Cargo", "other_dependency 0.4.0 - Cargo"); + + var components = componentRecorder.GetDetectedComponents(); + + foreach (var component in components) + { + if (component.Component is CargoComponent cargoComponent) + { + cargoComponent.Author.Should().Be(null); + cargoComponent.License.Should().Be(null); + } + } + + return; + } + + [TestMethod] + public async Task RustCliDetector_FallBackLogicFailsIfNoCargoLockFoundAsync() + { + var cargoMetadata = @" +{ + ""packages"": [], + ""workspace_members"": [ + ""rust-test 0.1.0 (path+file:///home/justin/rust-test)"" + ], + ""resolve"": { + ""nodes"": [ + { + ""id"": ""libc 0.2.147 (registry+https://github.com/rust-lang/crates.io-index)"", + ""dependencies"": [], + ""deps"": [], + ""features"": [ + ""default"", + ""std"" + ] + }, + { + ""id"": ""rust-test 0.1.0 (path+file:///home/justin/rust-test)"", + ""dependencies"": [ + ""libc 0.2.147 (registry+https://github.com/rust-lang/crates.io-index)"" + ], + ""deps"": [ + { + ""name"": ""libc"", + ""pkg"": ""libc 0.2.147 (registry+https://github.com/rust-lang/crates.io-index)"", + ""dep_kinds"": [ + { + ""kind"": null, + ""target"": null + } + ] + } + ], + ""features"": [] + } + ], + ""root"": null + }, + ""packages"": [ + { + ""name"": ""libc"", + ""version"": ""0.2.147"", + ""id"": ""libc 0.2.147 (registry+https://github.com/rust-lang/crates.io-index)"", + ""license"": """", + ""license_file"": null, + ""description"": """", + ""source"": ""registry+https://github.com/rust-lang/crates.io-index"", + ""dependencies"": [], + ""targets"": [], + ""features"": {}, + ""manifest_path"": """", + ""metadata"": {}, + ""publish"": null, + ""authors"": [ + """" + ], + ""categories"": [], + ""keywords"": [], + ""readme"": ""README.md"", + ""repository"": ""https://github.com/tkaitchuck/ahash"", + ""homepage"": null, + ""documentation"": """", + ""edition"": ""00"", + ""links"": null, + ""default_run"": null, + ""rust_version"": null + }, + { + ""name"": ""rust-test"", + ""version"": ""0.1.0"", + ""id"": ""rust-test (registry+https://github.com/rust-lang/crates.io-index)"", + ""license"": """", + ""license_file"": null, + ""description"": ""A non-cryptographic hash function using AES-NI for high performance"", + ""source"": ""registry+https://github.com/rust-lang/crates.io-index"", + ""dependencies"": [], + ""targets"": [], + ""features"": {}, + ""manifest_path"": """", + ""metadata"": {}, + ""publish"": null, + ""authors"": [ + """" + ], + ""categories"": [], + ""keywords"": [], + ""readme"": ""README.md"", + ""repository"": ""https://github.com/tkaitchuck/ahash"", + ""homepage"": null, + ""documentation"": """", + ""edition"": ""000"", + ""links"": null, + ""default_run"": null, + ""rust_version"": null + } +], + ""target_directory"": ""/home/justin/rust-test/target"", + ""version"": 1, + ""workspace_root"": ""/home/justin/rust-test"", + ""metadata"": null +}"; + + this.mockCliService.Setup(x => x.CanCommandBeLocatedAsync("cargo", It.IsAny>())).ReturnsAsync(true); + this.mockCliService.Setup(x => x.ExecuteCommandAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(new CommandLineExecutionResult { StdOut = cargoMetadata }); + + this.mockComponentStreamEnumerableFactory.Setup(x => x.GetComponentStreams(It.IsAny(), new List { "Cargo.lock" }, It.IsAny(), false)) + .Returns(Enumerable.Empty()); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("Cargo.toml", string.Empty) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + componentRecorder.GetDetectedComponents().Should().HaveCount(0); + } + + [TestMethod] + public async Task RustCliDetector_FallBackLogicTriggeredOnFailedCargoCommandAsync() + { + var testCargoLockString = @" +[[package]] +name = ""my_dependency"" +version = ""1.0.0"" +source = ""registry+https://github.com/rust-lang/crates.io-index"" +dependencies = [ + ""same_package 1.0.0"" +] + +[[package]] +name = ""other_dependency"" +version = ""0.4.0"" +source = ""registry+https://github.com/rust-lang/crates.io-index"" +dependencies = [ + ""other_dependency_dependency"", +] + +[[package]] +name = ""other_dependency_dependency"" +version = ""0.1.12-alpha.6"" +source = ""registry+https://github.com/rust-lang/crates.io-index"" + +[[package]] +name = ""my_dev_dependency"" +version = ""1.0.0"" +source = ""registry+https://github.com/rust-lang/crates.io-index"" +dependencies = [ + ""other_dependency_dependency 0.1.12-alpha.6 (registry+https://github.com/rust-lang/crates.io-index)"", + ""dev_dependency_dependency 0.2.23 (registry+https://github.com/rust-lang/crates.io-index)"", +]"; + this.mockCliService.Setup(x => x.CanCommandBeLocatedAsync("cargo", It.IsAny>())).ReturnsAsync(true); + this.mockCliService.Setup(x => x.ExecuteCommandAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .ReturnsAsync(new CommandLineExecutionResult { StdOut = null, ExitCode = -1 }); + + using var stream = new MemoryStream(); + using var writer = new StreamWriter(stream); + await writer.WriteAsync(testCargoLockString); + await writer.FlushAsync(); + stream.Position = 0; + this.mockComponentStreamEnumerableFactory.Setup(x => x.GetComponentStreams(It.IsAny(), new List { "Cargo.lock" }, It.IsAny(), false)) + .Returns(new[] { new ComponentStream() { Location = "Cargo.toml", Stream = stream } }); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("Cargo.toml", string.Empty) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + componentRecorder.GetDetectedComponents().Should().HaveCount(4); + + componentRecorder + .GetDetectedComponents() + .Select(x => x.Component.Id) + .Should() + .BeEquivalentTo("other_dependency_dependency 0.1.12-alpha.6 - Cargo", "my_dev_dependency 1.0.0 - Cargo", "my_dependency 1.0.0 - Cargo", "other_dependency 0.4.0 - Cargo"); + + var components = componentRecorder.GetDetectedComponents(); + + foreach (var component in components) + { + if (component.Component is CargoComponent cargoComponent) + { + cargoComponent.Author.Should().Be(null); + cargoComponent.License.Should().Be(null); + } + } + + return; + } + + [TestMethod] + public async Task RustCliDetector_FallBackLogicTriggeredOnFailedProcessingAsync() + { + var testCargoLockString = @" +[[package]] +name = ""my_dependency"" +version = ""1.0.0"" +source = ""registry+https://github.com/rust-lang/crates.io-index"" +dependencies = [ + ""same_package 1.0.0"" +] + +[[package]] +name = ""other_dependency"" +version = ""0.4.0"" +source = ""registry+https://github.com/rust-lang/crates.io-index"" +dependencies = [ + ""other_dependency_dependency"", +] + +[[package]] +name = ""other_dependency_dependency"" +version = ""0.1.12-alpha.6"" +source = ""registry+https://github.com/rust-lang/crates.io-index"" + +[[package]] +name = ""my_dev_dependency"" +version = ""1.0.0"" +source = ""registry+https://github.com/rust-lang/crates.io-index"" +dependencies = [ + ""other_dependency_dependency 0.1.12-alpha.6 (registry+https://github.com/rust-lang/crates.io-index)"", + ""dev_dependency_dependency 0.2.23 (registry+https://github.com/rust-lang/crates.io-index)"", +]"; + this.mockCliService.Setup(x => x.CanCommandBeLocatedAsync("cargo", It.IsAny>())).ReturnsAsync(true); + this.mockCliService.Setup(x => x.ExecuteCommandAsync(It.IsAny(), It.IsAny>(), It.IsAny())) + .Throws(new InvalidOperationException()); + + using var stream = new MemoryStream(); + using var writer = new StreamWriter(stream); + await writer.WriteAsync(testCargoLockString); + await writer.FlushAsync(); + stream.Position = 0; + this.mockComponentStreamEnumerableFactory.Setup(x => x.GetComponentStreams(It.IsAny(), new List { "Cargo.lock" }, It.IsAny(), false)) + .Returns(new[] { new ComponentStream() { Location = "Cargo.toml", Stream = stream } }); + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile("Cargo.toml", string.Empty) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + componentRecorder.GetDetectedComponents().Should().HaveCount(4); + + componentRecorder + .GetDetectedComponents() + .Select(x => x.Component.Id) + .Should() + .BeEquivalentTo("other_dependency_dependency 0.1.12-alpha.6 - Cargo", "my_dev_dependency 1.0.0 - Cargo", "my_dependency 1.0.0 - Cargo", "other_dependency 0.4.0 - Cargo"); + + var components = componentRecorder.GetDetectedComponents(); + + foreach (var component in components) + { + if (component.Component is CargoComponent cargoComponent) + { + cargoComponent.Author.Should().Be(null); + cargoComponent.License.Should().Be(null); + } + } + + return; + } }