use crate::sbom::Sbom;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SupplyChainError {
#[error("unknown component: {0}")]
UnknownComponent(String),
#[error("empty dependency graph")]
EmptyGraph,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DependencyGraph {
nodes: BTreeSet<String>,
forward: BTreeMap<String, BTreeSet<String>>,
reverse: BTreeMap<String, BTreeSet<String>>,
}
impl DependencyGraph {
pub fn from_sbom(sbom: &Sbom) -> Self {
let mut nodes: BTreeSet<String> = BTreeSet::new();
let mut forward: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut reverse: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for component in &sbom.components {
nodes.insert(component.bom_ref.clone());
}
for dep in &sbom.dependencies {
nodes.insert(dep.dependent.clone());
for target in &dep.depends_on {
nodes.insert(target.clone());
forward
.entry(dep.dependent.clone())
.or_default()
.insert(target.clone());
reverse
.entry(target.clone())
.or_default()
.insert(dep.dependent.clone());
}
}
DependencyGraph {
nodes,
forward,
reverse,
}
}
pub fn node_count(&self) -> usize {
self.nodes.len()
}
pub fn edge_count(&self) -> usize {
self.forward.values().map(BTreeSet::len).sum()
}
pub fn contains(&self, bom_ref: &str) -> bool {
self.nodes.contains(bom_ref)
}
pub fn nodes(&self) -> Vec<String> {
self.nodes.iter().cloned().collect()
}
pub fn direct_dependencies(&self, bom_ref: &str) -> Vec<String> {
self.forward
.get(bom_ref)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default()
}
pub fn direct_dependents(&self, bom_ref: &str) -> Vec<String> {
self.reverse
.get(bom_ref)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default()
}
pub fn transitive_dependencies(&self, bom_ref: &str) -> Vec<String> {
self.closure(bom_ref, &self.forward)
}
pub fn transitive_dependents(&self, bom_ref: &str) -> Vec<String> {
self.closure(bom_ref, &self.reverse)
}
fn closure(&self, start: &str, adjacency: &BTreeMap<String, BTreeSet<String>>) -> Vec<String> {
let mut visited: BTreeSet<String> = BTreeSet::new();
let mut stack: Vec<String> = adjacency
.get(start)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default();
while let Some(node) = stack.pop() {
if visited.insert(node.clone()) {
if let Some(children) = adjacency.get(&node) {
stack.extend(children.iter().cloned());
}
}
}
visited.remove(start);
visited.into_iter().collect()
}
pub fn depth(&self, root: &str) -> usize {
let mut on_path: BTreeSet<String> = BTreeSet::new();
self.depth_inner(root, &mut on_path)
}
fn depth_inner(&self, node: &str, on_path: &mut BTreeSet<String>) -> usize {
if !on_path.insert(node.to_string()) {
return 0;
}
let deepest = self
.forward
.get(node)
.map(|children| {
children
.iter()
.map(|child| 1 + self.depth_inner(child, on_path))
.max()
.unwrap_or(0)
})
.unwrap_or(0);
on_path.remove(node);
deepest
}
pub fn is_cyclic(&self) -> bool {
let mut color: BTreeMap<String, u8> = BTreeMap::new();
for node in &self.nodes {
if color.get(node).copied().unwrap_or(0) != 0 {
continue;
}
let mut stack: Vec<(String, bool)> = vec![(node.clone(), false)];
while let Some((current, expanded)) = stack.pop() {
if expanded {
color.insert(current, 2);
continue;
}
color.insert(current.clone(), 1);
stack.push((current.clone(), true));
if let Some(children) = self.forward.get(¤t) {
for child in children {
match color.get(child).copied().unwrap_or(0) {
1 => return true, 0 => stack.push((child.clone(), false)),
_ => {}
}
}
}
}
}
false
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlastRadius {
pub component: String,
pub directly_impacted: usize,
pub transitively_impacted: usize,
pub impacted: Vec<String>,
}
pub fn blast_radius(
graph: &DependencyGraph,
bom_ref: &str,
) -> Result<BlastRadius, SupplyChainError> {
if !graph.contains(bom_ref) {
return Err(SupplyChainError::UnknownComponent(bom_ref.to_string()));
}
let impacted = graph.transitive_dependents(bom_ref);
Ok(BlastRadius {
component: bom_ref.to_string(),
directly_impacted: graph.direct_dependents(bom_ref).len(),
transitively_impacted: impacted.len(),
impacted,
})
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SupplierConcentration {
pub supplier: String,
pub component_count: usize,
pub share: f64,
pub components: Vec<String>,
}
pub const UNKNOWN_SUPPLIER: &str = "UNKNOWN";
pub fn supplier_concentration(sbom: &Sbom) -> Vec<SupplierConcentration> {
let total = sbom.components.len();
if total == 0 {
return Vec::new();
}
let mut buckets: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for component in &sbom.components {
let supplier = component
.supplier
.as_ref()
.map(|s| s.name.trim())
.filter(|name| !name.is_empty())
.unwrap_or(UNKNOWN_SUPPLIER)
.to_string();
buckets
.entry(supplier)
.or_default()
.insert(component.bom_ref.clone());
}
let mut out: Vec<SupplierConcentration> = buckets
.into_iter()
.map(|(supplier, refs)| {
let component_count = refs.len();
SupplierConcentration {
supplier,
component_count,
share: component_count as f64 / total as f64,
components: refs.into_iter().collect(),
}
})
.collect();
out.sort_by(|a, b| {
b.component_count
.cmp(&a.component_count)
.then_with(|| a.supplier.cmp(&b.supplier))
});
out
}
pub fn single_points_of_failure(
graph: &DependencyGraph,
_sbom: &Sbom,
threshold: usize,
) -> Vec<String> {
let mut scored: Vec<(usize, String)> = graph
.nodes()
.into_iter()
.filter_map(|node| {
let impact = graph.transitive_dependents(&node).len();
if impact >= threshold {
Some((impact, node))
} else {
None
}
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
scored.into_iter().map(|(_, node)| node).collect()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProvenanceAttestation {
pub sbom_address: String,
pub primary_component: Option<String>,
pub builder: Option<String>,
pub attested_supplier: Option<String>,
pub dependency_edges: usize,
pub generated_from_receipt: Option<String>,
}
pub fn attest_provenance(sbom: &Sbom, receipt_ref: Option<&str>) -> ProvenanceAttestation {
let dependency_edges = sbom.dependencies.iter().map(|d| d.depends_on.len()).sum();
let builder = sbom
.metadata
.tools
.first()
.map(|tool| tool.name.clone())
.filter(|name| !name.trim().is_empty());
let attested_supplier = sbom
.metadata
.supplier
.as_ref()
.map(|s| s.name.clone())
.filter(|name| !name.trim().is_empty());
ProvenanceAttestation {
sbom_address: sbom.content_address().as_hex().to_string(),
primary_component: sbom.metadata.primary_component.clone(),
builder,
attested_supplier,
dependency_edges,
generated_from_receipt: receipt_ref
.map(str::trim)
.filter(|r| !r.is_empty())
.map(|r| r.to_string()),
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SupplyChainReport {
pub component_count: usize,
pub edge_count: usize,
pub max_depth: usize,
pub is_cyclic: bool,
pub supplier_count: usize,
pub top_supplier_share: f64,
pub spof_count: usize,
}
pub fn build_report(sbom: &Sbom, spof_threshold: usize) -> SupplyChainReport {
let graph = DependencyGraph::from_sbom(sbom);
let max_depth = graph
.nodes()
.iter()
.map(|node| graph.depth(node))
.max()
.unwrap_or(0);
let concentration = supplier_concentration(sbom);
let top_supplier_share = concentration.first().map(|c| c.share).unwrap_or(0.0);
SupplyChainReport {
component_count: graph.node_count(),
edge_count: graph.edge_count(),
max_depth,
is_cyclic: graph.is_cyclic(),
supplier_count: concentration.len(),
top_supplier_share,
spof_count: single_points_of_failure(&graph, sbom, spof_threshold).len(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sbom::{Component, Dependency, Sbom, SbomFormat, Supplier, Tool};
fn supplied(bom_ref: &str, supplier: &str) -> Component {
let mut c = Component::library(bom_ref, bom_ref, "1.0");
c.supplier = Some(Supplier {
name: supplier.to_string(),
url: None,
contact: None,
});
c
}
fn diamond_sbom() -> Sbom {
let mut sbom = Sbom::new(SbomFormat::CycloneDx16, "1.6");
sbom.components = vec![
supplied("A", "acme"),
supplied("B", "acme"),
supplied("C", "globex"),
supplied("D", "globex"),
];
sbom.dependencies = vec![
Dependency {
dependent: "A".to_string(),
depends_on: vec!["B".to_string(), "C".to_string()],
},
Dependency {
dependent: "B".to_string(),
depends_on: vec!["D".to_string()],
},
Dependency {
dependent: "C".to_string(),
depends_on: vec!["D".to_string()],
},
];
sbom
}
fn chain_sbom() -> Sbom {
let mut sbom = Sbom::new(SbomFormat::CycloneDx16, "1.6");
sbom.components = vec![
Component::library("A", "A", "1.0"),
Component::library("B", "B", "1.0"),
Component::library("C", "C", "1.0"),
];
sbom.dependencies = vec![
Dependency {
dependent: "A".to_string(),
depends_on: vec!["B".to_string()],
},
Dependency {
dependent: "B".to_string(),
depends_on: vec!["C".to_string()],
},
];
sbom
}
fn cyclic_sbom() -> Sbom {
let mut sbom = Sbom::new(SbomFormat::CycloneDx16, "1.6");
sbom.components = vec![
Component::library("A", "A", "1.0"),
Component::library("B", "B", "1.0"),
];
sbom.dependencies = vec![
Dependency {
dependent: "A".to_string(),
depends_on: vec!["B".to_string()],
},
Dependency {
dependent: "B".to_string(),
depends_on: vec!["A".to_string()],
},
];
sbom
}
#[test]
fn graph_builds_nodes_and_edges() {
let g = DependencyGraph::from_sbom(&diamond_sbom());
assert_eq!(g.node_count(), 4);
assert_eq!(g.edge_count(), 4);
assert!(g.contains("A"));
assert!(g.contains("D"));
assert!(!g.contains("Z"));
assert_eq!(g.nodes(), vec!["A", "B", "C", "D"]);
}
#[test]
fn direct_dependencies_are_sorted() {
let g = DependencyGraph::from_sbom(&diamond_sbom());
assert_eq!(g.direct_dependencies("A"), vec!["B", "C"]);
assert_eq!(g.direct_dependencies("B"), vec!["D"]);
assert!(g.direct_dependencies("D").is_empty());
assert!(g.direct_dependencies("Z").is_empty());
}
#[test]
fn direct_dependents_walk_reverse_edges() {
let g = DependencyGraph::from_sbom(&diamond_sbom());
assert_eq!(g.direct_dependents("D"), vec!["B", "C"]);
assert!(g.direct_dependents("A").is_empty());
}
#[test]
fn transitive_dependencies_reach_the_leaf() {
let g = DependencyGraph::from_sbom(&diamond_sbom());
assert_eq!(g.transitive_dependencies("A"), vec!["B", "C", "D"]);
assert_eq!(g.transitive_dependencies("B"), vec!["D"]);
assert!(g.transitive_dependencies("D").is_empty());
}
#[test]
fn transitive_dependents_is_blast_radius() {
let g = DependencyGraph::from_sbom(&diamond_sbom());
assert_eq!(g.transitive_dependents("D"), vec!["A", "B", "C"]);
assert_eq!(g.transitive_dependents("B"), vec!["A"]);
assert!(g.transitive_dependents("A").is_empty());
}
#[test]
fn depth_counts_longest_path_in_edges() {
let chain = DependencyGraph::from_sbom(&chain_sbom());
assert_eq!(chain.depth("A"), 2);
assert_eq!(chain.depth("B"), 1);
assert_eq!(chain.depth("C"), 0);
let diamond = DependencyGraph::from_sbom(&diamond_sbom());
assert_eq!(diamond.depth("A"), 2);
assert_eq!(diamond.depth("D"), 0);
}
#[test]
fn acyclic_graphs_report_no_cycle() {
assert!(!DependencyGraph::from_sbom(&diamond_sbom()).is_cyclic());
assert!(!DependencyGraph::from_sbom(&chain_sbom()).is_cyclic());
}
#[test]
fn cycle_detection_finds_a_b_a() {
let g = DependencyGraph::from_sbom(&cyclic_sbom());
assert!(g.is_cyclic());
}
#[test]
fn depth_terminates_on_cycle() {
let g = DependencyGraph::from_sbom(&cyclic_sbom());
assert_eq!(g.depth("A"), 2);
assert_eq!(g.depth("B"), 2);
}
#[test]
fn transitive_dependents_handles_cycle() {
let g = DependencyGraph::from_sbom(&cyclic_sbom());
assert_eq!(g.transitive_dependents("A"), vec!["B"]);
assert_eq!(g.transitive_dependents("B"), vec!["A"]);
}
#[test]
fn blast_radius_of_leaf_includes_all_ancestors() {
let g = DependencyGraph::from_sbom(&diamond_sbom());
let br = blast_radius(&g, "D").unwrap();
assert_eq!(br.component, "D");
assert_eq!(br.directly_impacted, 2); assert_eq!(br.transitively_impacted, 3); assert_eq!(br.impacted, vec!["A", "B", "C"]);
}
#[test]
fn blast_radius_rejects_unknown_component() {
let g = DependencyGraph::from_sbom(&diamond_sbom());
let err = blast_radius(&g, "Z").unwrap_err();
assert_eq!(err, SupplyChainError::UnknownComponent("Z".to_string()));
}
#[test]
fn supplier_concentration_groups_and_orders() {
let conc = supplier_concentration(&diamond_sbom());
assert_eq!(conc.len(), 2);
assert_eq!(conc[0].supplier, "acme");
assert_eq!(conc[0].component_count, 2);
assert_eq!(conc[0].components, vec!["A", "B"]);
assert_eq!(conc[1].supplier, "globex");
let total: f64 = conc.iter().map(|c| c.share).sum();
assert!((total - 1.0).abs() < 1e-9);
assert!((conc[0].share - 0.5).abs() < 1e-9);
}
#[test]
fn supplier_concentration_buckets_unknown() {
let mut sbom = Sbom::new(SbomFormat::CycloneDx16, "1.6");
sbom.components = vec![
supplied("A", "acme"),
Component::library("B", "B", "1.0"), Component::library("C", "C", "1.0"), ];
let conc = supplier_concentration(&sbom);
assert_eq!(conc[0].supplier, UNKNOWN_SUPPLIER);
assert_eq!(conc[0].component_count, 2);
assert_eq!(conc[1].supplier, "acme");
}
#[test]
fn supplier_concentration_empty_sbom_is_empty() {
let sbom = Sbom::new(SbomFormat::CycloneDx16, "1.6");
assert!(supplier_concentration(&sbom).is_empty());
}
#[test]
fn spof_detection_respects_threshold_and_order() {
let sbom = diamond_sbom();
let g = DependencyGraph::from_sbom(&sbom);
let spof = single_points_of_failure(&g, &sbom, 1);
assert_eq!(spof, vec!["D", "B", "C"]);
assert_eq!(single_points_of_failure(&g, &sbom, 3), vec!["D"]);
assert!(single_points_of_failure(&g, &sbom, 4).is_empty());
}
#[test]
fn attest_provenance_carries_address_builder_supplier() {
let mut sbom = diamond_sbom();
sbom.metadata.primary_component = Some("A".to_string());
sbom.metadata.tools = vec![Tool {
vendor: Some("acme".to_string()),
name: "cdxgen".to_string(),
version: Some("9.0".to_string()),
}];
sbom.metadata.supplier = Some(Supplier {
name: "acme corp".to_string(),
url: None,
contact: None,
});
let att = attest_provenance(&sbom, Some("receipt-123"));
assert_eq!(att.sbom_address.len(), 64); assert_eq!(att.sbom_address, sbom.content_address().as_hex());
assert_eq!(att.primary_component, Some("A".to_string()));
assert_eq!(att.builder, Some("cdxgen".to_string()));
assert_eq!(att.attested_supplier, Some("acme corp".to_string()));
assert_eq!(att.dependency_edges, 4);
assert_eq!(att.generated_from_receipt, Some("receipt-123".to_string()));
}
#[test]
fn attest_provenance_normalizes_blank_receipt_and_missing_fields() {
let sbom = chain_sbom();
let att = attest_provenance(&sbom, Some(" "));
assert_eq!(att.generated_from_receipt, None);
assert_eq!(att.primary_component, None);
assert_eq!(att.builder, None);
assert_eq!(att.attested_supplier, None);
assert_eq!(att.dependency_edges, 2);
}
#[test]
fn attest_provenance_address_is_deterministic() {
let sbom = diamond_sbom();
let a = attest_provenance(&sbom, None);
let b = attest_provenance(&sbom, None);
assert_eq!(a.sbom_address, b.sbom_address);
}
#[test]
fn report_summarizes_diamond() {
let sbom = diamond_sbom();
let report = build_report(&sbom, 1);
assert_eq!(report.component_count, 4);
assert_eq!(report.edge_count, 4);
assert_eq!(report.max_depth, 2);
assert!(!report.is_cyclic);
assert_eq!(report.supplier_count, 2);
assert!((report.top_supplier_share - 0.5).abs() < 1e-9);
assert_eq!(report.spof_count, 3);
}
#[test]
fn report_flags_cyclic_graph() {
let report = build_report(&cyclic_sbom(), 1);
assert!(report.is_cyclic);
assert_eq!(report.component_count, 2);
assert_eq!(report.edge_count, 2);
}
}