diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c85e744..0e78f8bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `@` 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 diff --git a/CycloneDX.Tests/FunctionalTests/IPR-TwoLevelTree/IprTwoLevelTree.cs b/CycloneDX.Tests/FunctionalTests/IPR-TwoLevelTree/IprTwoLevelTree.cs index 74596220..4f86be90 100644 --- a/CycloneDX.Tests/FunctionalTests/IPR-TwoLevelTree/IprTwoLevelTree.cs +++ b/CycloneDX.Tests/FunctionalTests/IPR-TwoLevelTree/IprTwoLevelTree.cs @@ -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"); + } } } diff --git a/CycloneDX.Tests/FunctionalTests/ProjectReferenceNugetPurlTests.cs b/CycloneDX.Tests/FunctionalTests/ProjectReferenceNugetPurlTests.cs new file mode 100644 index 00000000..da2aadcd --- /dev/null +++ b/CycloneDX.Tests/FunctionalTests/ProjectReferenceNugetPurlTests.cs @@ -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 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 + { + [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(); + projectService.Setup(s => s.GetProjectDotnetDependencysAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => assetsService.GetDotnetDependencys(projectPath, assetsPath, false)); + projectService.Setup(s => s.GetComponent(It.IsAny())) + .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(); + nugetService.Setup(s => s.GetComponentAsync(It.IsAny())) + .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(); + factory.Setup(s => s.Create(It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny>())) + .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; + } + } +} diff --git a/CycloneDX.Tests/FunctionalTests/ProjectReferenceWithPackageReferenceWithTransitivePackage.cs b/CycloneDX.Tests/FunctionalTests/ProjectReferenceWithPackageReferenceWithTransitivePackage.cs index ec0eb25d..a8e2573c 100644 --- a/CycloneDX.Tests/FunctionalTests/ProjectReferenceWithPackageReferenceWithTransitivePackage.cs +++ b/CycloneDX.Tests/FunctionalTests/ProjectReferenceWithPackageReferenceWithTransitivePackage.cs @@ -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"); + } } } diff --git a/CycloneDX/Program.cs b/CycloneDX/Program.cs index 8ffbc906..91ea87a0 100644 --- a/CycloneDX/Program.cs +++ b/CycloneDX/Program.cs @@ -89,7 +89,7 @@ public static async Task Main(string[] args) var setVersion = new Option("--set-version", "-sv") { Description = "Override the default BOM metadata component version (defaults to 0.0.0)." }; var includeProjectReferences = new Option("--include-project-references", "-ipr") { Description = "Include project references as components (can only be used with project files)." }; var setType = new Option("--set-type", "-st") { Description = "Override the default BOM metadata component type (defaults to application).", DefaultValueFactory = _ => Component.Classification.Application }; - var setNugetPurl = new Option("--set-nuget-purl", null) { Description = "Override the default BOM metadata component bom ref and PURL as NuGet package." }; + var setNugetPurl = new Option("--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("--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("--output-format", "-F") { Description = "Select the BOM output format: auto (default), xml, json, or unsafeJson (relaxed escaping)." }; var specVersion = new Option("--spec-version", "-spv") { Description = $"Which version of CycloneDX spec to use. [default: {SpecificationVersionHelpers.VersionString(SpecificationVersionHelpers.CurrentVersion)}]" }; diff --git a/CycloneDX/Runner.cs b/CycloneDX/Runner.cs index 2a947174..80f0ffb9 100644 --- a/CycloneDX/Runner.cs +++ b/CycloneDX/Runner.cs @@ -325,6 +325,19 @@ public async Task 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) diff --git a/README.md b/README.md index 4e2c0922..e8d16701 100755 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ Options: Machine_Learning_Model|Null|Operating_System|Platform> -ef, --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 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 diff --git a/docs/architecture.md b/docs/architecture.md index 5beb5eb9..829acb1e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 ``, 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 `@` 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/@` 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 +`@` 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. --- @@ -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/@`. +`--set-nuget-purl` generates `pkg:nuget/@` 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 +`@`) 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`. diff --git a/docs/bom-metadata.md b/docs/bom-metadata.md index a6cc3ca7..62d4c62e 100644 --- a/docs/bom-metadata.md +++ b/docs/bom-metadata.md @@ -14,19 +14,29 @@ four sources. They are applied in priority order — highest first: ## Source 1 — CLI arguments -These flags always win, regardless of what the metadata file says. +The name, version, and type flags always win, regardless of what the metadata +file says. `--set-nuget-purl` only fills missing identifiers as described below. | Flag | Short | Default | Description | |---|---|---|---| | `--set-name` | `-sn` | _(filename)_ | Override the metadata component `` | | `--set-version` | `-sv` | `0.0.0` | Override the metadata component `` | | `--set-type` | `-st` | `Application` | Override the metadata component `type` attribute | -| `--set-nuget-purl` | — | off | Generate a NuGet `pkg:nuget/@` purl and bom-ref if they are not already set | +| `--set-nuget-purl` | — | off | Generate a missing NuGet `pkg:nuget/@` purl; fill a missing metadata bom-ref from the purl. Also applies to included project references with `--include-project-references` (see below) | `--set-name`, `--set-version`, and `--set-type` override only the specific field they target; every other field from the template file (description, licenses, authors, etc.) is preserved unchanged. +For the root metadata component, `--set-nuget-purl` preserves every nonempty +purl and bom-ref, including a bom-ref of `@`. For referenced +project components, both `--include-project-references` and `--set-nuget-purl` +are required: missing purls are generated, existing nonempty purls are kept, +and a missing or default `@` bom-ref is replaced by the available +purl. Custom project bom-refs are preserved. If PURL generation returns null, +the original project bom-ref is retained. NuGet package identifiers are unchanged. +See [project references](project-references.md) for examples. + --- ## Source 2 — Metadata template file (`--import-metadata-path`) @@ -133,8 +143,8 @@ When both a template file and CLI arguments are provided, the merge rules are: | type | no | no/null | any | `Application` | All other metadata fields (description, licenses, authors, purl, bom-ref, etc.) -are always taken entirely from the template file; there are no CLI args to -override them individually. +are preserved from the template file. `--set-nuget-purl` can fill missing purl +and bom-ref values, but does not override nonempty metadata identifiers. --- @@ -173,4 +183,8 @@ dotnet-CycloneDX MyLib.csproj -o ./bom \ -sn "MyLib" -sv "2.0.0" --set-nuget-purl ``` -This sets `bom-ref` and `purl` to `pkg:nuget/MyLib@2.0.0`. +When neither identifier is already set, this sets the metadata `bom-ref` and +`purl` to `pkg:nuget/MyLib@2.0.0`. Existing nonempty metadata identifiers are +preserved. To also generate missing PURLs for referenced projects, add +`--include-project-references`; their default bom-refs become their PURLs, +while custom bom-refs remain unchanged. diff --git a/docs/project-references.md b/docs/project-references.md index 22222b55..50ab8a94 100644 --- a/docs/project-references.md +++ b/docs/project-references.md @@ -76,6 +76,27 @@ component in the BOM, and the full multi-level dependency graph is preserved: MyApp → MyLib → SomeNuGetPackage ``` +### NuGet PURLs for included projects + +By default, included project components have a bom-ref of `@` +and no generated NuGet PURL. To generate missing project PURLs, enable both flags: + +```bash +dotnet-CycloneDX MyApp.csproj -o ./bom --include-project-references --set-nuget-purl +``` + +For a project component named `MyLib` with version `1.2.3`, a missing (null or +empty) PURL becomes `pkg:nuget/MyLib@1.2.3`. Existing nonempty PURLs are never +overwritten. When a PURL is available, a missing bom-ref or the default +`MyLib@1.2.3` bom-ref becomes that PURL; any custom bom-ref is preserved. +If PURL generation returns null, the original bom-ref is retained. Dependency +edges use the final bom-ref, including preserved custom references. + +`--include-project-references` alone leaves project identifiers unchanged. +`--set-nuget-purl` alone still only fills missing root metadata identifiers; +it does not include project components. Root metadata preserves all nonempty +bom-refs, even `@`, and NuGet package identifiers are unchanged. + `-ipr` is only valid with a project file input. Passing it with a `.sln`, directory, or `packages.config` path is a hard error. @@ -88,6 +109,7 @@ directory, or `packages.config` path is a hard error. | Modern SDK-style project, you want all NuGet packages | _(none needed)_ | | Referenced project uses `packages.config` | `--recursive` | | You want referenced projects listed as BOM components | `--recursive --include-project-references` | +| You want missing NuGet PURLs generated for included projects | `--include-project-references --set-nuget-purl` | | Solution file input | _(none; solution path already aggregates all projects) _ | --- @@ -100,3 +122,5 @@ directory, or `packages.config` path is a hard error. or when combined with `--include-project-references`. - `--include-project-references` controls whether project nodes appear as BOM components. It does not affect which NuGet packages are included. +- Combined with `--set-nuget-purl`, it generates missing project PURLs and + replaces missing/default project bom-refs, preserving existing PURLs and custom refs.