Skip to content

Commit 8eab29b

Browse files
ruromeroclaude
andauthored
fix(cargo): resolve workspace-inherited license in Cargo.toml (#532)
* fix(cargo): resolve workspace-inherited license in Cargo.toml When a Cargo.toml uses `license = { workspace = true }`, the TOML parser returns a JS object that flows through to the CycloneDX SBOM as invalid JSON, causing a 400 validation error from the backend. Fix by detecting workspace inheritance in readLicenseFromManifest and resolving the actual license value from the workspace root Cargo.toml via cargo metadata's workspace_root field. Add a defensive guard in CycloneDxSbom.getComponent to filter out non-CycloneDX license objects. Refs: TC-4530 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(cargo): add negative-path test for missing workspace license Add test fixture and tests for the case where workspace_root exists in cargo metadata but the workspace root Cargo.toml lacks a license field, verifying the SBOM gracefully omits the licenses property. Also update the Provider typedef to reflect the optional metadata parameter. Refs: TC-4530 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 212178e commit 8eab29b

15 files changed

Lines changed: 715 additions & 10 deletions

src/cyclone_dx_sbom.js

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,20 @@ function getComponent(component, type, scope, licenses, hashes) {
4747
// Add licenses if provided (CycloneDX format). Callers must provide valid SPDX identifiers.
4848
if (licenses) {
4949
const licenseArray = Array.isArray(licenses) ? licenses : [licenses];
50-
componentObject.licenses = licenseArray.map(lic => {
51-
if (typeof lic === 'string') {
52-
return { license: { id: lic } };
53-
}
54-
return lic;
55-
});
50+
const validLicenses = licenseArray
51+
.map(lic => {
52+
if (typeof lic === 'string') {
53+
return { license: { id: lic } };
54+
}
55+
if (typeof lic === 'object' && lic !== null && ('license' in lic || 'expression' in lic)) {
56+
return lic;
57+
}
58+
return null;
59+
})
60+
.filter(Boolean);
61+
if (validLicenses.length > 0) {
62+
componentObject.licenses = validLicenses;
63+
}
5664
}
5765

5866
// Add hashes if provided (CycloneDX 1.4 format).

src/provider.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import Python_uv from './providers/python_uv.js'
1515
import rustCargoProvider from './providers/rust_cargo.js'
1616

1717
/** @typedef {{ecosystem: string, contentType: string, content: string}} Provided */
18-
/** @typedef {{isSupported: function(string): boolean, validateLockFile: function(string, Object): void, provideComponent: function(string, {}): Provided | Promise<Provided>, provideStack: function(string, {}): Provided | Promise<Provided>, readLicenseFromManifest: function(string): string | null, packageManagerName: function(): string}} Provider */
18+
/** @typedef {{isSupported: function(string): boolean, validateLockFile: function(string, Object): void, provideComponent: function(string, {}): Provided | Promise<Provided>, provideStack: function(string, {}): Provided | Promise<Provided>, readLicenseFromManifest: function(string, Object=): string | null, packageManagerName: function(): string}} Provider */
1919

2020
/**
2121
* MUST include all providers here.

src/providers/rust_cargo.js

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,16 +77,24 @@ function isSupported(manifestName) {
7777
* Read project license from Cargo.toml, with fallback to LICENSE file.
7878
* Supports the `license` field under `[package]` (single crate / workspace
7979
* with root) and under `[workspace.package]` (virtual workspaces).
80+
* When a member crate uses `license = { workspace = true }`, the actual
81+
* license is resolved from the workspace root Cargo.toml.
8082
* @param {string} manifestPath - path to Cargo.toml
83+
* @param {object} [metadata] - cargo metadata output (needed for workspace license resolution)
8184
* @returns {string|null} SPDX identifier or null
8285
*/
83-
function readLicenseFromManifest(manifestPath) {
86+
function readLicenseFromManifest(manifestPath, metadata) {
8487
let fromManifest = null
8588
try {
8689
let content = fs.readFileSync(manifestPath, 'utf-8')
8790
let parsed = parseToml(content)
8891

89-
fromManifest = parsed.package?.license
92+
let packageLicense = parsed.package?.license
93+
if (typeof packageLicense === 'object' && packageLicense?.workspace === true) {
94+
packageLicense = resolveWorkspaceLicense(parsed, metadata)
95+
}
96+
97+
fromManifest = packageLicense
9098
|| parsed.workspace?.package?.license
9199
|| null
92100
} catch (_) {
@@ -95,6 +103,26 @@ function readLicenseFromManifest(manifestPath) {
95103
return getLicense(fromManifest, manifestPath)
96104
}
97105

106+
/**
107+
* Resolve a workspace-inherited license from the workspace root Cargo.toml.
108+
* @param {object} parsed - the parsed member crate Cargo.toml
109+
* @param {object} [metadata] - cargo metadata output with workspace_root
110+
* @returns {string|null} resolved license string or null
111+
*/
112+
function resolveWorkspaceLicense(parsed, metadata) {
113+
if (metadata?.workspace_root) {
114+
let cargoTomlPath = path.join(metadata.workspace_root, 'Cargo.toml')
115+
try {
116+
let content = fs.readFileSync(cargoTomlPath, 'utf-8')
117+
let workspaceParsed = parseToml(content)
118+
return workspaceParsed.workspace?.package?.license || null
119+
} catch (_) {
120+
// fall through
121+
}
122+
}
123+
return parsed.workspace?.package?.license || null
124+
}
125+
98126
/**
99127
* Validates that Cargo.lock exists in the manifest directory or in a parent
100128
* workspace root directory. In Cargo workspaces the lock file always lives at
@@ -189,7 +217,7 @@ function getSBOM(manifest, opts = {}, includeTransitive) {
189217
let metadata = executeCargoMetadata(cargoBin, manifestDir)
190218
let ignoredDeps = getIgnoredDeps(manifest, metadata)
191219
let crateType = detectCrateType(metadata)
192-
let license = readLicenseFromManifest(manifest)
220+
let license = readLicenseFromManifest(manifest, metadata)
193221

194222
let sbom
195223
if (crateType === CrateType.WORKSPACE_VIRTUAL) {

test/cyclone_dx_sbom_hashes.test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,3 +151,35 @@ suite('CycloneDX SBOM hash support', () => {
151151
expect(targetComponent).to.not.have.property('hashes')
152152
})
153153
})
154+
155+
suite('CycloneDX SBOM license validation', () => {
156+
157+
/** Verifies that non-CycloneDX license objects (e.g., { workspace: true }) are filtered out. */
158+
test('addRoot filters out non-CycloneDX license objects', () => {
159+
// Given a license value that is an unresolved workspace inheritance marker
160+
const sbom = new CycloneDxSbom()
161+
const root = new PackageURL('cargo', undefined, 'my-crate', '1.0.0', undefined, undefined)
162+
163+
// When adding a root with a non-CycloneDX license object
164+
sbom.addRoot(root, { workspace: true })
165+
166+
// Then the root component should have no licenses field
167+
expect(sbom.rootComponent).to.not.have.property('licenses')
168+
})
169+
170+
/** Verifies that valid CycloneDX license objects are preserved. */
171+
test('addRoot preserves valid CycloneDX license objects', () => {
172+
const sbom = new CycloneDxSbom()
173+
const root = new PackageURL('cargo', undefined, 'my-crate', '1.0.0', undefined, undefined)
174+
sbom.addRoot(root, { license: { id: 'MIT' } })
175+
expect(sbom.rootComponent.licenses).to.deep.equal([{ license: { id: 'MIT' } }])
176+
})
177+
178+
/** Verifies that string licenses are wrapped in CycloneDX format. */
179+
test('addRoot wraps string license in CycloneDX format', () => {
180+
const sbom = new CycloneDxSbom()
181+
const root = new PackageURL('cargo', undefined, 'my-crate', '1.0.0', undefined, undefined)
182+
sbom.addRoot(root, 'Apache-2.0')
183+
expect(sbom.rootComponent.licenses).to.deep.equal([{ license: { id: 'Apache-2.0' } }])
184+
})
185+
})

test/providers/rust_cargo.test.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -538,3 +538,41 @@ suite('testing rust-cargo license detection', () => {
538538
expect(sbom.metadata.component.licenses).to.be.undefined
539539
}).timeout(10000)
540540
}).beforeAll(() => clock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z'))).afterAll(() => clock.restore());
541+
542+
suite('testing rust-cargo workspace license inheritance', () => {
543+
const workspaceLicenseDir = 'test/providers/tst_manifests/cargo/cargo_single_crate_workspace_license'
544+
545+
/** Verifies that workspace-inherited license resolves correctly in stack analysis SBOM. */
546+
test('verify workspace-inherited license is resolved in SBOM (stack analysis)', async () => {
547+
await assertSbomMatchesExpected(workspaceLicenseDir, 'stack')
548+
}).timeout(10000)
549+
550+
/** Verifies that workspace-inherited license resolves correctly in component analysis SBOM. */
551+
test('verify workspace-inherited license is resolved in SBOM (component analysis)', async () => {
552+
await assertSbomMatchesExpected(workspaceLicenseDir, 'component')
553+
}).timeout(10000)
554+
555+
/** Verifies the resolved license value is Apache-2.0 from [workspace.package]. */
556+
test('verify resolved workspace license is Apache-2.0 in SBOM metadata', async () => {
557+
let sbom = await getParsedSbom(workspaceLicenseDir, 'stack')
558+
expect(sbom.metadata.component.licenses).to.deep.equal([{ license: { id: 'Apache-2.0' } }])
559+
}).timeout(10000)
560+
561+
const workspaceLicenseMissingDir = 'test/providers/tst_manifests/cargo/cargo_single_crate_workspace_license_missing'
562+
563+
/** Verifies SBOM omits licenses when workspace_root exists but has no license field. */
564+
test('verify SBOM omits licenses when workspace license is missing (stack analysis)', async () => {
565+
await assertSbomMatchesExpected(workspaceLicenseMissingDir, 'stack')
566+
}).timeout(10000)
567+
568+
/** Verifies SBOM omits licenses when workspace_root exists but has no license field. */
569+
test('verify SBOM omits licenses when workspace license is missing (component analysis)', async () => {
570+
await assertSbomMatchesExpected(workspaceLicenseMissingDir, 'component')
571+
}).timeout(10000)
572+
573+
/** Verifies the licenses property is undefined when workspace license cannot be resolved. */
574+
test('verify licenses is undefined when workspace license is missing', async () => {
575+
let sbom = await getParsedSbom(workspaceLicenseMissingDir, 'stack')
576+
expect(sbom.metadata.component.licenses).to.be.undefined
577+
}).timeout(10000)
578+
}).beforeAll(() => clock = useFakeTimers(new Date('2023-08-07T00:00:00.000Z'))).afterAll(() => clock.restore());

test/providers/tst_manifests/cargo/cargo_single_crate_workspace_license/Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[package]
2+
name = "my-test-crate"
3+
version = { workspace = true }
4+
edition = "2021"
5+
license = { workspace = true }
6+
7+
[workspace]
8+
members = []
9+
10+
[workspace.package]
11+
version = "0.1.0"
12+
license = "Apache-2.0"
13+
14+
[dependencies]
15+
serde = "1.0.193"
16+
tokio = { version = "1.35.0", features = ["full"] }
17+
18+
[dev-dependencies]
19+
tempfile = "3.8.0"
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
{
2+
"packages": [
3+
{
4+
"name": "my-test-crate",
5+
"version": "0.1.0",
6+
"id": "path+file:///fake/path/my-test-crate#0.1.0",
7+
"source": null,
8+
"dependencies": [
9+
{
10+
"name": "serde",
11+
"kind": null
12+
},
13+
{
14+
"name": "tokio",
15+
"kind": null
16+
},
17+
{
18+
"name": "tempfile",
19+
"kind": "dev"
20+
}
21+
]
22+
},
23+
{
24+
"name": "serde",
25+
"version": "1.0.193",
26+
"id": "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.193",
27+
"source": "registry+https://github.com/rust-lang/crates.io-index",
28+
"dependencies": [
29+
{
30+
"name": "serde_derive",
31+
"kind": null
32+
}
33+
]
34+
},
35+
{
36+
"name": "serde_derive",
37+
"version": "1.0.193",
38+
"id": "registry+https://github.com/rust-lang/crates.io-index#serde_derive@1.0.193",
39+
"source": "registry+https://github.com/rust-lang/crates.io-index",
40+
"dependencies": []
41+
},
42+
{
43+
"name": "tokio",
44+
"version": "1.35.0",
45+
"id": "registry+https://github.com/rust-lang/crates.io-index#tokio@1.35.0",
46+
"source": "registry+https://github.com/rust-lang/crates.io-index",
47+
"dependencies": [
48+
{
49+
"name": "pin-project-lite",
50+
"kind": null
51+
}
52+
]
53+
},
54+
{
55+
"name": "pin-project-lite",
56+
"version": "0.2.13",
57+
"id": "registry+https://github.com/rust-lang/crates.io-index#pin-project-lite@0.2.13",
58+
"source": "registry+https://github.com/rust-lang/crates.io-index",
59+
"dependencies": []
60+
},
61+
{
62+
"name": "tempfile",
63+
"version": "3.8.0",
64+
"id": "registry+https://github.com/rust-lang/crates.io-index#tempfile@3.8.0",
65+
"source": "registry+https://github.com/rust-lang/crates.io-index",
66+
"dependencies": []
67+
}
68+
],
69+
"workspace_members": [
70+
"path+file:///fake/path/my-test-crate#0.1.0"
71+
],
72+
"workspace_root": "test/providers/tst_manifests/cargo/cargo_single_crate_workspace_license",
73+
"resolve": {
74+
"root": "path+file:///fake/path/my-test-crate#0.1.0",
75+
"nodes": [
76+
{
77+
"id": "path+file:///fake/path/my-test-crate#0.1.0",
78+
"deps": [
79+
{
80+
"pkg": "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.193",
81+
"dep_kinds": [
82+
{
83+
"kind": null,
84+
"target": null
85+
}
86+
]
87+
},
88+
{
89+
"pkg": "registry+https://github.com/rust-lang/crates.io-index#tokio@1.35.0",
90+
"dep_kinds": [
91+
{
92+
"kind": null,
93+
"target": null
94+
}
95+
]
96+
},
97+
{
98+
"pkg": "registry+https://github.com/rust-lang/crates.io-index#tempfile@3.8.0",
99+
"dep_kinds": [
100+
{
101+
"kind": "dev",
102+
"target": null
103+
}
104+
]
105+
}
106+
]
107+
},
108+
{
109+
"id": "registry+https://github.com/rust-lang/crates.io-index#serde@1.0.193",
110+
"deps": [
111+
{
112+
"pkg": "registry+https://github.com/rust-lang/crates.io-index#serde_derive@1.0.193",
113+
"dep_kinds": [
114+
{
115+
"kind": null,
116+
"target": null
117+
}
118+
]
119+
}
120+
]
121+
},
122+
{
123+
"id": "registry+https://github.com/rust-lang/crates.io-index#serde_derive@1.0.193",
124+
"deps": []
125+
},
126+
{
127+
"id": "registry+https://github.com/rust-lang/crates.io-index#tokio@1.35.0",
128+
"deps": [
129+
{
130+
"pkg": "registry+https://github.com/rust-lang/crates.io-index#pin-project-lite@0.2.13",
131+
"dep_kinds": [
132+
{
133+
"kind": null,
134+
"target": null
135+
}
136+
]
137+
}
138+
]
139+
},
140+
{
141+
"id": "registry+https://github.com/rust-lang/crates.io-index#pin-project-lite@0.2.13",
142+
"deps": []
143+
},
144+
{
145+
"id": "registry+https://github.com/rust-lang/crates.io-index#tempfile@3.8.0",
146+
"deps": []
147+
}
148+
]
149+
}
150+
}

0 commit comments

Comments
 (0)