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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **NuGet purl for project reference components** (#1137, fixes #1136) — when `--set-nuget-purl`
is combined with `--include-project-references`, only missing project purls are generated.
Missing or default `<name>@<version>` bom-refs use the available purl; existing purls and
custom bom-refs are preserved. Dependency edges use the final references. Root metadata
and NuGet package identifier behavior is unchanged.

## [6.2.1] - 2026-10-11

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,5 +72,32 @@ public async Task DependencyGraph()
FunctionalTestHelper.AssertHasDependencyWithChild(bom, "project2@1.0.0", "project3@1.0.0");
FunctionalTestHelper.AssertHasDependencyWithChild(bom, "project3@1.0.0", "pkg:nuget/log4net@2.0.15");
}

[Fact]
public async Task DependencyGraphWithNugetPurl()
{
var options = new RunOptions
{
scanProjectReferences = true,
includeProjectReferences = true,
setNugetPurl = true,
SolutionOrProjectFile = MockUnixSupport.Path("c:/project1/project1.csproj")

};

var bom = await FunctionalTestHelper.Test(options, getMockFS());

foreach (var name in new[] { "project2", "project3" })
{
var project = Assert.Single(bom.Components, c => c.Name == name);
Assert.Equal($"pkg:nuget/{name}@1.0.0", project.Purl);
Assert.Equal(project.Purl, project.BomRef);
}
Assert.Equal(3, bom.Components.Count);
Assert.Equal(4, bom.Dependencies.Count);
FunctionalTestHelper.AssertHasDependencyWithChild(bom, "pkg:nuget/project1@0.0.0", "pkg:nuget/project2@1.0.0");
FunctionalTestHelper.AssertHasDependencyWithChild(bom, "pkg:nuget/project2@1.0.0", "pkg:nuget/project3@1.0.0");
FunctionalTestHelper.AssertHasDependencyWithChild(bom, "pkg:nuget/project3@1.0.0", "pkg:nuget/log4net@2.0.15");
}
}
}
155 changes: 155 additions & 0 deletions CycloneDX.Tests/FunctionalTests/ProjectReferenceNugetPurlTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading.Tasks;
using CycloneDX.Interfaces;
using CycloneDX.Models;
using CycloneDX.Services;
using Moq;
using Xunit;

