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}");