Skip to content

Commit 1070dd0

Browse files
committed
fix: resolve version-range bom-ref crash when scanning multi-project solutions
When a package's nuspec declares an exact-range dependency (e.g. [1.0.0]) and the project that consumes it has resolved a higher version of the same package directly, NuGet stores the range string verbatim in project.assets.json. ResolveDependencyVersionRanges cannot resolve it within that project's assets (2.0.0 does not satisfy [1.0.0]), leaving the range unresolved. In a multi-project solution where another project resolves 1.0.0, the merged BOM contains both versions. The name-only fallback in Runner.cs then finds two candidates and crashes with 'Unable to locate valid bom ref for X [1.0.0, 1.0.0]'. Fix: when the name-only fallback finds multiple candidates and dep.Value is a parseable VersionRange, use the range to select the single satisfying candidate before falling back to the error path. Adds a two-project E2E regression test that reproduces the exact topology from the issue report and asserts both the tool succeeds and the dependency edge points to the correct version.
1 parent 46a688d commit 1070dd0

File tree

2 files changed

+60
-11
lines changed

2 files changed

+60
-11
lines changed

CycloneDX.E2ETests/Tests/Issue903Tests.cs

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
// SPDX-License-Identifier: Apache-2.0
1616
// Copyright (c) OWASP Foundation. All Rights Reserved.
1717

18+
using System.Text.RegularExpressions;
1819
using System.Threading.Tasks;
1920
using CycloneDX.E2ETests.Builders;
2021
using CycloneDX.E2ETests.Infrastructure;
@@ -54,21 +55,33 @@ public async Task VersionRangeDependency_MultipleVersionsInSolution_ShouldSuccee
5455
{
5556
// Reproduces the exact topology reported in PR #903 / issue comment by @ilehtoranta:
5657
//
57-
// ProjectA — directly references TestPkg.Shared 2.0.0
58+
// ProjectB — directly references TestPkg.Shared 1.0.0
59+
// ProjectA — ProjectReference to ProjectB
60+
// — directly references TestPkg.Shared 2.0.0
5861
// — directly references TestPkg.Consumer 1.0.0
5962
// TestPkg.Consumer — depends on TestPkg.Shared [1.0.0, 1.0.0] (exact-range notation)
6063
//
61-
// After restore, ProjectA's project.assets.json contains two targets entries for
62-
// TestPkg.Shared (1.0.0 and 2.0.0) and one dep entry under TestPkg.Consumer that
63-
// reads "[1.0.0, 1.0.0]" instead of "1.0.0".
64+
// After restore:
65+
// ProjectA's assets: NuGet resolves TestPkg.Shared to 2.0.0 (direct ref wins).
66+
// TestPkg.Consumer's dep is stored as "[1.0.0]" in project.assets.json.
67+
// ResolveDependencyVersionRanges cannot resolve it because 2.0.0 does not
68+
// satisfy the exact range [1.0.0]. Range string is left unresolved.
69+
// ProjectB's assets: resolves TestPkg.Shared/1.0.0.
70+
//
71+
// The tool merges both projects' packages into one BOM → both Shared 1.0.0 and 2.0.0
72+
// are present. The name-only fallback in Runner.cs finds two candidates → crash.
6473
//
6574
// Before the fix: "Unable to locate valid bom ref for TestPkg.Shared [1.0.0, 1.0.0]"
6675
// After the fix: tool succeeds and both versions appear in the BOM.
6776
using var solution = await new SolutionBuilder("Issue903Sln")
77+
.AddProject("ProjectB", p => p
78+
.WithTargetFramework("net8.0")
79+
.AddPackage("TestPkg.Shared", "1.0.0"))
6880
.AddProject("ProjectA", p => p
6981
.WithTargetFramework("net8.0")
7082
.AddPackage("TestPkg.Shared", "2.0.0")
71-
.AddPackage("TestPkg.Consumer", "1.0.0"))
83+
.AddPackage("TestPkg.Consumer", "1.0.0")
84+
.AddProjectReference("../ProjectB/ProjectB.csproj"))
7285
.BuildAsync(_fixture.NuGetFeedUrl);
7386