namespace CycloneDX.Tests.FunctionalTests
{
public class ProjectReferenceNugetPurlTests
{
private const string DefaultRef = "ClassLibrary1@1.0.0";
private const string GeneratedPurl = "pkg:nuget/ClassLibrary1@1.0.0";
// Deliberately different from the guessed NuGet identity.
private const string ExistingPurl = "pkg:nuget/Published.Library@2.3.4";
private const string CustomRef = "urn:project:class-library";

[Theory]
[InlineData(ExistingPurl, CustomRef, ExistingPurl, CustomRef)]
[InlineData(ExistingPurl, DefaultRef, ExistingPurl, ExistingPurl)]
[InlineData(ExistingPurl, "", ExistingPurl, ExistingPurl)]
[InlineData(ExistingPurl, null, ExistingPurl, ExistingPurl)]
[InlineData(null, DefaultRef, GeneratedPurl, GeneratedPurl)]
[InlineData("", DefaultRef, GeneratedPurl, GeneratedPurl)]
[InlineData(null, "", GeneratedPurl, GeneratedPurl)]
[InlineData(null, null, GeneratedPurl, GeneratedPurl)]
[InlineData(null, CustomRef, GeneratedPurl, CustomRef)]
[InlineData("", CustomRef, GeneratedPurl, CustomRef)]
public async Task BothFlags_PreserveOrGenerateIdentifiersAndWireGraph(
string purl, string bomRef, string expectedPurl, string expectedRef)
{
var bom = await RunWithProjectComponent(purl, bomRef, true, true);

var project = Assert.Single(bom.Components, c => c.Name == "ClassLibrary1");
Assert.Equal(expectedPurl, project.Purl);
Assert.Equal(expectedRef, project.BomRef);
AssertProjectGraph(bom, expectedRef);
}

[Theory]
[InlineData(null, DefaultRef)]
[InlineData(ExistingPurl, DefaultRef)]
[InlineData(ExistingPurl, CustomRef)]
public async Task IncludeOnly_DoesNotRewriteIdentifiers(string purl, string bomRef)
{
var bom = await RunWithProjectComponent(purl, bomRef, true, false);

var project = Assert.Single(bom.Components, c => c.Name == "ClassLibrary1");
Assert.Equal(purl, project.Purl);
Assert.Equal(bomRef, project.BomRef);
Assert.Equal("Project@0.0.0", bom.Metadata.Component.BomRef);
AssertProjectGraph(bom, bomRef);
}

[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task WithoutInclude_ProjectNodesAreExcludedAndPackagesFlattened(bool setNugetPurl)
{
var bom = await RunWithProjectComponent(ExistingPurl, CustomRef, false, setNugetPurl);

Assert.Equal(setNugetPurl ? "pkg:nuget/Project@0.0.0" : "Project@0.0.0",
bom.Metadata.Component.BomRef);
Assert.Equal(3, bom.Components.Count);
Assert.DoesNotContain(bom.Components, c => c.Name == "ClassLibrary1");
var root = Assert.Single(bom.Dependencies, d => d.Ref == bom.Metadata.Component.BomRef);
Assert.Equal(new[] { "pkg:nuget/Moq@4.20.70" }, root.Dependencies.Select(d => d.Ref));
AssertPackageGraph(bom);
AssertGraphReferencesResolve(bom);
}

private static void AssertProjectGraph(Bom bom, string projectRef)
{
Assert.Equal(4, bom.Components.Count);
var root = Assert.Single(bom.Dependencies, d => d.Ref == bom.Metadata.Component.BomRef);
Assert.Equal(new[] { projectRef }, root.Dependencies.Select(d => d.Ref));
var project = Assert.Single(bom.Dependencies, d => d.Ref == projectRef);
Assert.Equal(new[] { "pkg:nuget/Moq@4.20.70" }, project.Dependencies.Select(d => d.Ref));
AssertPackageGraph(bom);
AssertGraphReferencesResolve(bom);
}

private static void AssertPackageGraph(Bom bom)
{
var moq = Assert.Single(bom.Dependencies, d => d.Ref == "pkg:nuget/Moq@4.20.70");
Assert.Equal(new[] { "pkg:nuget/Castle.Core@5.1.1" }, moq.Dependencies.Select(d => d.Ref));
var castle = Assert.Single(bom.Dependencies, d => d.Ref == "pkg:nuget/Castle.Core@5.1.1");
Assert.Equal(new[] { "pkg:nuget/System.Diagnostics.EventLog@6.0.0" }, castle.Dependencies.Select(d => d.Ref));
var eventLog = Assert.Single(bom.Dependencies, d => d.Ref == "pkg:nuget/System.Diagnostics.EventLog@6.0.0");
Assert.Empty(eventLog.Dependencies);
}

private static void AssertGraphReferencesResolve(Bom bom)
{
var refs = bom.Components.Select(c => c.BomRef).Append(bom.Metadata.Component.BomRef).ToHashSet();
Assert.Equal(refs.Count, bom.Dependencies.Count);
Assert.All(bom.Dependencies, d =>
{
Assert.Contains(d.Ref, refs);
Assert.All(d.Dependencies, child => Assert.Contains(child.Ref, refs));
});
}

private static async Task<Bom> RunWithProjectComponent(
string purl, string bomRef, bool includeProjectReferences, bool setNugetPurl)
{
var projectPath = MockUnixSupport.Path("c:/ProjectPath/Project.csproj");
var assetsPath = MockUnixSupport.Path("c:/ProjectPath/obj/project.assets.json");
var fileSystem = new MockFileSystem(new Dictionary<string, MockFileData>
{
[projectPath] = new MockFileData(FunctionalTestHelper.CsprojContents),
[assetsPath] = new MockFileData(File.ReadAllText(Path.Combine("FunctionalTests", "TestcaseFiles",
"ProjectReferenceWithPackageReferenceWithTransitivePackage.json")))
});
var assetsService = new ProjectAssetsFileService(fileSystem, () => new AssetFileReader());
var projectService = new Mock<IProjectFileService>();
projectService.Setup(s => s.GetProjectDotnetDependencysAsync(
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<bool>(),
It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>()))
.ReturnsAsync(() => assetsService.GetDotnetDependencys(projectPath, assetsPath, false));
projectService.Setup(s => s.GetComponent(It.IsAny<DotnetDependency>()))
.Returns((DotnetDependency dep) => new Component
{
Name = dep.Name, Version = dep.Version, Type = Component.Classification.Library,
Purl = purl, BomRef = bomRef, Scope = dep.Scope
});
var nugetService = new Mock<INugetService>();
nugetService.Setup(s => s.GetComponentAsync(It.IsAny<DotnetDependency>()))
.ReturnsAsync((DotnetDependency dep) => new Component
{
Name = dep.Name, Version = dep.Version, Type = Component.Classification.Library,
Purl = $"pkg:nuget/{dep.Name}@{dep.Version}",
BomRef = $"pkg:nuget/{dep.Name}@{dep.Version}", Scope = dep.Scope
});
var factory = new Mock<INugetServiceFactory>();
factory.Setup(s => s.Create(It.IsAny<RunOptions>(), It.IsAny<IFileSystem>(),
It.IsAny<IGithubService>(), It.IsAny<List<string>>()))
.Returns(nugetService.Object);
var runner = new Runner(fileSystem, null, null, null, null, projectService.Object, null, factory.Object);
var exitCode = await runner.HandleCommandAsync(new RunOptions
{
SolutionOrProjectFile = projectPath, disablePackageRestore = true,
outputDirectory = "/bom/", outputFormat = OutputFileFormat.Json,
includeProjectReferences = includeProjectReferences, setNugetPurl = setNugetPurl
});
Assert.Equal((int)ExitCode.OK, exitCode);
return runner.LastGeneratedBom;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,34 @@ public async Task ProjectReferenceWithPackageReferenceWithTransitivePackage_incl

FunctionalTestHelper.AssertHasDependencyWithChild(bom, "ClassLibrary1@1.0.0", "pkg:nuget/Moq@4.20.70", "expected dependency not found");
}

[Fact(Timeout = 15000)]
public async Task ProjectReferenceWithPackageReferenceWithTransitivePackage_includeProjectReferences_HasNoPurlByDefault()
{
options.includeProjectReferences = true;

var bom = await FunctionalTestHelper.Test(assetsJson, options);

var classLibrary = Assert.Single(bom.Components, c => c.Name == "ClassLibrary1");
Assert.Null(classLibrary.Purl);
Assert.Equal("ClassLibrary1@1.0.0", classLibrary.BomRef);
FunctionalTestHelper.AssertHasDependencyWithChild(bom, bom.Metadata.Component.BomRef, classLibrary.BomRef);
FunctionalTestHelper.AssertHasDependencyWithChild(bom, classLibrary.BomRef, "pkg:nuget/Moq@4.20.70");
}

[Fact(Timeout = 15000)]
public async Task ProjectReferenceWithPackageReferenceWithTransitivePackage_includeProjectReferences_SetNugetPurl()
{
options.includeProjectReferences = true;
options.setNugetPurl = true;

var bom = await FunctionalTestHelper.Test(assetsJson, options);

var classLibrary = Assert.Single(bom.Components, c => c.Name == "ClassLibrary1");
Assert.Equal("pkg:nuget/ClassLibrary1@1.0.0", classLibrary.Purl);
Assert.Equal("pkg:nuget/ClassLibrary1@1.0.0", classLibrary.BomRef);
FunctionalTestHelper.AssertHasDependencyWithChild(bom, bom.Metadata.Component.BomRef, "pkg:nuget/ClassLibrary1@1.0.0", "expected dependency on project reference not found");
FunctionalTestHelper.AssertHasDependencyWithChild(bom, "pkg:nuget/ClassLibrary1@1.0.0", "pkg:nuget/Moq@4.20.70", "expected dependency of project reference not found");
}
}
}
2 changes: 1 addition & 1 deletion CycloneDX/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ public static async Task<int> Main(string[] args)
var setVersion = new Option<string>("--set-version", "-sv") { Description = "Override the default BOM metadata component version (defaults to 0.0.0)." };
var includeProjectReferences = new Option<bool>("--include-project-references", "-ipr") { Description = "Include project references as components (can only be used with project files)." };
var setType = new Option<Component.Classification>("--set-type", "-st") { Description = "Override the default BOM metadata component type (defaults to application).", DefaultValueFactory = _ => Component.Classification.Application };
var setNugetPurl = new Option<bool>("--set-nuget-purl", null) { Description = "Override the default BOM metadata component bom ref and PURL as NuGet package." };
var setNugetPurl = new Option<bool>("--set-nuget-purl", null) { Description = "Generate missing NuGet PURLs for the metadata component and included project references. Preserve existing PURLs and bom refs, except missing or default project bom refs use the PURL (requires --include-project-references)." };
var excludeFilter = new Option<string>("--exclude-filter", "-ef") { Description = "A comma separated list of dependencies to exclude in form 'name1@version1,name2@version2' or 'name1,name2' (to exclude all versions). Transitive dependencies will also be removed." };
var outputFormat = new Option<OutputFileFormat>("--output-format", "-F") { Description = "Select the BOM output format: auto (default), xml, json, or unsafeJson (relaxed escaping)." };
var specVersion = new Option<string>("--spec-version", "-spv") { Description = $"Which version of CycloneDX spec to use. [default: {SpecificationVersionHelpers.VersionString(SpecificationVersionHelpers.CurrentVersion)}]" };
Expand Down
13 changes: 13 additions & 0 deletions CycloneDX/Runner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,19 @@ public async Task<int> HandleCommandAsync(RunOptions options)
else if (package.DependencyType == DependencyType.Project)
{
component = projectFileService.GetComponent(package);
if (component != null && options.includeProjectReferences && setNugetPurl)
{
if (string.IsNullOrEmpty(component.Purl))
{
component.Purl = Utils.GeneratePackageUrl(component.Name, component.Version);
}
if (!string.IsNullOrEmpty(component.Purl)
&& (string.IsNullOrEmpty(component.BomRef)
|| component.BomRef == $"{component.Name}@{component.Version}"))
{
component.BomRef = component.Purl;
}
}
}

if (component != null)
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Options:
Machine_Learning_Model|Null|Operating_System|Platform>
-ef, --exclude-filter <exclude-filter> A comma separated list of dependencies to exclude in form 'name1@version1,name2@version2' or 'name1,name2' (to exclude all versions). Transitive dependencies will also be removed.
-F, --output-format <Auto|Json|UnsafeJson|Xml> Select the BOM output format: auto (default), xml, json, or unsafeJson (relaxed escaping). [default: Auto]
--set-nuget-purl Override the default BOM metadata component bom ref and PURL as NuGet package.
--set-nuget-purl Generate missing NuGet PURLs for the metadata component and included project references. Preserve existing PURLs and bom refs, except missing or default project bom refs use the PURL (requires --include-project-references).
-spv, --spec-version <1.0|1.1|1.2|1.3|1.4|1.5|1.6|1.7> Which version of CycloneDX spec to use. [default: 1.7]
--version Show version information
-?, -h, --help Show help and usage information
Expand Down
19 changes: 16 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,17 @@ to keep the graph consistent.
Project-type entries are resolved to `Component` objects (Type: Library) using metadata
read directly from the `.csproj`/`.fsproj`/`.vbproj` XML. Version is sourced from
`<Version>`, or `AssemblyVersion` in `AssemblyInfo.cs`, or `"1.0.0"` as fallback. These
components get a `BomRef` but no NuGet PURL. Only valid when the input is a project file.
components normally get a `BomRef` of `<name>@<version>` but no NuGet PURL.
Only valid when the input is a project file.

When both `--include-project-references` and `--set-nuget-purl` are enabled,
each non-null project component gets a generated `pkg:nuget/<name>@<version>` PURL
only if its PURL is null or empty. Existing nonempty PURLs are preserved.
If a PURL is available, it replaces only a null/empty `BomRef` or the default
`<name>@<version>` reference; custom references are preserved. If generation
returns null, the original reference remains unchanged. This happens before
`bomRefLookup` is populated so dependency edges use the final reference.
Neither flag alone changes project identifiers, and NuGet package behavior is unchanged.

---

Expand All @@ -222,8 +232,11 @@ After replacement, `SetMetadataComponentIfNecessary` fills blank fields only:
- `Metadata.Component.Type` ← `Application` if currently `Null`

`--set-name`, `--set-version`, `--set-type` override the above regardless of template content.
`--set-nuget-purl` sets both `Purl` and `BomRef` of the metadata component to
`pkg:nuget/<name>@<version>`.
`--set-nuget-purl` generates `pkg:nuget/<name>@<version>` only when the metadata
component's `Purl` is null or empty, and uses that PURL as `BomRef` only when
`BomRef` is null or empty. Existing metadata PURLs and references (including
`<name>@<version>`) are preserved. The default-project-reference replacement
rule above does not apply to the root metadata component.

`Metadata.Timestamp` defaults to `DateTime.UtcNow` if not provided by the template.
A `Tool` entry for `CycloneDX module for .NET` is always injected into `Metadata.Tools`.
Expand Down
Loading
Loading