Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions CycloneDX.E2ETests/Infrastructure/NuGetServerFixture.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
126 changes: 126 additions & 0 deletions CycloneDX.E2ETests/Tests/Issue903Tests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Regression tests for issue #903:
/// "Unable to locate valid bom ref for &lt;package&gt; [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".
/// </summary>
[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:
// <dependency ref="pkg:nuget/TestPkg.Consumer@1.0.0">
// <dependency ref="pkg:nuget/TestPkg.Shared@1.0.0"/>
// </dependency>
var consumerDepBlock = Regex.Match(
result.BomContent,
@"<dependency ref=""pkg:nuget/TestPkg\.Consumer@1\.0\.0"">(.*?)</dependency>",
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);
}
}
}
18 changes: 18 additions & 0 deletions CycloneDX/Runner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
using CycloneDX.Services;
using static CycloneDX.Models.Component;
using Json.Schema;
using NuGet.Versioning;

namespace CycloneDX
{
Expand Down Expand Up @@ -361,6 +362,23 @@ public async Task<int> 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}");
Expand Down
Loading