use std::path::Path;
use clap::ValueEnum;
use super::common::default_name;
use super::Res;
use crate::package_json::lock::Lockfile;
use crate::package_json::{License, PackageJson};
use crate::sbom;
#[derive(Clone, Copy, ValueEnum)]
pub(super) enum Format {
Summary,
Cyclonedx,
Spdx,
}
#[derive(Clone, Copy, ValueEnum)]
pub(super) enum LicenseSource {
Auto,
Lockfile,
Package,
}
pub(super) fn run(
dir: &Path,
format: Format,
name: Option<&str>,
license_source: LicenseSource,
) -> Res {
let lock_path = dir.join("package-lock.json");
let text = std::fs::read_to_string(&lock_path)
.map_err(|e| format!("reading {}: {e}", lock_path.display()))?;
let mut components = sbom::components(&Lockfile::parse(&text)?);
enrich_licenses(&mut components, dir, license_source);
let app = name
.map(str::to_string)
.unwrap_or_else(|| default_name(dir));
let out = match format {
Format::Summary => sbom::render_summary(&components),
Format::Cyclonedx => {
sbom::to_cyclonedx(&components, &app, "0.0.0", Some(&sbom::now_rfc3339()))
}
Format::Spdx => {
let created = sbom::now_rfc3339();
let namespace = format!("https://spdx.org/spdxdocs/{app}-{created}");
sbom::to_spdx(&components, &app, &namespace, &created)
}
};
print!("{out}");
Ok(())
}
fn enrich_licenses(components: &mut [sbom::Component], dir: &Path, source: LicenseSource) {
if matches!(source, LicenseSource::Lockfile) {
return;
}
for c in components.iter_mut() {
let want_pkg = match source {
LicenseSource::Package => true,
LicenseSource::Auto => c.license.is_none(),
LicenseSource::Lockfile => false,
};
if want_pkg {
if let Some(license) = package_license(dir, &c.name) {
c.license = Some(license);
}
}
}
}
fn package_license(dir: &Path, name: &str) -> Option<String> {
let path = dir.join("node_modules").join(name).join("package.json");
PackageJson::from_path(&path)
.ok()
.and_then(|pj| pj.license())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sbom::Component;
use tempfile::tempdir;
fn component(name: &str, license: Option<&str>) -> Component {
Component {
purl: format!("pkg:npm/{name}@1.0.0"),
name: name.to_string(),
version: "1.0.0".to_string(),
license: license.map(str::to_string),
resolved: None,
integrity: None,
}
}
fn write_installed(dir: &Path, name: &str, license: &str) {
let pkg = dir.join("node_modules").join(name);
std::fs::create_dir_all(&pkg).unwrap();
std::fs::write(
pkg.join("package.json"),
format!(r#"{{"name":"{name}","version":"1.0.0","license":"{license}"}}"#),
)
.unwrap();
}
#[test]
fn auto_fills_only_a_missing_license_from_package_json() {
let tmp = tempdir().unwrap();
write_installed(tmp.path(), "foo", "MIT");
let mut comps = vec![component("foo", None), component("bar", Some("Apache-2.0"))];
enrich_licenses(&mut comps, tmp.path(), LicenseSource::Auto);
assert_eq!(comps[0].license.as_deref(), Some("MIT")); assert_eq!(comps[1].license.as_deref(), Some("Apache-2.0")); }
#[test]
fn lockfile_source_never_reads_package_json() {
let tmp = tempdir().unwrap();
write_installed(tmp.path(), "foo", "MIT");
let mut comps = vec![component("foo", None)];
enrich_licenses(&mut comps, tmp.path(), LicenseSource::Lockfile);
assert_eq!(comps[0].license, None);
}
#[test]
fn package_source_prefers_package_json_but_keeps_lockfile_when_uninstalled() {
let tmp = tempdir().unwrap();
write_installed(tmp.path(), "foo", "MIT");
let mut comps = vec![
component("foo", Some("WTFPL")),
component("ghost", Some("ISC")),
];
enrich_licenses(&mut comps, tmp.path(), LicenseSource::Package);
assert_eq!(comps[0].license.as_deref(), Some("MIT")); assert_eq!(comps[1].license.as_deref(), Some("ISC")); }
}