use serde_json::{Map, Value, json};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::io::{self, Write};
use uuid::Uuid;
use crate::output_schema::{
Output, OutputFileInfo, OutputFileType, OutputPackage as Package,
OutputTopLevelDependency as TopLevelDependency,
};
use crate::utils::time::{convert_header_timestamp_to_iso_utc, fallback_iso_utc_timestamp};
use super::sbom::{self, InventoryEntry, SbomInventory};
use super::shared::{io_other, xml_escape};
const CYCLONEDX_SPEC_VERSION: &str = "1.7";
const CYCLONEDX_JSON_SCHEMA: &str = "http://cyclonedx.org/schema/bom-1.7.schema.json";
const CYCLONEDX_XML_NAMESPACE: &str = "http://cyclonedx.org/schema/bom/1.7";
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum LicenseAcknowledgement {
Declared,
Concluded,
}
impl LicenseAcknowledgement {
fn as_str(self) -> &'static str {
match self {
Self::Declared => "declared",
Self::Concluded => "concluded",
}
}
}
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
struct LicenseOccurrence {
location: String,
line: Option<u64>,
}
struct LicenseEvidence {
licenses: Vec<String>,
occurrences: Vec<LicenseOccurrence>,
}
fn build_license_evidence(files: &[OutputFileInfo]) -> HashMap<String, LicenseEvidence> {
let mut licenses: HashMap<&str, BTreeSet<String>> = HashMap::new();
let mut occurrences: HashMap<&str, BTreeSet<LicenseOccurrence>> = HashMap::new();
for file in files {
if file.file_type != OutputFileType::File
|| file.for_packages.is_empty()
|| file.license_detections.is_empty()
{
continue;
}
for detection in &file.license_detections {
let expression = detection.license_expression_spdx.trim();
if expression.is_empty() {
continue;
}
let detection_occurrences: Vec<LicenseOccurrence> = if detection.matches.is_empty() {
vec![LicenseOccurrence {
location: file.path.clone(),
line: None,
}]
} else {
detection
.matches
.iter()
.map(|m| LicenseOccurrence {
location: m
.from_file
.as_deref()
.filter(|from| !from.is_empty())
.unwrap_or(file.path.as_str())
.to_string(),
line: Some(m.start_line),
})
.collect()
};
for uid in &file.for_packages {
licenses
.entry(uid.as_str())
.or_default()
.insert(expression.to_string());
occurrences
.entry(uid.as_str())
.or_default()
.extend(detection_occurrences.iter().cloned());
}
}
}
licenses
.into_iter()
.map(|(uid, license_set)| {
let occurrence_set = occurrences.remove(uid).unwrap_or_default();
(
uid.to_string(),
LicenseEvidence {
licenses: license_set.into_iter().collect(),
occurrences: occurrence_set.into_iter().collect(),
},
)
})
.collect()
}
fn cyclonedx_scope(entry: &InventoryEntry<'_>) -> Option<&'static str> {
match entry.is_optional() {
Some(true) => Some("optional"),
Some(false) => Some("required"),
None => None,
}
}
pub(crate) fn write_cyclonedx_json(output: &Output, writer: &mut dyn Write) -> io::Result<()> {
let bom = build_cyclonedx_json(output);
serde_json::to_writer_pretty(&mut *writer, &bom).map_err(io_other)?;
writer.write_all(b"\n")
}
pub(crate) fn write_cyclonedx_xml(output: &Output, writer: &mut dyn Write) -> io::Result<()> {
let serial = format!("urn:uuid:{}", Uuid::new_v4());
let timestamp = output
.headers
.first()
.and_then(|h| convert_header_timestamp_to_iso_utc(&h.end_timestamp))
.unwrap_or_else(|| fallback_iso_utc_timestamp().to_string());
let inventory = sbom::build_inventory(output);
let component_refs = component_bom_refs_for(&inventory);
let evidence_by_uid = build_license_evidence(&output.files);
let dependency_graph =
build_dependency_graph(&inventory, &output.dependencies, &component_refs);
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str(&format!(
"<bom xmlns=\"{}\" serialNumber=\"{}\" version=\"1\">\n",
CYCLONEDX_XML_NAMESPACE,
xml_escape(&serial)
));
xml.push_str(" <metadata>\n");
xml.push_str(" <timestamp>");
xml.push_str(&xml_escape(×tamp));
xml.push_str("</timestamp>\n");
xml.push_str(" <tools>\n");
xml.push_str(" <tool><vendor>Provenant</vendor><name>Provenant</name><version>");
xml.push_str(crate::version::BUILD_VERSION);
xml.push_str("</version></tool>\n");
xml.push_str(" </tools>\n");
if let Some(root_idx) = select_root_package_index(&output.packages) {
let root_pkg = &output.packages[root_idx];
write_component_xml(
&mut xml,
root_pkg,
None,
"application",
None,
evidence_by_uid.get(root_pkg.package_uid.as_str()),
);
}
xml.push_str(" </metadata>\n");
xml.push_str(" <components>\n");
for (idx, entry) in inventory.entries.iter().enumerate() {
write_component_xml(
&mut xml,
entry.package(),
Some(&component_refs[idx]),
"library",
cyclonedx_scope(entry),
evidence_by_uid.get(entry.package().package_uid.as_str()),
);
}
xml.push_str(" </components>\n");
if !dependency_graph.is_empty() {
xml.push_str(" <dependencies>\n");
for (dep_ref, depends_on) in &dependency_graph {
xml.push_str(&format!(
" <dependency ref=\"{}\">\n",
xml_escape(dep_ref)
));
for child in depends_on {
xml.push_str(&format!(
" <dependency ref=\"{}\"/>\n",
xml_escape(child)
));
}
xml.push_str(" </dependency>\n");
}
xml.push_str(" </dependencies>\n");
}
xml.push_str("</bom>\n");
writer.write_all(xml.as_bytes())
}
fn write_component_xml(
xml: &mut String,
pkg: &Package,
bom_ref: Option<&str>,
component_type: &str,
scope: Option<&str>,
evidence: Option<&LicenseEvidence>,
) {
let name = pkg.name.as_deref().unwrap_or("unknown");
let version = pkg.version.as_deref().unwrap_or("unknown");
match bom_ref {
Some(bom_ref) => xml.push_str(&format!(
" <component type=\"{component_type}\" bom-ref=\"{}\">\n",
xml_escape(bom_ref)
)),
None => xml.push_str(&format!(" <component type=\"{component_type}\">\n")),
}
if let Some(author) = package_author(pkg) {
xml.push_str(" <author>");
xml.push_str(&xml_escape(&author));
xml.push_str("</author>\n");
}
xml.push_str(" <name>");
xml.push_str(&xml_escape(name));
xml.push_str("</name>\n");
xml.push_str(" <version>");
xml.push_str(&xml_escape(version));
xml.push_str("</version>\n");
if let Some(description) = &pkg.description {
xml.push_str(" <description>");
xml.push_str(&xml_escape(description));
xml.push_str("</description>\n");
}
if let Some(scope) = scope {
xml.push_str(" <scope>");
xml.push_str(&xml_escape(scope));
xml.push_str("</scope>\n");
}
let hashes = component_hashes(pkg);
if !hashes.is_empty() {
xml.push_str(" <hashes>\n");
for (alg, content) in hashes {
xml.push_str(" <hash alg=\"");
xml.push_str(alg);
xml.push_str("\">");
xml.push_str(&xml_escape(&content));
xml.push_str("</hash>\n");
}
xml.push_str(" </hashes>\n");
}
if let Some((license_expression, acknowledgement)) = cyclonedx_license_expression(pkg) {
xml.push_str(&format!(
" <licenses><expression acknowledgement=\"{}\">",
acknowledgement.as_str()
));
xml.push_str(&xml_escape(&license_expression));
xml.push_str("</expression></licenses>\n");
}
if let Some(purl) = &pkg.purl {
xml.push_str(" <purl>");
xml.push_str(&xml_escape(purl));
xml.push_str("</purl>\n");
}
let external_refs = component_external_references(pkg);
if !external_refs.is_empty() {
xml.push_str(" <externalReferences>\n");
for (ref_type, url) in external_refs {
xml.push_str(" <reference type=\"");
xml.push_str(ref_type);
xml.push_str("\"><url>");
xml.push_str(&xml_escape(&url));
xml.push_str("</url></reference>\n");
}
xml.push_str(" </externalReferences>\n");
}
if let Some(evidence) = evidence
&& (!evidence.occurrences.is_empty() || !evidence.licenses.is_empty())
{
xml.push_str(" <evidence>\n");
if !evidence.occurrences.is_empty() {
xml.push_str(" <occurrences>\n");
for occurrence in &evidence.occurrences {
xml.push_str(" <occurrence><location>");
xml.push_str(&xml_escape(&occurrence.location));
xml.push_str("</location>");
if let Some(line) = occurrence.line {
xml.push_str(&format!("<line>{line}</line>"));
}
xml.push_str("</occurrence>\n");
}
xml.push_str(" </occurrences>\n");
}
if !evidence.licenses.is_empty() {
xml.push_str(" <licenses>");
for expression in &evidence.licenses {
xml.push_str("<expression>");
xml.push_str(&xml_escape(expression));
xml.push_str("</expression>");
}
xml.push_str("</licenses>\n");
}
xml.push_str(" </evidence>\n");
}
xml.push_str(" </component>\n");
}
fn build_cyclonedx_json(output: &Output) -> Value {
let timestamp = output
.headers
.first()
.and_then(|h| convert_header_timestamp_to_iso_utc(&h.end_timestamp))
.unwrap_or_else(|| fallback_iso_utc_timestamp().to_string());
let inventory = sbom::build_inventory(output);
let component_refs = component_bom_refs_for(&inventory);
let evidence_by_uid = build_license_evidence(&output.files);
let components = inventory
.entries
.iter()
.enumerate()
.map(|(idx, entry)| {
let mut obj = Map::new();
obj.insert("type".to_string(), Value::String("library".to_string()));
component_json_fields(
&mut obj,
entry.package(),
Some(&component_refs[idx]),
cyclonedx_scope(entry),
evidence_by_uid.get(entry.package().package_uid.as_str()),
);
Value::Object(obj)
})
.collect::<Vec<_>>();
let dependencies = build_dependency_graph(&inventory, &output.dependencies, &component_refs)
.into_iter()
.map(|(reference, depends_on)| {
json!({
"ref": reference,
"dependsOn": depends_on.into_iter().collect::<Vec<_>>(),
})
})
.collect::<Vec<_>>();
if components.is_empty() && dependencies.is_empty() {
json!({
"$schema": CYCLONEDX_JSON_SCHEMA,
"bomFormat": "CycloneDX",
"specVersion": CYCLONEDX_SPEC_VERSION,
"version": 1,
"components": [],
"dependencies": [],
})
} else {
let mut metadata = Map::new();
metadata.insert("timestamp".to_string(), Value::String(timestamp));
metadata.insert(
"tools".to_string(),
json!([{ "name": "Provenant", "version": crate::version::BUILD_VERSION }]),
);
if let Some(root_idx) = select_root_package_index(&output.packages) {
let mut root_obj = Map::new();
root_obj.insert("type".to_string(), Value::String("application".to_string()));
let root_pkg = &output.packages[root_idx];
component_json_fields(
&mut root_obj,
root_pkg,
None,
None,
evidence_by_uid.get(root_pkg.package_uid.as_str()),
);
metadata.insert("component".to_string(), Value::Object(root_obj));
}
json!({
"$schema": CYCLONEDX_JSON_SCHEMA,
"bomFormat": "CycloneDX",
"specVersion": CYCLONEDX_SPEC_VERSION,
"serialNumber": format!("urn:uuid:{}", Uuid::new_v4()),
"version": 1,
"metadata": metadata,
"components": components,
"dependencies": dependencies,
})
}
}
fn component_json_fields(
obj: &mut Map<String, Value>,
pkg: &Package,
bom_ref: Option<&str>,
scope: Option<&str>,
evidence: Option<&LicenseEvidence>,
) {
if let Some(bom_ref) = bom_ref {
obj.insert("bom-ref".to_string(), Value::String(bom_ref.to_string()));
}
obj.insert(
"name".to_string(),
Value::String(pkg.name.clone().unwrap_or_else(|| "unknown".to_string())),
);
obj.insert(
"version".to_string(),
Value::String(pkg.version.clone().unwrap_or_else(|| "unknown".to_string())),
);
if let Some(description) = &pkg.description {
obj.insert(
"description".to_string(),
Value::String(description.clone()),
);
}
if let Some(author) = package_author(pkg) {
obj.insert("author".to_string(), Value::String(author));
}
if let Some(scope) = scope {
obj.insert("scope".to_string(), Value::String(scope.to_string()));
}
if let Some(purl) = &pkg.purl {
obj.insert("purl".to_string(), Value::String(purl.clone()));
}
let hashes = component_hashes(pkg)
.into_iter()
.map(|(alg, content)| json!({"alg": alg, "content": content}))
.collect::<Vec<_>>();
if !hashes.is_empty() {
obj.insert("hashes".to_string(), Value::Array(hashes));
}
if let Some((license_expression, acknowledgement)) = cyclonedx_license_expression(pkg) {
obj.insert(
"licenses".to_string(),
Value::Array(vec![json!({
"expression": license_expression,
"acknowledgement": acknowledgement.as_str(),
})]),
);
}
let external_refs = component_external_references(pkg)
.into_iter()
.map(|(ref_type, url)| json!({"type": ref_type, "url": url}))
.collect::<Vec<_>>();
if !external_refs.is_empty() {
obj.insert(
"externalReferences".to_string(),
Value::Array(external_refs),
);
}
if let Some(evidence) = evidence {
let mut evidence_obj = Map::new();
if !evidence.occurrences.is_empty() {
let occurrences = evidence
.occurrences
.iter()
.map(|occurrence| {
let mut entry = Map::new();
entry.insert(
"location".to_string(),
Value::String(occurrence.location.clone()),
);
if let Some(line) = occurrence.line {
entry.insert("line".to_string(), json!(line));
}
Value::Object(entry)
})
.collect();
evidence_obj.insert("occurrences".to_string(), Value::Array(occurrences));
}
if !evidence.licenses.is_empty() {
let licenses = evidence
.licenses
.iter()
.map(|expression| json!({ "expression": expression }))
.collect();
evidence_obj.insert("licenses".to_string(), Value::Array(licenses));
}
if !evidence_obj.is_empty() {
obj.insert("evidence".to_string(), Value::Object(evidence_obj));
}
}
}
fn component_bom_refs_for(inventory: &SbomInventory<'_>) -> Vec<String> {
component_bom_refs(inventory.packages())
}
fn component_bom_refs<'a>(packages: impl IntoIterator<Item = &'a Package>) -> Vec<String> {
let packages: Vec<&Package> = packages.into_iter().collect();
let mut purl_counts: HashMap<&str, usize> = HashMap::new();
for pkg in &packages {
if let Some(purl) = pkg.purl.as_deref() {
*purl_counts.entry(purl).or_default() += 1;
}
}
packages
.iter()
.enumerate()
.map(|(idx, pkg)| match pkg.purl.as_deref() {
Some(purl) if purl_counts.get(purl).copied().unwrap_or(0) == 1 => purl.to_string(),
Some(_) => pkg.package_uid.clone(),
None if !pkg.package_uid.is_empty() => pkg.package_uid.clone(),
None => format!("component-{}", idx + 1),
})
.collect()
}
fn select_root_package_index(packages: &[Package]) -> Option<usize> {
if packages.len() == 1 {
return Some(0);
}
let mut depths: Vec<(usize, usize)> = packages
.iter()
.enumerate()
.filter_map(|(idx, pkg)| package_min_datafile_depth(pkg).map(|depth| (idx, depth)))
.collect();
depths.sort_by_key(|(_, depth)| *depth);
match depths.as_slice() {
[(idx, shallowest), rest @ ..] if rest.iter().all(|(_, depth)| depth > shallowest) => {
Some(*idx)
}
_ => None,
}
}
fn package_min_datafile_depth(pkg: &Package) -> Option<usize> {
pkg.datafile_paths
.iter()
.map(|path| {
path.replace('\\', "/")
.split('/')
.filter(|component| !component.is_empty())
.count()
})
.min()
}
fn build_dependency_graph(
inventory: &SbomInventory<'_>,
dependencies: &[TopLevelDependency],
component_refs: &[String],
) -> BTreeMap<String, BTreeSet<String>> {
let mut graph: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for bom_ref in component_refs {
graph.entry(bom_ref.clone()).or_default();
}
for (owner_ref, children) in sbom::dependency_edges(inventory, dependencies, component_refs) {
graph.entry(owner_ref).or_default().extend(children);
}
graph
}
fn cyclonedx_license_expression(pkg: &Package) -> Option<(String, LicenseAcknowledgement)> {
pkg.declared_license_expression_spdx
.clone()
.or_else(|| pkg.declared_license_expression.clone())
.map(|expression| (expression, LicenseAcknowledgement::Declared))
.or_else(|| {
pkg.license_detections.first().map(|d| {
(
d.license_expression_spdx.clone(),
LicenseAcknowledgement::Concluded,
)
})
})
}
fn package_author(pkg: &Package) -> Option<String> {
pkg.parties.iter().find_map(|party| party.name.clone())
}
fn component_hashes(pkg: &Package) -> Vec<(&'static str, String)> {
let mut hashes = Vec::new();
if let Some(sha1) = &pkg.sha1 {
hashes.push(("SHA-1", sha1.clone()));
}
if let Some(sha256) = &pkg.sha256 {
hashes.push(("SHA-256", sha256.clone()));
}
if let Some(sha512) = &pkg.sha512 {
hashes.push(("SHA-512", sha512.clone()));
}
if let Some(md5) = &pkg.md5 {
hashes.push(("MD5", md5.clone()));
}
hashes
}
fn component_external_references(pkg: &Package) -> Vec<(&'static str, String)> {
let mut refs = Vec::new();
let mut seen = HashSet::new();
let mut push_ref = |ref_type: &'static str, url: &Option<String>| {
if let Some(url) = url
&& seen.insert((ref_type, url.clone()))
{
refs.push((ref_type, url.clone()));
}
};
push_ref("bom", &pkg.api_data_url);
push_ref("issue-tracker", &pkg.bug_tracking_url);
push_ref("distribution", &pkg.download_url);
push_ref("distribution", &pkg.repository_download_url);
push_ref("website", &pkg.homepage_url);
push_ref("website", &pkg.repository_homepage_url);
push_ref("vcs", &pkg.vcs_url);
refs
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::DatasourceId;
use crate::output_schema::OutputDatasourceId;
fn sample_package(name: &str, version: &str, uid: &str) -> Package {
Package {
package_type: None,
namespace: None,
name: Some(name.to_string()),
version: Some(version.to_string()),
qualifiers: None,
subpath: None,
primary_language: None,
description: None,
release_date: None,
parties: vec![],
keywords: vec![],
homepage_url: None,
download_url: None,
size: None,
sha1: None,
md5: None,
sha256: None,
sha512: None,
bug_tracking_url: None,
code_view_url: None,
vcs_url: None,
copyright: None,
holder: None,
declared_license_expression: None,
declared_license_expression_spdx: None,
license_detections: vec![],
other_license_expression: None,
other_license_expression_spdx: None,
other_license_detections: vec![],
extracted_license_statement: None,
notice_text: None,
source_packages: vec![],
is_private: false,
is_virtual: false,
extra_data: None,
repository_homepage_url: None,
repository_download_url: None,
api_data_url: None,
purl: Some(format!("pkg:hex/{name}@{version}")),
package_uid: uid.to_string(),
datafile_paths: vec![],
datasource_ids: vec![],
}
}
fn sample_dep(purl: &str, for_package_uid: Option<&str>) -> TopLevelDependency {
TopLevelDependency {
purl: Some(purl.to_string()),
extracted_requirement: None,
scope: None,
is_runtime: Some(true),
is_optional: Some(false),
is_pinned: Some(true),
is_direct: Some(true),
resolved_package: None,
extra_data: None,
dependency_uid: format!("{purl}?uuid=dep"),
for_package_uid: for_package_uid.map(str::to_string),
datafile_path: "mix.lock".to_string(),
datasource_id: OutputDatasourceId::from(DatasourceId::HexMixLock),
namespace: None,
}
}
#[test]
fn dependency_graph_dedups_shared_deps_across_owners() {
let pkg_a = sample_package("a", "0.1.0", "uid-a");
let pkg_b = sample_package("b", "0.1.0", "uid-b");
let output = Output {
summary: None,
tallies: None,
tallies_of_key_files: None,
tallies_by_facet: None,
headers: vec![],
packages: vec![pkg_a, pkg_b],
dependencies: vec![
sample_dep("pkg:hex/jason@1.4.1", Some("uid-a")),
sample_dep("pkg:hex/jason@1.4.1", Some("uid-b")),
sample_dep("pkg:hex/plug@1.15.0", Some("uid-a")),
sample_dep("pkg:hex/plug@1.15.0", Some("uid-b")),
],
license_detections: vec![],
files: vec![],
license_references: vec![],
license_rule_references: vec![],
};
let inventory = sbom::build_inventory(&output);
let refs = component_bom_refs_for(&inventory);
let graph = build_dependency_graph(&inventory, &output.dependencies, &refs);
assert_eq!(graph.len(), 4);
assert_eq!(
graph["pkg:hex/a@0.1.0"],
BTreeSet::from([
"pkg:hex/jason@1.4.1".to_string(),
"pkg:hex/plug@1.15.0".to_string()
])
);
assert_eq!(
graph["pkg:hex/b@0.1.0"],
BTreeSet::from([
"pkg:hex/jason@1.4.1".to_string(),
"pkg:hex/plug@1.15.0".to_string()
])
);
assert!(graph["pkg:hex/jason@1.4.1"].is_empty());
assert!(graph["pkg:hex/plug@1.15.0"].is_empty());
for (r, deps) in &graph {
assert!(!deps.contains(r), "self-edge on {r}");
for child in deps {
assert!(graph.contains_key(child), "dangling dependsOn ref: {child}");
}
}
}
#[test]
fn component_bom_refs_disambiguate_duplicate_purls() {
let mut a = sample_package("shared", "1.0.0", "uid-1");
let mut b = sample_package("shared", "1.0.0", "uid-2");
a.purl = Some("pkg:hex/shared@1.0.0".to_string());
b.purl = Some("pkg:hex/shared@1.0.0".to_string());
let refs = component_bom_refs([&a, &b]);
assert_eq!(refs[0], "uid-1");
assert_eq!(refs[1], "uid-2");
assert_ne!(refs[0], refs[1]);
}
#[test]
fn depends_on_uses_component_bom_ref_when_target_purl_is_unique() {
let owner = sample_package("app", "1.0.0", "uid-app");
let mut lib = sample_package("lib", "2.0.0", "uid-lib");
lib.purl = Some("pkg:hex/lib@2.0.0".to_string());
let output = Output {
summary: None,
tallies: None,
tallies_of_key_files: None,
tallies_by_facet: None,
headers: vec![],
packages: vec![owner, lib],
dependencies: vec![sample_dep("pkg:hex/lib@2.0.0", Some("uid-app"))],
license_detections: vec![],
files: vec![],
license_references: vec![],
license_rule_references: vec![],
};
let inventory = sbom::build_inventory(&output);
let refs = component_bom_refs_for(&inventory);
let graph = build_dependency_graph(&inventory, &output.dependencies, &refs);
assert_eq!(
graph["pkg:hex/app@1.0.0"],
BTreeSet::from(["pkg:hex/lib@2.0.0".to_string()])
);
}
#[test]
fn depends_on_uses_disambiguated_bom_refs_when_target_purl_collides() {
let owner = sample_package("app", "1.0.0", "uid-app");
let mut shared_a = sample_package("shared", "1.0.0", "uid-shared-a");
let mut shared_b = sample_package("shared", "1.0.0", "uid-shared-b");
shared_a.purl = Some("pkg:hex/shared@1.0.0".to_string());
shared_b.purl = Some("pkg:hex/shared@1.0.0".to_string());
let output = Output {
summary: None,
tallies: None,
tallies_of_key_files: None,
tallies_by_facet: None,
headers: vec![],
packages: vec![owner, shared_a, shared_b],
dependencies: vec![sample_dep("pkg:hex/shared@1.0.0", Some("uid-app"))],
license_detections: vec![],
files: vec![],
license_references: vec![],
license_rule_references: vec![],
};
let inventory = sbom::build_inventory(&output);
let refs = component_bom_refs_for(&inventory);
assert_eq!(refs[1], "uid-shared-a");
assert_eq!(refs[2], "uid-shared-b");
let graph = build_dependency_graph(&inventory, &output.dependencies, &refs);
assert_eq!(
graph["pkg:hex/app@1.0.0"],
BTreeSet::from(["uid-shared-a".to_string(), "uid-shared-b".to_string()])
);
assert!(!graph["pkg:hex/app@1.0.0"].contains("pkg:hex/shared@1.0.0"));
}
fn with_datafile_path(mut pkg: Package, path: &str) -> Package {
pkg.datafile_paths = vec![path.to_string()];
pkg
}
#[test]
fn select_root_package_index_picks_sole_package() {
let only = sample_package("app", "1.0.0", "uid-app");
assert_eq!(select_root_package_index(&[only]), Some(0));
}
#[test]
fn select_root_package_index_picks_unique_shallowest_workspace_root() {
let root = with_datafile_path(
sample_package("workspace", "1.0.0", "uid-root"),
"package.json",
);
let member_a = with_datafile_path(
sample_package("widget-a", "1.0.0", "uid-a"),
"packages/widget-a/package.json",
);
let member_b = with_datafile_path(
sample_package("widget-b", "1.0.0", "uid-b"),
"packages/widget-b/package.json",
);
let packages = vec![member_a, root, member_b];
assert_eq!(select_root_package_index(&packages), Some(1));
}
#[test]
fn select_root_package_index_omits_when_multiple_roots_tie() {
let member_a = with_datafile_path(
sample_package("widget-a", "1.0.0", "uid-a"),
"packages/widget-a/package.json",
);
let member_b = with_datafile_path(
sample_package("widget-b", "1.0.0", "uid-b"),
"packages/widget-b/package.json",
);
let packages = vec![member_a, member_b];
assert_eq!(select_root_package_index(&packages), None);
}
#[test]
fn select_root_package_index_omits_when_no_packages() {
assert_eq!(select_root_package_index(&[]), None);
}
#[test]
fn select_root_package_index_picks_shallowest_root_with_windows_style_paths() {
let root = with_datafile_path(
sample_package("workspace", "1.0.0", "uid-root"),
"package.json",
);
let member = with_datafile_path(
sample_package("member", "1.0.0", "uid-member"),
"packages\\member\\package.json",
);
let packages = vec![member, root];
assert_eq!(select_root_package_index(&packages), Some(1));
}
fn package_from_data(data: crate::models::PackageData) -> Package {
Package::from(&crate::models::Package::from_package_data(
&data,
"package.json".to_string(),
))
}
#[test]
fn license_expression_tags_parser_declared_as_declared() {
let pkg = package_from_data(crate::models::PackageData {
package_type: Some(crate::models::PackageType::Npm),
declared_license_expression: Some("mit".to_string()),
declared_license_expression_spdx: Some("MIT".to_string()),
..Default::default()
});
assert_eq!(
cyclonedx_license_expression(&pkg),
Some(("MIT".to_string(), LicenseAcknowledgement::Declared))
);
}
#[test]
fn license_expression_tags_source_detection_as_concluded() {
let pkg = package_from_data(crate::models::PackageData {
package_type: Some(crate::models::PackageType::Npm),
license_detections: vec![crate::models::LicenseDetection {
license_expression: "apache-2.0".to_string(),
license_expression_spdx: "Apache-2.0".to_string(),
matches: vec![],
detection_log: vec![],
identifier: String::new(),
}],
..Default::default()
});
assert_eq!(
cyclonedx_license_expression(&pkg),
Some(("Apache-2.0".to_string(), LicenseAcknowledgement::Concluded))
);
}
#[test]
fn license_expression_prefers_declared_over_detection() {
let pkg = package_from_data(crate::models::PackageData {
package_type: Some(crate::models::PackageType::Npm),
declared_license_expression_spdx: Some("MIT".to_string()),
license_detections: vec![crate::models::LicenseDetection {
license_expression: "apache-2.0".to_string(),
license_expression_spdx: "Apache-2.0".to_string(),
matches: vec![],
detection_log: vec![],
identifier: String::new(),
}],
..Default::default()
});
assert_eq!(
cyclonedx_license_expression(&pkg),
Some(("MIT".to_string(), LicenseAcknowledgement::Declared))
);
}
#[test]
fn json_emits_spec_version_schema_and_license_acknowledgement() {
let mut declared = package_from_data(crate::models::PackageData {
package_type: Some(crate::models::PackageType::Npm),
name: Some("declared-pkg".to_string()),
version: Some("1.0.0".to_string()),
purl: Some("pkg:npm/declared-pkg@1.0.0".to_string()),
declared_license_expression_spdx: Some("MIT".to_string()),
..Default::default()
});
declared.package_uid = "uid-declared".to_string();
let output = Output {
summary: None,
tallies: None,
tallies_of_key_files: None,
tallies_by_facet: None,
headers: vec![],
packages: vec![declared],
dependencies: vec![],
license_detections: vec![],
files: vec![],
license_references: vec![],
license_rule_references: vec![],
};
let bom = build_cyclonedx_json(&output);
assert_eq!(bom["specVersion"], "1.7");
assert_eq!(bom["$schema"], CYCLONEDX_JSON_SCHEMA);
assert_eq!(bom["bomFormat"], "CycloneDX");
let license = &bom["components"][0]["licenses"][0];
assert_eq!(license["expression"], "MIT");
assert_eq!(license["acknowledgement"], "declared");
}
#[test]
fn license_evidence_maps_file_detections_to_owning_package() {
use crate::models::{FileInfo, FileType, LicenseDetection, LineNumber, Match, MatchScore};
let detection = |spdx: &str, path: &str, line: usize| LicenseDetection {
license_expression: spdx.to_lowercase(),
license_expression_spdx: spdx.to_string(),
matches: vec![Match {
license_expression: spdx.to_lowercase(),
license_expression_spdx: spdx.to_string(),
from_file: Some(path.to_string()),
start_line: LineNumber::new(line).unwrap(),
end_line: LineNumber::new(line).unwrap(),
matcher: crate::license_detection::MatcherKind::Hash,
score: MatchScore::MAX,
matched_length: Some(1),
match_coverage: Some(100.0),
rule_relevance: Some(100),
rule_identifier: String::new(),
rule_url: None,
matched_text: None,
referenced_filenames: None,
matched_text_diagnostics: None,
}],
detection_log: vec![],
identifier: String::new(),
};
let make_file = |path: &str, det: LicenseDetection| {
let mut file = FileInfo::new(
path.to_string(),
path.to_string(),
String::new(),
path.to_string(),
FileType::File,
None,
None,
1,
None,
None,
None,
None,
None,
vec![],
None,
vec![det],
vec![],
vec![],
vec![],
vec![],
vec![],
vec![],
vec![],
vec![],
);
file.for_packages = vec![crate::models::PackageUid::from_raw(
"uid-widget".to_string(),
)];
crate::output_schema::OutputFileInfo::from(&file)
};
let files = vec![
make_file("scan/LICENSE", detection("MIT", "scan/LICENSE", 1)),
make_file(
"scan/src/main.js",
detection("Apache-2.0", "scan/src/main.js", 5),
),
];
let evidence = build_license_evidence(&files);
let widget = evidence.get("uid-widget").expect("owning package evidence");
assert_eq!(
widget.licenses,
vec!["Apache-2.0".to_string(), "MIT".to_string()]
);
assert_eq!(
widget.occurrences,
vec![
LicenseOccurrence {
location: "scan/LICENSE".to_string(),
line: Some(1)
},
LicenseOccurrence {
location: "scan/src/main.js".to_string(),
line: Some(5)
},
]
);
assert!(build_license_evidence(&[]).is_empty());
}
#[test]
fn empty_document_carries_spec_version_and_schema() {
let output = Output {
summary: None,
tallies: None,
tallies_of_key_files: None,
tallies_by_facet: None,
headers: vec![],
packages: vec![],
dependencies: vec![],
license_detections: vec![],
files: vec![],
license_references: vec![],
license_rule_references: vec![],
};
let bom = build_cyclonedx_json(&output);
assert_eq!(bom["specVersion"], "1.7");
assert_eq!(bom["$schema"], CYCLONEDX_JSON_SCHEMA);
}
#[test]
fn metadata_component_has_no_bom_ref_to_avoid_duplicating_the_unique_key() {
let root = with_datafile_path(
sample_package("workspace", "1.0.0", "uid-root"),
"package.json",
);
let member = with_datafile_path(
sample_package("widget-a", "1.0.0", "uid-a"),
"packages/widget-a/package.json",
);
let output = Output {
summary: None,
tallies: None,
tallies_of_key_files: None,
tallies_by_facet: None,
headers: vec![],
packages: vec![root, member],
dependencies: vec![],
license_detections: vec![],
files: vec![],
license_references: vec![],
license_rule_references: vec![],
};
let bom = build_cyclonedx_json(&output);
assert!(bom["metadata"]["component"]["bom-ref"].is_null());
assert_eq!(bom["metadata"]["component"]["type"], "application");
assert_eq!(bom["metadata"]["component"]["name"], "workspace");
assert!(bom["components"][0]["bom-ref"].is_string());
}
}