use std::collections::BTreeMap;
use base64::Engine as _;
use serde_json::{json, Map, Value};
use crate::package_json::lock::Lockfile;
const NOASSERTION: &str = "NOASSERTION";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Component {
pub name: String,
pub version: String,
pub purl: String,
pub license: Option<String>,
pub resolved: Option<String>,
pub integrity: Option<String>,
}
pub fn components(lock: &Lockfile) -> Vec<Component> {
let mut out: Vec<Component> = lock
.packages
.iter()
.filter(|p| p.key.contains("node_modules/") && !p.link)
.map(|p| Component {
purl: npm_purl(&p.name, &p.version),
name: p.name.clone(),
version: p.version.clone(),
license: p.license.clone(),
resolved: p.resolved.clone(),
integrity: p.integrity.clone(),
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
out.dedup_by(|a, b| a.name == b.name && a.version == b.version);
out
}
pub fn components_from_resolved(resolved: &[crate::registry::Resolved]) -> Vec<Component> {
let mut out: Vec<Component> = resolved
.iter()
.map(|r| Component {
purl: npm_purl(&r.name, &r.version.to_string()),
name: r.name.clone(),
version: r.version.to_string(),
license: r.license.clone(),
resolved: Some(r.tarball_url.clone()),
integrity: r.integrity.clone(),
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.version.cmp(&b.version)));
out
}
pub fn license_summary(components: &[Component]) -> BTreeMap<String, Vec<String>> {
let mut by_license: BTreeMap<String, Vec<String>> = BTreeMap::new();
for c in components {
let key = c.license.clone().unwrap_or_else(|| NOASSERTION.to_string());
by_license
.entry(key)
.or_default()
.push(format!("{}@{}", c.name, c.version));
}
for pkgs in by_license.values_mut() {
pkgs.sort();
}
by_license
}
pub fn render_summary(components: &[Component]) -> String {
use std::fmt::Write as _;
let by = license_summary(components);
let mut s = String::new();
let _ = writeln!(
s,
"{} package(s) across {} license(s)",
components.len(),
by.len()
);
for (license, pkgs) in &by {
let _ = write!(s, "\n{license} ({})\n", pkgs.len());
for p in pkgs {
let _ = writeln!(s, " {p}");
}
}
s
}
pub fn to_cyclonedx(
components: &[Component],
app_name: &str,
app_version: &str,
timestamp: Option<&str>,
) -> String {
let mut metadata = Map::new();
if let Some(ts) = timestamp {
metadata.insert("timestamp".into(), json!(ts));
}
metadata.insert(
"tools".into(),
json!({ "components": [{
"type": "application",
"name": env!("CARGO_PKG_NAME"),
"version": env!("CARGO_PKG_VERSION"),
}] }),
);
metadata.insert(
"component".into(),
json!({
"type": "application",
"bom-ref": format!("{app_name}@{app_version}"),
"name": app_name,
"version": app_version,
}),
);
let comps: Vec<Value> = components
.iter()
.map(|c| {
let mut m = Map::new();
m.insert("type".into(), json!("library"));
m.insert("bom-ref".into(), json!(c.purl));
m.insert("name".into(), json!(c.name));
m.insert("version".into(), json!(c.version));
if let Some(lic) = &c.license {
m.insert("licenses".into(), cyclonedx_licenses(lic));
}
m.insert("purl".into(), json!(c.purl));
if let Some(hex) = sri_to_sha512_hex(c.integrity.as_deref()) {
m.insert(
"hashes".into(),
json!([{ "alg": "SHA-512", "content": hex }]),
);
}
Value::Object(m)
})
.collect();
let doc = json!({
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"metadata": Value::Object(metadata),
"components": comps,
});
let mut s = serde_json::to_string_pretty(&doc).expect("serialize CycloneDX");
s.push('\n');
s
}
pub fn to_spdx(components: &[Component], name: &str, namespace: &str, created: &str) -> String {
let packages: Vec<Value> = components
.iter()
.map(|c| {
let mut m = Map::new();
m.insert(
"SPDXID".into(),
json!(format!(
"SPDXRef-Package-{}-{}",
spdx_id_fragment(&c.name),
spdx_id_fragment(&c.version)
)),
);
m.insert("name".into(), json!(c.name));
m.insert("versionInfo".into(), json!(c.version));
m.insert(
"downloadLocation".into(),
json!(c.resolved.as_deref().unwrap_or(NOASSERTION)),
);
m.insert("filesAnalyzed".into(), json!(false));
m.insert("licenseConcluded".into(), json!(NOASSERTION));
m.insert(
"licenseDeclared".into(),
json!(c.license.as_deref().unwrap_or(NOASSERTION)),
);
m.insert("copyrightText".into(), json!(NOASSERTION));
m.insert(
"externalRefs".into(),
json!([{
"referenceCategory": "PACKAGE-MANAGER",
"referenceType": "purl",
"referenceLocator": c.purl,
}]),
);
if let Some(hex) = sri_to_sha512_hex(c.integrity.as_deref()) {
m.insert(
"checksums".into(),
json!([{ "algorithm": "SHA512", "checksumValue": hex }]),
);
}
Value::Object(m)
})
.collect();
let doc = json!({
"spdxVersion": "SPDX-2.3",
"dataLicense": "CC0-1.0",
"SPDXID": "SPDXRef-DOCUMENT",
"name": name,
"documentNamespace": namespace,
"creationInfo": {
"created": created,
"creators": [format!("Tool: {}-{}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"))],
},
"packages": packages,
});
let mut s = serde_json::to_string_pretty(&doc).expect("serialize SPDX");
s.push('\n');
s
}
pub fn now_rfc3339() -> String {
let now = time::OffsetDateTime::now_utc();
now.replace_nanosecond(0)
.unwrap_or(now)
.format(&time::format_description::well_known::Rfc3339)
.expect("format current time as RFC 3339")
}
fn npm_purl(name: &str, version: &str) -> String {
let path = match name.strip_prefix('@') {
Some(rest) => format!("%40{rest}"),
None => name.to_string(),
};
format!("pkg:npm/{path}@{version}")
}
fn cyclonedx_licenses(license: &str) -> Value {
if is_spdx_expression(license) {
json!([{ "expression": license }])
} else {
json!([{ "license": { "id": license } }])
}
}
fn is_spdx_expression(license: &str) -> bool {
license.contains(" OR ")
|| license.contains(" AND ")
|| license.contains(" WITH ")
|| license.contains('(')
}
fn sri_to_sha512_hex(integrity: Option<&str>) -> Option<String> {
let b64 = integrity?.strip_prefix("sha512-")?;
let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
Some(bytes.iter().map(|b| format!("{b:02x}")).collect())
}
fn spdx_id_fragment(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
c
} else {
'-'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE: &str = r#"{
"name": "demo", "version": "1.0.0", "lockfileVersion": 3,
"packages": {
"": { "name": "demo", "version": "1.0.0" },
"node_modules/lit": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.3.3.tgz",
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/context": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@lit/context/-/context-1.1.6.tgz",
"license": "BSD-3-Clause"
},
"node_modules/mystery": { "version": "0.0.1" },
"node_modules/local-link": { "version": "1.0.0", "link": true }
}
}"#;
fn sample_components() -> Vec<Component> {
components(&Lockfile::parse(SAMPLE).unwrap())
}
#[test]
fn components_skip_root_and_links_and_build_purls() {
let c = sample_components();
let names: Vec<&str> = c.iter().map(|x| x.name.as_str()).collect();
assert_eq!(names, ["@lit/context", "lit", "mystery"]);
assert_eq!(c[0].purl, "pkg:npm/%40lit/context@1.1.6");
assert_eq!(c[1].purl, "pkg:npm/lit@3.3.3");
}
#[test]
fn components_cover_nested_and_aliased_entries_once() {
let lock = Lockfile::parse(
r#"{
"name": "ws", "version": "1.0.0", "lockfileVersion": 3,
"packages": {
"": { "name": "ws", "version": "1.0.0" },
"app": { "name": "@ws/app", "version": "1.0.0" },
"node_modules/lodash": { "version": "4.17.21" },
"app/node_modules/lodash": { "version": "4.17.11" },
"node_modules/dash": { "name": "lodash", "version": "4.17.11" }
}
}"#,
)
.unwrap();
let c = components(&lock);
let ids: Vec<String> = c
.iter()
.map(|x| format!("{}@{}", x.name, x.version))
.collect();
assert_eq!(ids, ["lodash@4.17.11", "lodash@4.17.21"]);
assert_eq!(c[0].purl, "pkg:npm/lodash@4.17.11");
}
#[test]
fn license_summary_groups_and_marks_unknown_as_noassertion() {
let summary = license_summary(&sample_components());
assert_eq!(
summary.get("BSD-3-Clause").unwrap(),
&["@lit/context@1.1.6", "lit@3.3.3"]
);
assert_eq!(summary.get("NOASSERTION").unwrap(), &["mystery@0.0.1"]);
}
#[test]
fn cyclonedx_has_required_shape_purls_licenses_and_sha512_hash() {
let json: Value =
serde_json::from_str(&to_cyclonedx(&sample_components(), "demo", "1.0.0", None))
.unwrap();
assert_eq!(json["bomFormat"], "CycloneDX");
assert_eq!(json["specVersion"], "1.6");
assert!(json["metadata"].get("timestamp").is_none());
let lit = &json["components"][1];
assert_eq!(lit["name"], "lit");
assert_eq!(lit["purl"], "pkg:npm/lit@3.3.3");
assert_eq!(lit["licenses"][0]["license"]["id"], "BSD-3-Clause");
let hex = lit["hashes"][0]["content"].as_str().unwrap();
assert_eq!(lit["hashes"][0]["alg"], "SHA-512");
assert_eq!(hex.len(), 128);
assert!(hex.bytes().all(|b| b.is_ascii_hexdigit()));
let mystery = &json["components"][2];
assert_eq!(mystery["name"], "mystery");
assert!(mystery.get("licenses").is_none());
assert!(mystery.get("hashes").is_none());
}
#[test]
fn cyclonedx_uses_expression_for_compound_licenses() {
let comps = vec![Component {
name: "dual".into(),
version: "1.0.0".into(),
purl: "pkg:npm/dual@1.0.0".into(),
license: Some("MIT OR Apache-2.0".into()),
resolved: None,
integrity: None,
}];
let json: Value = serde_json::from_str(&to_cyclonedx(&comps, "x", "1", None)).unwrap();
assert_eq!(
json["components"][0]["licenses"][0]["expression"],
"MIT OR Apache-2.0"
);
}
#[test]
fn spdx_has_required_shape_ids_purls_and_license() {
let json: Value = serde_json::from_str(&to_spdx(
&sample_components(),
"demo-sbom",
"https://spdx.example/demo",
"2026-01-01T00:00:00Z",
))
.unwrap();
assert_eq!(json["spdxVersion"], "SPDX-2.3");
assert_eq!(json["SPDXID"], "SPDXRef-DOCUMENT");
assert_eq!(json["creationInfo"]["created"], "2026-01-01T00:00:00Z");
let scoped = &json["packages"][0];
assert_eq!(scoped["SPDXID"], "SPDXRef-Package--lit-context-1.1.6");
assert_eq!(scoped["licenseDeclared"], "BSD-3-Clause");
assert_eq!(
scoped["externalRefs"][0]["referenceLocator"],
"pkg:npm/%40lit/context@1.1.6"
);
assert_eq!(json["packages"][2]["licenseDeclared"], "NOASSERTION");
}
#[test]
fn sri_decodes_to_64_byte_sha512_hex() {
let hex = sri_to_sha512_hex(Some(
"sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
))
.unwrap();
assert_eq!(hex.len(), 128); assert!(sri_to_sha512_hex(Some("sha1-deadbeef")).is_none()); assert!(sri_to_sha512_hex(None).is_none());
}
}