diff --git a/src/Microsoft.ComponentDetection.Detectors/vcpkg/Contracts/ManifestInfo.cs b/src/Microsoft.ComponentDetection.Detectors/vcpkg/Contracts/ManifestInfo.cs new file mode 100644 index 000000000..b5512c1b0 --- /dev/null +++ b/src/Microsoft.ComponentDetection.Detectors/vcpkg/Contracts/ManifestInfo.cs @@ -0,0 +1,9 @@ +namespace Microsoft.ComponentDetection.Detectors.Vcpkg.Contracts; + +using Newtonsoft.Json; + +public class ManifestInfo +{ + [JsonProperty("manifest-path")] + public string ManifestPath { get; set; } +} diff --git a/src/Microsoft.ComponentDetection.Detectors/vcpkg/VcpkgComponentDetector.cs b/src/Microsoft.ComponentDetection.Detectors/vcpkg/VcpkgComponentDetector.cs index 393e10879..96988f925 100644 --- a/src/Microsoft.ComponentDetection.Detectors/vcpkg/VcpkgComponentDetector.cs +++ b/src/Microsoft.ComponentDetection.Detectors/vcpkg/VcpkgComponentDetector.cs @@ -1,9 +1,11 @@ namespace Microsoft.ComponentDetection.Detectors.Vcpkg; using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reactive.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.ComponentDetection.Contracts; @@ -15,7 +17,11 @@ namespace Microsoft.ComponentDetection.Detectors.Vcpkg; public class VcpkgComponentDetector : FileComponentDetector { + private const string VcpkgInstalledFolder = "vcpkg_installed"; + private const string ManifestInfoFile = "manifest-info.json"; + private readonly HashSet projectRoots = []; + private readonly ConcurrentDictionary manifestMappings = new(StringComparer.OrdinalIgnoreCase); private readonly ICommandLineInvocationService commandLineInvocationService; private readonly IEnvironmentVariableService envVarService; @@ -38,11 +44,11 @@ public VcpkgComponentDetector( public override IEnumerable Categories => [Enum.GetName(typeof(DetectorClass), DetectorClass.Vcpkg)]; - public override IList SearchPatterns { get; } = ["vcpkg.spdx.json"]; + public override IList SearchPatterns { get; } = ["vcpkg.spdx.json", ManifestInfoFile]; public override IEnumerable SupportedComponentTypes { get; } = [ComponentType.Vcpkg]; - public override int Version => 2; + public override int Version => 3; protected override async Task OnFileFoundAsync(ProcessRequest processRequest, IDictionary detectorArgs, CancellationToken cancellationToken = default) { @@ -57,7 +63,44 @@ protected override async Task OnFileFoundAsync(ProcessRequest processRequest, ID return; } - await this.ParseSpdxFileAsync(singleFileComponentRecorder, file); + await this.ParseSpdxFileAsync(this.GetManifestComponentRecorder(singleFileComponentRecorder), file); + } + + protected override async Task> OnPrepareDetectionAsync(IObservable processRequests, IDictionary detectorArgs, CancellationToken cancellationToken = default) + { + var filteredProcessRequests = new List(); + + await processRequests.ForEachAsync(async pr => + { + var fileLocation = pr.ComponentStream.Location; + var fileName = Path.GetFileName(fileLocation); + + if (fileName.Equals(ManifestInfoFile, StringComparison.OrdinalIgnoreCase)) + { + this.Logger.LogDebug("Discovered VCPKG package manifest file at: {Location}", pr.ComponentStream.Location); + + using (var reader = new StreamReader(pr.ComponentStream.Stream)) + { + var contents = await reader.ReadToEndAsync().ConfigureAwait(false); + var manifestData = JsonConvert.DeserializeObject(contents); + + if (manifestData == null || string.IsNullOrWhiteSpace(manifestData.ManifestPath)) + { + this.Logger.LogDebug("Failed to deserialize manifest-info.json or missing ManifestPath at {Path}", pr.ComponentStream.Location); + } + else + { + this.manifestMappings.TryAdd(fileLocation, manifestData.ManifestPath); + } + } + } + else + { + filteredProcessRequests.Add(pr); + } + }).ConfigureAwait(false); + + return filteredProcessRequests.ToObservable(); } private async Task ParseSpdxFileAsync( @@ -123,4 +166,64 @@ private async Task ParseSpdxFileAsync( } } } + + /// + /// Attempts to resolve and return a manifest component recorder for the given recorder. + /// Returns the matching manifest component recorder if found; otherwise, returns the original recorder. + /// + private ISingleFileComponentRecorder GetManifestComponentRecorder(ISingleFileComponentRecorder singleFileComponentRecorder) + { + try + { + var manifestFileLocation = singleFileComponentRecorder.ManifestFileLocation; + + var vcpkgInstalledIndex = manifestFileLocation.IndexOf(VcpkgInstalledFolder, StringComparison.OrdinalIgnoreCase); + if (vcpkgInstalledIndex < 0) + { + this.Logger.LogDebug( + "Could not find '{VcpkgInstalled}' in ManifestFileLocation: '{ManifestFileLocation}'. Returning original recorder.", + VcpkgInstalledFolder, + manifestFileLocation); + + return singleFileComponentRecorder; + } + + var vcpkgInstalledDir = manifestFileLocation[..(vcpkgInstalledIndex + VcpkgInstalledFolder.Length)]; + + var preferredManifest = Path.Combine(vcpkgInstalledDir, "vcpkg", ManifestInfoFile); + var fallbackManifest = Path.Combine(vcpkgInstalledDir, ManifestInfoFile); + + // Try preferred location first + if (this.manifestMappings.TryGetValue(preferredManifest, out var manifestPath) && manifestPath != null) + { + return this.ComponentRecorder.CreateSingleFileComponentRecorder(manifestPath); + } + else if (this.manifestMappings.TryGetValue(fallbackManifest, out manifestPath) && manifestPath != null) + { + // Use the fallback location. + this.Logger.LogDebug( + "Preferred manifest at '{PreferredManifest}' was not found or invalid. Using fallback manifest at '{FallbackManifest}'.", + preferredManifest, + fallbackManifest); + + return this.ComponentRecorder.CreateSingleFileComponentRecorder(manifestPath); + } + + this.Logger.LogDebug( + "No valid manifest-info.json found at either '{PreferredManifest}' or '{FallbackManifest}' for base location '{VcpkgInstalledDir}'. Returning original recorder.", + preferredManifest, + fallbackManifest, + vcpkgInstalledDir); + } + catch (Exception ex) + { + this.Logger.LogWarning( + ex, + "An exception occurred while resolving manifest component recorder for '{ManifestFileLocation}'. Returning original recorder.", + singleFileComponentRecorder.ManifestFileLocation); + } + + // Always return the original recorder if no manifest is found or on error + return singleFileComponentRecorder; + } } diff --git a/test/Microsoft.ComponentDetection.Detectors.Tests/VcpkgComponentDetectorTests.cs b/test/Microsoft.ComponentDetection.Detectors.Tests/VcpkgComponentDetectorTests.cs index f149aec1b..246a56132 100644 --- a/test/Microsoft.ComponentDetection.Detectors.Tests/VcpkgComponentDetectorTests.cs +++ b/test/Microsoft.ComponentDetection.Detectors.Tests/VcpkgComponentDetectorTests.cs @@ -1,5 +1,6 @@ namespace Microsoft.ComponentDetection.Detectors.Tests; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -177,4 +178,60 @@ public async Task TestInvalidFileAsync() var components = detectedComponents.ToList(); components.Should().BeEmpty(); } + + [TestMethod] + [DataTestMethod] + [DataRow("vcpkg_installed\\manifest-info.json", "vcpkg.json")] + [DataRow("vcpkg_installed\\vcpkg\\manifest-info.json", "vcpkg.json")] + [DataRow("bad_location\\manifest-info.json", "vcpkg_installed\\packageLocation\\vcpkg.spdx.json")] + public async Task TestVcpkgManifestFileAsync(string manifestPath, string pathToVcpkg) + { + var t_pathToVcpkg = CrossPlatformPath(Path.GetFullPath(pathToVcpkg)); + var t_manifestPath = CrossPlatformPath(Path.GetFullPath(manifestPath)); + + var spdxFile = @"{ + ""SPDXID"": ""SPDXRef - DOCUMENT"", + ""documentNamespace"": + ""https://spdx.org/spdxdocs/nlohmann-json-x64-linux-3.10.4-78c7f190-b402-44d1-a364-b9ac86392b84"", + ""name"": ""nlohmann-json:x64-linux@3.10.4 69dcfc6886529ad2d210f71f132d743672a7e65d2c39f53456f17fc5fc08b278"", + ""packages"": [ + { + ""name"": ""nlohmann-json"", + ""SPDXID"": ""SPDXRef-port"", + ""versionInfo"": ""3.10.4#5"", + ""downloadLocation"": ""git+https://github.com/Microsoft/vcpkg#ports/nlohmann-json"", + ""homepage"": ""https://github.com/nlohmann/json"", + ""licenseConcluded"": ""NOASSERTION"", + ""licenseDeclared"": ""NOASSERTION"", + ""copyrightText"": ""NOASSERTION"", + ""description"": ""JSON for Modern C++"", + ""comment"": ""This is the port (recipe) consumed by vcpkg."" + } + ] +}"; + var manifestFile = $@"{{ + ""manifest-path"": ""{t_pathToVcpkg.Replace("\\", "\\\\")}"" +}}"; + + var (scanResult, componentRecorder) = await this.DetectorTestUtility + .WithFile(CrossPlatformPath(Path.GetFullPath("vcpkg_installed\\packageLocation\\vcpkg.spdx.json")), spdxFile) + .WithFile(t_manifestPath, manifestFile) + .ExecuteDetectorAsync(); + + scanResult.ResultCode.Should().Be(ProcessingResultCode.Success); + + var detectedComponents = componentRecorder.GetDependencyGraphsByLocation(); + + var singleFileComponent = detectedComponents.FirstOrDefault(); + singleFileComponent.Should().NotBeNull(); + + var expectedResult = singleFileComponent.Key.Replace("/tmp/", string.Empty); + expectedResult.Should().Be(t_pathToVcpkg); + } + + private static string CrossPlatformPath(string relPath) + { + var segments = relPath.Split(['\\', '/'], StringSplitOptions.RemoveEmptyEntries); + return Path.Combine(segments); + } }