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> {
60 let mut out: Vec<Component> = lock
61 .packages
62 .iter()
63 .filter(|p| p.key.starts_with("node_modules/") && !p.link)
64 .map(|p| Component {
65 purl: npm_purl(&p.name, &p.version),
66 name: p.name.clone(),
67 version: p.version.clone(),
68 license: p.license.clone(),
69 resolved: p.resolved.clone(),
70 integrity: p.integrity.clone(),
71 })
72 .collect();
73 out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
74 out
75}
76
77pub fn license_summary(components: &[Component]) -> BTreeMap<String, Vec<String>> {
80 let mut by_license: BTreeMap<String, Vec<String>> = BTreeMap::new();
81 for c in components {
82 let key = c.license.clone().unwrap_or_else(|| NOASSERTION.to_string());
83 by_license
84 .entry(key)
85 .or_default()
86 .push(format!("{}@{}", c.name, c.version));
87 }
88 for pkgs in by_license.values_mut() {
89 pkgs.sort();
90 }
91 by_license
92}
93
94pub fn render_summary(components: &[Component]) -> String {
96 use std::fmt::Write as _;
97 let by = license_summary(components);
98 let mut s = String::new();
99 let _ = writeln!(
100 s,
101 "{} package(s) across {} license(s)",
102 components.len(),
103 by.len()
104 );
105 for (license, pkgs) in &by {
106 let _ = write!(s, "\n{license} ({})\n", pkgs.len());
107 for p in pkgs {
108 let _ = writeln!(s, " {p}");
109 }
110 }
111 s
112}
113
114pub fn to_cyclonedx(
118 components: &[Component],
119 app_name: &str,
120 app_version: &str,
121 timestamp: Option<&str>,
122) -> String {
123 let mut metadata = Map::new();
124 if let Some(ts) = timestamp {
125 metadata.insert("timestamp".into(), json!(ts));
126 }
127 metadata.insert(
128 "tools".into(),
129 json!({ "components": [{
130 "type": "application",
131 "name": env!("CARGO_PKG_NAME"),
132 "version": env!("CARGO_PKG_VERSION"),
133 }] }),
134 );
135 metadata.insert(
136 "component".into(),
137 json!({
138 "type": "application",
139 "bom-ref": format!("{app_name}@{app_version}"),
140 "name": app_name,
141 "version": app_version,
142 }),
143 );
144
145 let comps: Vec<Value> = components
146 .iter()
147 .map(|c| {
148 let mut m = Map::new();
149 m.insert("type".into(), json!("library"));
150 m.insert("bom-ref".into(), json!(c.purl));
151 m.insert("name".into(), json!(c.name));
152 m.insert("version".into(), json!(c.version));
153 if let Some(lic) = &c.license {
154 m.insert("licenses".into(), cyclonedx_licenses(lic));
155 }
156 m.insert("purl".into(), json!(c.purl));
157 if let Some(hex) = sri_to_sha512_hex(c.integrity.as_deref()) {
158 m.insert(
159 "hashes".into(),
160 json!([{ "alg": "SHA-512", "content": hex }]),
161 );
162 }
163 Value::Object(m)
164 })
165 .collect();
166
167 let doc = json!({
168 "bomFormat": "CycloneDX",
169 "specVersion": "1.6",
170 "version": 1,
171 "metadata": Value::Object(metadata),
172 "components": comps,
173 });
174 let mut s = serde_json::to_string_pretty(&doc).expect("serialize CycloneDX");
175 s.push('\n');
176 s
177}
178
179pub fn to_spdx(components: &[Component], name: &str, namespace: &str, created: &str) -> String {
182 let packages: Vec<Value> = components
183 .iter()
184 .map(|c| {
185 let mut m = Map::new();
186 m.insert(
187 "SPDXID".into(),
188 json!(format!(
189 "SPDXRef-Package-{}-{}",
190 spdx_id_fragment(&c.name),
191 spdx_id_fragment(&c.version)
192 )),
193 );
194 m.insert("name".into(), json!(c.name));
195 m.insert("versionInfo".into(), json!(c.version));
196 m.insert(
197 "downloadLocation".into(),
198 json!(c.resolved.as_deref().unwrap_or(NOASSERTION)),
199 );
200 m.insert("filesAnalyzed".into(), json!(false));
201 m.insert("licenseConcluded".into(), json!(NOASSERTION));
202 m.insert(
203 "licenseDeclared".into(),
204 json!(c.license.as_deref().unwrap_or(NOASSERTION)),
205 );
206 m.insert("copyrightText".into(), json!(NOASSERTION));
207 m.insert(
208 "externalRefs".into(),
209 json!([{
210 "referenceCategory": "PACKAGE-MANAGER",
211 "referenceType": "purl",
212 "referenceLocator": c.purl,
213 }]),
214 );
215 if let Some(hex) = sri_to_sha512_hex(c.integrity.as_deref()) {
216 m.insert(
217 "checksums".into(),
218 json!([{ "algorithm": "SHA512", "checksumValue": hex }]),
219 );
220 }
221 Value::Object(m)
222 })
223 .collect();
224
225 let doc = json!({
226 "spdxVersion": "SPDX-2.3",
227 "dataLicense": "CC0-1.0",
228 "SPDXID": "SPDXRef-DOCUMENT",
229 "name": name,
230 "documentNamespace": namespace,
231 "creationInfo": {
232 "created": created,
233 "creators": [format!("Tool: {}-{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))],
234 },
235 "packages": packages,
236 });
237 let mut s = serde_json::to_string_pretty(&doc).expect("serialize SPDX");
238 s.push('\n');
239 s
240}
241
242pub fn now_rfc3339() -> String {
245 let now = time::OffsetDateTime::now_utc();
246 now.replace_nanosecond(0)
247 .unwrap_or(now)
248 .format(&time::format_description::well_known::Rfc3339)
249 .expect("format current time as RFC 3339")
250}
251
252fn npm_purl(name: &str, version: &str) -> String {
257 let path = match name.strip_prefix('@') {
258 Some(rest) => format!("%40{rest}"),
259 None => name.to_string(),
260 };
261 format!("pkg:npm/{path}@{version}")
262}
263
264fn cyclonedx_licenses(license: &str) -> Value {
268 if is_spdx_expression(license) {
269 json!([{ "expression": license }])
270 } else {
271 json!([{ "license": { "id": license } }])
272 }
273}
274
275fn is_spdx_expression(license: &str) -> bool {
276 license.contains(" OR ")
277 || license.contains(" AND ")
278 || license.contains(" WITH ")
279 || license.contains('(')
280}
281
282fn sri_to_sha512_hex(integrity: Option<&str>) -> Option<String> {
285 let b64 = integrity?.strip_prefix("sha512-")?;
286 let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
287 Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
288}
289
290fn spdx_id_fragment(s: &str) -> String {
293 s.chars()
294 .map(|c| {
295 if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
296 c
297 } else {
298 '-'
299 }
300 })
301 .collect()
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 const SAMPLE: &str = r#"{
311 "name": "demo", "version": "1.0.0", "lockfileVersion": 3,
312 "packages": {
313 "": { "name": "demo", "version": "1.0.0" },
314 "node_modules/lit": {
315 "version": "3.3.3",
316 "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
317 "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
318 "license": "BSD-3-Clause"
319 },
320 "node_modules/@lit/context": {
321 "version": "1.1.6",
322 "resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz",
323 "license": "BSD-3-Clause"
324 },
325 "node_modules/mystery": { "version": "0.0.1" },
326 "node_modules/local-link": { "version": "1.0.0", "link": true }
327 }
328 }"#;
329
330 fn sample_components() -> Vec<Component> {
331 components(&Lockfile::parse(SAMPLE).unwrap())
332 }
333
334 #[test]
335 fn components_skip_root_and_links_and_build_purls() {
336 let c = sample_components();
337 let names: Vec<&str> = c.iter().map(|x| x.name.as_str()).collect();
338 assert_eq!(names, ["@lit/context", "lit", "mystery"]);
340 assert_eq!(c[0].purl, "pkg:npm/%40lit/context@1.1.6");
342 assert_eq!(c[1].purl, "pkg:npm/lit@3.3.3");
343 }
344
345 #[test]
346 fn license_summary_groups_and_marks_unknown_as_noassertion() {
347 let summary = license_summary(&sample_components());
348 assert_eq!(
349 summary.get("BSD-3-Clause").unwrap(),
350 &["@lit/context@1.1.6", "lit@3.3.3"]
351 );
352 assert_eq!(summary.get("NOASSERTION").unwrap(), &["mystery@0.0.1"]);
354 }
355
356 #[test]
357 fn cyclonedx_has_required_shape_purls_licenses_and_sha512_hash() {
358 let json: Value =
359 serde_json::from_str(&to_cyclonedx(&sample_components(), "demo", "1.0.0", None))
360 .unwrap();
361 assert_eq!(json["bomFormat"], "CycloneDX");
362 assert_eq!(json["specVersion"], "1.6");
363 assert!(json["metadata"].get("timestamp").is_none());
365
366 let lit = &json["components"][1];
367 assert_eq!(lit["name"], "lit");
368 assert_eq!(lit["purl"], "pkg:npm/lit@3.3.3");
369 assert_eq!(lit["licenses"][0]["license"]["id"], "BSD-3-Clause");
370 let hex = lit["hashes"][0]["content"].as_str().unwrap();
372 assert_eq!(lit["hashes"][0]["alg"], "SHA-512");
373 assert_eq!(hex.len(), 128);
374 assert!(hex.bytes().all(|b| b.is_ascii_hexdigit()));
375 let mystery = &json["components"][2];
377 assert_eq!(mystery["name"], "mystery");
378 assert!(mystery.get("licenses").is_none());
379 assert!(mystery.get("hashes").is_none());
380 }
381
382 #[test]
383 fn cyclonedx_uses_expression_for_compound_licenses() {
384 let comps = vec![Component {
385 name: "dual".into(),
386 version: "1.0.0".into(),
387 purl: "pkg:npm/dual@1.0.0".into(),
388 license: Some("MIT OR Apache-2.0".into()),
389 resolved: None,
390 integrity: None,
391 }];
392 let json: Value = serde_json::from_str(&to_cyclonedx(&comps, "x", "1", None)).unwrap();
393 assert_eq!(
394 json["components"][0]["licenses"][0]["expression"],
395 "MIT OR Apache-2.0"
396 );
397 }
398
399 #[test]
400 fn spdx_has_required_shape_ids_purls_and_license() {
401 let json: Value = serde_json::from_str(&to_spdx(
402 &sample_components(),
403 "demo-sbom",
404 "https://spdx.example/demo",
405 "2026-01-01T00:00:00Z",
406 ))
407 .unwrap();
408 assert_eq!(json["spdxVersion"], "SPDX-2.3");
409 assert_eq!(json["SPDXID"], "SPDXRef-DOCUMENT");
410 assert_eq!(json["creationInfo"]["created"], "2026-01-01T00:00:00Z");
411
412 let scoped = &json["packages"][0];
414 assert_eq!(scoped["SPDXID"], "SPDXRef-Package--lit-context-1.1.6");
415 assert_eq!(scoped["licenseDeclared"], "BSD-3-Clause");
416 assert_eq!(
417 scoped["externalRefs"][0]["referenceLocator"],
418 "pkg:npm/%40lit/context@1.1.6"
419 );
420 assert_eq!(json["packages"][2]["licenseDeclared"], "NOASSERTION");
422 }
423
424 #[test]
425 fn sri_decodes_to_64_byte_sha512_hex() {
426 let hex = sri_to_sha512_hex(Some(
427 "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
428 ))
429 .unwrap();
430 assert_eq!(hex.len(), 128); assert!(sri_to_sha512_hex(Some("sha1-deadbeef")).is_none()); assert!(sri_to_sha512_hex(None).is_none());
433 }
434}