7487
using var outputDir = solution.CreateOutputDir();
@@ -86,13 +99,28 @@ public async Task VersionRangeDependency_MultipleVersionsInSolution_ShouldSuccee
8699
Assert.True(result.Success,
87100
$"Tool failed with exit code {result.ExitCode}.\nstderr:\n{result.StdErr}\nstdout:\n{result.StdOut}");
88101

89-
// Both versions of the shared package must be present in the BOM
90-
Assert.Contains("TestPkg.Shared", result.BomContent);
91-
Assert.Contains("1.0.0", result.BomContent);
92-
Assert.Contains("2.0.0", result.BomContent);
102+
// Both versions of the shared package must appear as distinct components in the BOM.
103+
// Scanning a solution is an explicit union of all projects — duplicate versions of the
104+
// same package across projects are expected and correct.
105+
Assert.Contains("pkg:nuget/TestPkg.Shared@1.0.0", result.BomContent);
106+
Assert.Contains("pkg:nuget/TestPkg.Shared@2.0.0", result.BomContent);
107+
Assert.Contains("pkg:nuget/TestPkg.Consumer@1.0.0", result.BomContent);
108+
109+
// The critical correctness check: TestPkg.Consumer's dependency edge must point to
110+
// TestPkg.Shared 1.0.0 (what its nuspec declares), not 2.0.0.
111+
// In the XML the dependencies section looks like:
112+
// <dependency ref="pkg:nuget/TestPkg.Consumer@1.0.0">
113+
// <dependency ref="pkg:nuget/TestPkg.Shared@1.0.0"/>
114+
// </dependency>
115+
var consumerDepBlock = Regex.Match(
116+
result.BomContent,
117+
@"<dependency ref=""pkg:nuget/TestPkg\.Consumer@1\.0\.0"">(.*?)</dependency>",
118+
RegexOptions.Singleline).Value;
93119

94-
// The consumer package must be present
95-
Assert.Contains("TestPkg.Consumer", result.BomContent);
120+
Assert.False(string.IsNullOrEmpty(consumerDepBlock),
121+
"No dependency block found for TestPkg.Consumer@1.0.0");
122+
Assert.Contains("pkg:nuget/TestPkg.Shared@1.0.0", consumerDepBlock);
123+
Assert.DoesNotContain("pkg:nuget/TestPkg.Shared@2.0.0", consumerDepBlock);
96124
}
97125
}
98126
}

CycloneDX/Runner.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
using CycloneDX.Services;
2929
using static CycloneDX.Models.Component;
3030
using Json.Schema;
31+
using NuGet.Versioning;
3132

3233
namespace CycloneDX
3334
{
@@ -363,8 +364,28 @@ public async Task<int> HandleCommandAsync(RunOptions options)
363364
}
364365
else
365366
{
367+
// dep.Value may be a version range (e.g. "[1.0.0]") when NuGet
368+
// stored the nuspec constraint verbatim and ResolveDependencyVersionRanges
369+
// could not resolve it within this project's assets (because the
370+
// satisfying version only exists in another project's assets).
371+
// Try to pick the right candidate using the range itself.
372+
if (packageNameMatch.Count > 1
373+
&& VersionRange.TryParse(dep.Value, out var versionRange))
374+
{
375+
var rangeMatch = packageNameMatch
376+
.Where(x => NuGetVersion.TryParse(x.Key.Item2, out var v)
377+
&& versionRange.Satisfies(v))
378+
.ToList();
379+
if (rangeMatch.Count == 1)
380+
{
381+
lookupKey = rangeMatch[0].Key;
382+
goto found;
383+
}
384+
}
385+
366386
Console.Error.WriteLine($"Unable to locate valid bom ref for {dep.Key} {dep.Value}");
367387
return (int)ExitCode.UnableToLocateDependencyBomRef;
388+
found:;
368389
}
369390
}
370391

0 commit comments

Comments
 (0)