1use std::collections::BTreeMap;
29
30use base64::Engine as _;
31use serde_json::{json, Map, Value};
32
33use crate::package_json::lock::Lockfile;
34
35const NOASSERTION: &str = "NOASSERTION";
37
38#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Component {
42 pub name: String,
44 pub version: String,
46 pub purl: String,
48 pub license: Option<String>,
50 pub resolved: Option<String>,
52 pub integrity: Option<String>,
54}
55
56pub fn components(lock: &Lockfile) -> Vec<Component> {
63 let mut out: Vec<Component> = lock
64 .packages
65 .iter()
66 .filter(|p| p.key.contains("node_modules/") && !p.link)
67 .map(|p| Component {
68 purl: npm_purl(&p.name, &p.version),
69 name: p.name.clone(),
70 version: p.version.clone(),
71 license: p.license.clone(),
72 resolved: p.resolved.clone(),
73 integrity: p.integrity.clone(),
74 })
75 .collect();
76 out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
77 out.dedup_by(|a, b| a.name == b.name && a.version == b.version);
78 out
79}
80
81pub fn components_from_resolved(resolved: &[crate::registry::Resolved]) -> Vec<Component> {
86 let mut out: Vec<Component> = resolved
87 .iter()
88 .map(|r| Component {
89 purl: npm_purl(&r.name, &r.version.to_string()),
90 name: r.name.clone(),
91 version: r.version.to_string(),
92 license: r.license.clone(),
93 resolved: Some(r.tarball_url.clone()),
94 integrity: r.integrity.clone(),
95 })
96 .collect();
97 out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
98 out
99}
100
101pub fn license_summary(components: &[Component]) -> BTreeMap<String, Vec<String>> {
104 let mut by_license: BTreeMap<String, Vec<String>> = BTreeMap::new();
105 for c in components {
106 let key = c.license.clone().unwrap_or_else(|| NOASSERTION.to_string());
107 by_license
108 .entry(key)
109 .or_default()
110 .push(format!("{}@{}", c.name, c.version));
111 }
112 for pkgs in by_license.values_mut() {
113 pkgs.sort();
114 }
115 by_license
116}
117
118pub fn render_summary(components: &[Component]) -> String {
120 use std::fmt::Write as _;
121 let by = license_summary(components);
122 let mut s = String::new();
123 let _ = writeln!(
124 s,
125 "{} package(s) across {} license(s)",
126 components.len(),
127 by.len()
128 );
129 for (license, pkgs) in &by {
130 let _ = write!(s, "\n{license} ({})\n", pkgs.len());
131 for p in pkgs {
132 let _ = writeln!(s, " {p}");
133 }
134 }
135 s
136}
137
138pub fn to_cyclonedx(
142 components: &[Component],
143 app_name: &str,
144 app_version: &str,
145 timestamp: Option<&str>,
146) -> String {
147 let mut metadata = Map::new();
148 if let Some(ts) = timestamp {
149 metadata.insert("timestamp".into(), json!(ts));
150 }
151 metadata.insert(
152 "tools".into(),
153 json!({ "components": [{
154 "type": "application",
155 "name": env!("CARGO_PKG_NAME"),
156 "version": env!("CARGO_PKG_VERSION"),
157 }] }),
158 );
159 metadata.insert(
160 "component".into(),
161 json!({
162 "type": "application",
163 "bom-ref": format!("{app_name}@{app_version}"),
164 "name": app_name,
165 "version": app_version,
166 }),
167 );
168
169 let comps: Vec<Value> = components
170 .iter()
171 .map(|c| {
172 let mut m = Map::new();
173 m.insert("type".into(), json!("library"));
174 m.insert("bom-ref".into(), json!(c.purl));
175 m.insert("name".into(), json!(c.name));
176 m.insert("version".into(), json!(c.version));
177 if let Some(lic) = &c.license {
178 m.insert("licenses".into(), cyclonedx_licenses(lic));
179 }
180 m.insert("purl".into(), json!(c.purl));
181 if let Some(hex) = sri_to_sha512_hex(c.integrity.as_deref()) {
182 m.insert(
183 "hashes".into(),
184 json!([{ "alg": "SHA-512", "content": hex }]),
185 );
186 }
187 Value::Object(m)
188 })
189 .collect();
190
191 let doc = json!({
192 "bomFormat": "CycloneDX",
193 "specVersion": "1.6",
194 "version": 1,
195 "metadata": Value::Object(metadata),
196 "components": comps,
197 });
198 let mut s = serde_json::to_string_pretty(&doc).expect("serialize CycloneDX");
199 s.push('\n');
200 s
201}
202
203pub fn to_spdx(components: &[Component], name: &str, namespace: &str, created: &str) -> String {
206 let packages: Vec<Value> = components
207 .iter()
208 .map(|c| {
209 let mut m = Map::new();
210 m.insert(
211 "SPDXID".into(),
212 json!(format!(
213 "SPDXRef-Package-{}-{}",
214 spdx_id_fragment(&c.name),
215 spdx_id_fragment(&c.version)
216 )),
217 );
218 m.insert("name".into(), json!(c.name));
219 m.insert("versionInfo".into(), json!(c.version));
220 m.insert(
221 "downloadLocation".into(),
222 json!(c.resolved.as_deref().unwrap_or(NOASSERTION)),
223 );
224 m.insert("filesAnalyzed".into(), json!(false));
225 m.insert("licenseConcluded".into(), json!(NOASSERTION));
226 m.insert(
227 "licenseDeclared".into(),
228 json!(c.license.as_deref().unwrap_or(NOASSERTION)),
229 );
230 m.insert("copyrightText".into(), json!(NOASSERTION));
231 m.insert(
232 "externalRefs".into(),
233 json!([{
234 "referenceCategory": "PACKAGE-MANAGER",
235 "referenceType": "purl",
236 "referenceLocator": c.purl,
237 }]),
238 );
239 if let Some(hex) = sri_to_sha512_hex(c.integrity.as_deref()) {
240 m.insert(
241 "checksums".into(),
242 json!([{ "algorithm": "SHA512", "checksumValue": hex }]),
243 );
244 }
245 Value::Object(m)
246 })
247 .collect();
248
249 let doc = json!({
250 "spdxVersion": "SPDX-2.3",
251 "dataLicense": "CC0-1.0",
252 "SPDXID": "SPDXRef-DOCUMENT",
253 "name": name,
254 "documentNamespace": namespace,
255 "creationInfo": {
256 "created": created,
257 "creators": [format!("Tool: {}-{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))],
258 },
259 "packages": packages,
260 });
261 let mut s = serde_json::to_string_pretty(&doc).expect("serialize SPDX");
262 s.push('\n');
263 s
264}
265
266pub fn now_rfc3339() -> String {
269 let now = time::OffsetDateTime::now_utc();
270 now.replace_nanosecond(0)
271 .unwrap_or(now)
272 .format(&time::format_description::well_known::Rfc3339)
273 .expect("format current time as RFC 3339")
274}
275
276fn npm_purl(name: &str, version: &str) -> String {
281 let path = match name.strip_prefix('@') {
282 Some(rest) => format!("%40{rest}"),
283 None => name.to_string(),
284 };
285 format!("pkg:npm/{path}@{version}")
286}
287
288fn cyclonedx_licenses(license: &str) -> Value {
292 if is_spdx_expression(license) {
293 json!([{ "expression": license }])
294 } else {
295 json!([{ "license": { "id": license } }])
296 }
297}
298
299fn is_spdx_expression(license: &str) -> bool {
300 license.contains(" OR ")
301 || license.contains(" AND ")
302 || license.contains(" WITH ")
303 || license.contains('(')
304}
305
306fn sri_to_sha512_hex(integrity: Option<&str>) -> Option<String> {
309 let b64 = integrity?.strip_prefix("sha512-")?;
310 let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
311 Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
312}
313
314fn spdx_id_fragment(s: &str) -> String {
317 s.chars()
318 .map(|c| {
319 if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
320 c
321 } else {
322 '-'
323 }
324 })
325 .collect()
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 const SAMPLE: &str = r#"{
335 "name": "demo", "version": "1.0.0", "lockfileVersion": 3,
336 "packages": {
337 "": { "name": "demo", "version": "1.0.0" },
338 "node_modules/lit": {
339 "version": "3.3.3",
340 "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
341 "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
342 "license": "BSD-3-Clause"
343 },
344 "node_modules/@lit/context": {
345 "version": "1.1.6",
346 "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz",
347 "license": "BSD-3-Clause"
348 },
349 "node_modules/mystery": { "version": "0.0.1" },
350 "node_modules/local-link": { "version": "1.0.0", "link": true }
351 }
352 }"#;
353
354 fn sample_components() -> Vec<Component> {
355 components(&Lockfile::parse(SAMPLE).unwrap())
356 }
357
358 #[test]
359 fn components_skip_root_and_links_and_build_purls() {
360 let c = sample_components();
361 let names: Vec<&str> = c.iter().map(|x| x.name.as_str()).collect();
362 assert_eq!(names, ["@lit/context", "lit", "mystery"]);
364 assert_eq!(c[0].purl, "pkg:npm/%40lit/context@1.1.6");
366 assert_eq!(c[1].purl, "pkg:npm/lit@3.3.3");
367 }
368
369 #[test]
370 fn components_cover_nested_and_aliased_entries_once() {
371 let lock = Lockfile::parse(
375 r#"{
376 "name": "ws", "version": "1.0.0", "lockfileVersion": 3,
377 "packages": {
378 "": { "name": "ws", "version": "1.0.0" },
379 "app": { "name": "@ws/app", "version": "1.0.0" },
380 "node_modules/lodash": { "version": "4.17.21" },
381 "app/node_modules/lodash": { "version": "4.17.11" },
382 "node_modules/dash": { "name": "lodash", "version": "4.17.11" }
383 }
384 }"#,
385 )
386 .unwrap();
387 let c = components(&lock);
388 let ids: Vec<String> = c
389 .iter()
390 .map(|x| format!("{}@{}", x.name, x.version))
391 .collect();
392 assert_eq!(ids, ["lodash@4.17.11", "lodash@4.17.21"]);
393 assert_eq!(c[0].purl, "pkg:npm/lodash@4.17.11");
394 }
395
396 #[test]
397 fn license_summary_groups_and_marks_unknown_as_noassertion() {
398 let summary = license_summary(&sample_components());
399 assert_eq!(
400 summary.get("BSD-3-Clause").unwrap(),
401 &["@lit/context@1.1.6", "lit@3.3.3"]
402 );
403 assert_eq!(summary.get("NOASSERTION").unwrap(), &["mystery@0.0.1"]);
405 }
406
407 #[test]
408 fn cyclonedx_has_required_shape_purls_licenses_and_sha512_hash() {
409 let json: Value =
410 serde_json::from_str(&to_cyclonedx(&sample_components(), "demo", "1.0.0", None))
411 .unwrap();
412 assert_eq!(json["bomFormat"], "CycloneDX");
413 assert_eq!(json["specVersion"], "1.6");
414 assert!(json["metadata"].get("timestamp").is_none());
416
417 let lit = &json["components"][1];
418 assert_eq!(lit["name"], "lit");
419 assert_eq!(lit["purl"], "pkg:npm/lit@3.3.3");
420 assert_eq!(lit["licenses"][0]["license"]["id"], "BSD-3-Clause");
421 let hex = lit["hashes"][0]["content"].as_str().unwrap();
423 assert_eq!(lit["hashes"][0]["alg"], "SHA-512");
424 assert_eq!(hex.len(), 128);
425 assert!(hex.bytes().all(|b| b.is_ascii_hexdigit()));
426 let mystery = &json["components"][2];
428 assert_eq!(mystery["name"], "mystery");
429 assert!(mystery.get("licenses").is_none());
430 assert!(mystery.get("hashes").is_none());
431 }
432
433 #[test]
434 fn cyclonedx_uses_expression_for_compound_licenses() {
435 let comps = vec![Component {
436 name: "dual".into(),
437 version: "1.0.0".into(),
438 purl: "pkg:npm/dual@1.0.0".into(),
439 license: Some("MIT OR Apache-2.0".into()),
440 resolved: None,
441 integrity: None,
442 }];
443 let json: Value = serde_json::from_str(&to_cyclonedx(&comps, "x", "1", None)).unwrap();
444 assert_eq!(
445 json["components"][0]["licenses"][0]["expression"],
446 "MIT OR Apache-2.0"
447 );
448 }
449
450 #[test]
451 fn spdx_has_required_shape_ids_purls_and_license() {
452 let json: Value = serde_json::from_str(&to_spdx(
453 &sample_components(),
454 "demo-sbom",
455 "https://spdx.example/demo",
456 "2026-01-01T00:00:00Z",
457 ))
458 .unwrap();
459 assert_eq!(json["spdxVersion"], "SPDX-2.3");
460 assert_eq!(json["SPDXID"], "SPDXRef-DOCUMENT");
461 assert_eq!(json["creationInfo"]["created"], "2026-01-01T00:00:00Z");
462
463 let scoped = &json["packages"][0];
465 assert_eq!(scoped["SPDXID"], "SPDXRef-Package--lit-context-1.1.6");
466 assert_eq!(scoped["licenseDeclared"], "BSD-3-Clause");
467 assert_eq!(
468 scoped["externalRefs"][0]["referenceLocator"],
469 "pkg:npm/%40lit/context@1.1.6"
470 );
471 assert_eq!(json["packages"][2]["licenseDeclared"], "NOASSERTION");
473 }
474
475 #[test]
476 fn sri_decodes_to_64_byte_sha512_hex() {
477 let hex = sri_to_sha512_hex(Some(
478 "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
479 ))
480 .unwrap();
481 assert_eq!(hex.len(), 128); assert!(sri_to_sha512_hex(Some("sha1-deadbeef")).is_none()); assert!(sri_to_sha512_hex(None).is_none());
484 }
485}