diff --git a/CHANGELOG.md b/CHANGELOG.md index 65fb8850..5d304ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Fix crash when a nuspec declares an exact-range version constraint across multiple projects** (#1071) — when a package's nuspec dependency uses an exact version range (e.g. `[1.0.0]`) and multiple versions of that package are present in a multi-project solution, the tool no longer crashes with "Unable to locate valid bom ref"; the dependency edge is resolved to the version that satisfies the range + ## [6.1.0] ### Added diff --git a/CycloneDX.E2ETests/Infrastructure/NuGetServerFixture.cs b/CycloneDX.E2ETests/Infrastructure/NuGetServerFixture.cs index 04a7e72e..3e1578dd 100644 --- a/CycloneDX.E2ETests/Infrastructure/NuGetServerFixture.cs +++ b/CycloneDX.E2ETests/Infrastructure/NuGetServerFixture.cs @@ -112,6 +112,23 @@ await PushPackageAsync(NupkgBuilder.Build( // TestPkg.Transitive 1.0.0 — used only as a transitive dep await PushPackageAsync(NupkgBuilder.Build("TestPkg.Transitive", "1.0.0")).ConfigureAwait(false); + + // --- PR #903 / version-range bom-ref scenario --- + // TestPkg.Shared 1.0.0 — low-level package referenced via exact range [1.0.0, 1.0.0] + await PushPackageAsync(NupkgBuilder.Build("TestPkg.Shared", "1.0.0")).ConfigureAwait(false); + + // TestPkg.Shared 2.0.0 — a second version of the same package, directly referenced by + // another project in the solution (creates ambiguity for the name-only fallback) + await PushPackageAsync(NupkgBuilder.Build("TestPkg.Shared", "2.0.0")).ConfigureAwait(false); + + // TestPkg.Consumer 1.0.0 — declares its dep on TestPkg.Shared using exact-range notation + // ([1.0.0, 1.0.0]) as dotnet pack emits for packages with a fixed lower/upper bound. + // When NuGet resolves this alongside TestPkg.Shared 2.0.0 in another project, the + // project.assets.json stores the dep as "[1.0.0, 1.0.0]" which the tool must resolve. + await PushPackageAsync(NupkgBuilder.Build( + "TestPkg.Consumer", "1.0.0", + dependencies: new[] { new NupkgDependency("TestPkg.Shared", "[1.0.0, 1.0.0]") } + )).ConfigureAwait(false); } public async ValueTask DisposeAsync() diff --git a/CycloneDX.E2ETests/Tests/Issue903Tests.cs b/CycloneDX.E2ETests/Tests/Issue903Tests.cs new file mode 100644 index 00000000..50698d8c --- /dev/null +++ b/CycloneDX.E2ETests/Tests/Issue903Tests.cs @@ -0,0 +1,126 @@ +// This file is part of CycloneDX Tool for .NET +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) OWASP Foundation. All Rights Reserved. + +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using CycloneDX.E2ETests.Builders; +using CycloneDX.E2ETests.Infrastructure; +using Xunit; + +namespace CycloneDX.E2ETests.Tests +{ + /// + /// Regression tests for issue #903: + /// "Unable to locate valid bom ref for <package> [x.y.z, x.y.z]" when scanning a + /// solution that contains multiple projects referencing the same package at different versions. + /// + /// Root cause: NuGet stores a dependency's version constraint verbatim from the .nuspec into + /// project.assets.json. When a package declares its dep with exact-range notation + /// (e.g. "[1.0.0, 1.0.0]"), the version string stored in the lock file is a range, not a + /// plain version. Runner.cs builds its bomRefLookup keyed on plain versions only, so the + /// range string misses on the first lookup attempt. The name-only fallback at that point + /// succeeds only when exactly one version of the package is present in the BOM — but in a + /// multi-project solution a second version of the same package may be directly referenced, + /// giving two candidates and causing the error. + /// + /// The fix must resolve the range "[1.0.0, 1.0.0]" to the concrete version "1.0.0" and + /// look that up successfully even when another project directly pins "2.0.0". + /// + [Collection("E2E")] + public sealed class Issue903Tests + { + private readonly E2EFixture _fixture; + + public Issue903Tests(E2EFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task VersionRangeDependency_MultipleVersionsInSolution_ShouldSucceed() + { + // Reproduces the exact topology reported in PR #903 / issue comment by @ilehtoranta: + // + // ProjectB — directly references TestPkg.Shared 1.0.0 + // ProjectA — ProjectReference to ProjectB + // — directly references TestPkg.Shared 2.0.0 + // — directly references TestPkg.Consumer 1.0.0 + // TestPkg.Consumer — depends on TestPkg.Shared [1.0.0, 1.0.0] (exact-range notation) + // + // After restore: + // ProjectA's assets: NuGet resolves TestPkg.Shared to 2.0.0 (direct ref wins). + // TestPkg.Consumer's dep is stored as "[1.0.0]" in project.assets.json. + // ResolveDependencyVersionRanges cannot resolve it because 2.0.0 does not + // satisfy the exact range [1.0.0]. Range string is left unresolved. + // ProjectB's assets: resolves TestPkg.Shared/1.0.0. + // + // The tool merges both projects' packages into one BOM → both Shared 1.0.0 and 2.0.0 + // are present. The name-only fallback in Runner.cs finds two candidates → crash. + // + // Before the fix: "Unable to locate valid bom ref for TestPkg.Shared [1.0.0, 1.0.0]" + // After the fix: tool succeeds and both versions appear in the BOM. + using var solution = await new SolutionBuilder("Issue903Sln") + .AddProject("ProjectB", p => p + .WithTargetFramework("net8.0") + .AddPackage("TestPkg.Shared", "1.0.0")) + .AddProject("ProjectA", p => p + .WithTargetFramework("net8.0") + .AddPackage("TestPkg.Shared", "2.0.0") + .AddPackage("TestPkg.Consumer", "1.0.0") + .AddProjectReference("../ProjectB/ProjectB.csproj")) + .BuildAsync(_fixture.NuGetFeedUrl); + + using var outputDir = solution.CreateOutputDir(); + + var result = await _fixture.Runner.RunAsync( + solution.SolutionFile, + outputDir.Path, + new ToolRunOptions + { + NuGetFeedUrl = _fixture.NuGetFeedUrl, + NoSerialNumber = true, + DisableHashComputation = true, + }); + + Assert.True(result.Success, + $"Tool failed with exit code {result.ExitCode}.\nstderr:\n{result.StdErr}\nstdout:\n{result.StdOut}"); + + // Both versions of the shared package must appear as distinct components in the BOM. + // Scanning a solution is an explicit union of all projects — duplicate versions of the + // same package across projects are expected and correct. + Assert.Contains("pkg:nuget/TestPkg.Shared@1.0.0", result.BomContent); + Assert.Contains("pkg:nuget/TestPkg.Shared@2.0.0", result.BomContent); + Assert.Contains("pkg:nuget/TestPkg.Consumer@1.0.0", result.BomContent); + + // The critical correctness check: TestPkg.Consumer's dependency edge must point to + // TestPkg.Shared 1.0.0 (what its nuspec declares), not 2.0.0. + // In the XML the dependencies section looks like: + // + // + // + var consumerDepBlock = Regex.Match( + result.BomContent, + @"(.*?)", + RegexOptions.Singleline).Value; + + Assert.False(string.IsNullOrEmpty(consumerDepBlock), + "No dependency block found for TestPkg.Consumer@1.0.0"); + Assert.Contains("pkg:nuget/TestPkg.Shared@1.0.0", consumerDepBlock); + Assert.DoesNotContain("pkg:nuget/TestPkg.Shared@2.0.0", consumerDepBlock); + } + } +} diff --git a/CycloneDX/Runner.cs b/CycloneDX/Runner.cs index a0fd4889..948e1214 100644 --- a/CycloneDX/Runner.cs +++ b/CycloneDX/Runner.cs @@ -28,6 +28,7 @@ using CycloneDX.Services; using static CycloneDX.Models.Component; using Json.Schema; +using NuGet.Versioning; namespace CycloneDX { @@ -361,6 +362,23 @@ public async Task HandleCommandAsync(RunOptions options) { lookupKey = packageNameMatch.First().Key; } + else if (packageNameMatch.Count > 1 + && VersionRange.TryParse(dep.Value, out var versionRange)) + { + // dep.Value is a version range stored verbatim from the nuspec (e.g. "[1.0.0]"). + // ResolveDependencyVersionRanges couldn't resolve it within this project's + // assets because the satisfying version only exists in another project's assets. + // Use the range to pick the correct candidate from the merged BOM. + var rangeMatch = packageNameMatch.SingleOrDefault( + x => NuGetVersion.TryParse(x.Key.Item2, out var v) && versionRange.Satisfies(v)); + if (rangeMatch.Key != default) + lookupKey = rangeMatch.Key; + else + { + Console.Error.WriteLine($"Unable to locate valid bom ref for {dep.Key} {dep.Value}"); + return (int)ExitCode.UnableToLocateDependencyBomRef; + } + } else { Console.Error.WriteLine($"Unable to locate valid bom ref for {dep.Key} {dep.Value}");