use crate::ocel::{build_event, object_ref, qualified_object_ref, SeqCounter};
use crate::sbom::{Component, Sbom};
use crate::types::OperationEvent;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, thiserror::Error)]
pub enum SbomOcelError {
#[error("sbom document is empty: no components to project")]
EmptyDocument,
#[error("unknown component: {0}")]
UnknownComponent(String),
#[error(transparent)]
Ocel(#[from] crate::error::OcelError),
}
pub const EVENT_IMPORT: &str = "sbom:import";
pub const EVENT_GENERATE: &str = "sbom:generate";
pub const EVENT_COMPONENT_CATALOGUED: &str = "sbom:component-catalogued";
pub const EVENT_DEPENDENCY_RESOLVED: &str = "sbom:dependency-resolved";
pub const EVENT_LICENSE_DETECTED: &str = "sbom:license-detected";
pub const EVENT_SUPPLIER_ATTESTED: &str = "sbom:supplier-attested";
pub const EVENT_ATTEST: &str = "sbom:attest";
pub fn supported_event_types() -> &'static [&'static str] {
&[
EVENT_IMPORT,
EVENT_GENERATE,
EVENT_COMPONENT_CATALOGUED,
EVENT_DEPENDENCY_RESOLVED,
EVENT_LICENSE_DETECTED,
EVENT_SUPPLIER_ATTESTED,
EVENT_ATTEST,
]
}
pub fn component_object_type(component: &Component) -> String {
format!("sbom-component:{}", component.component_type.tag())
}
pub fn license_object_type(label: &str) -> String {
format!("sbom-license:{label}")
}
pub fn supplier_object_type(name: &str) -> String {
format!("sbom-supplier:{name}")
}
pub fn vulnerability_object_type(id: &str) -> String {
format!("sbom-vulnerability:{id}")
}
pub const OBJECT_DOCUMENT: &str = "sbom-document";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomOcelEvent {
pub event: OperationEvent,
pub sbom_event_type: String,
pub payload: serde_json::Value,
}
impl SbomOcelEvent {
fn build(
sbom_event_type: &str,
objects: Vec<crate::types::ObjectRef>,
payload: serde_json::Value,
counter: &mut SeqCounter,
) -> Result<Self, SbomOcelError> {
let payload_bytes = serde_json::to_vec(&payload).unwrap_or_default();
let event = build_event(sbom_event_type, objects, &payload_bytes, counter)?;
Ok(SbomOcelEvent {
event,
sbom_event_type: sbom_event_type.to_string(),
payload,
})
}
}
fn component_objects(component: &Component) -> Vec<crate::types::ObjectRef> {
let mut objects = vec![object_ref(
component.bom_ref.clone(),
component_object_type(component),
)];
for license in &component.licenses {
let label = license.label();
objects.push(qualified_object_ref(
label.clone(),
license_object_type(&label),
"licensed-under",
));
}
if let Some(supplier) = &component.supplier {
objects.push(qualified_object_ref(
supplier.name.clone(),
supplier_object_type(&supplier.name),
"supplied-by",
));
}
objects
}
pub fn sbom_to_ocel_events(
sbom: &Sbom,
counter: &mut SeqCounter,
) -> Result<Vec<SbomOcelEvent>, SbomOcelError> {
if sbom.components.is_empty() {
return Err(SbomOcelError::EmptyDocument);
}
let mut events = Vec::new();
let document_id = sbom
.serial_number
.clone()
.unwrap_or_else(|| sbom.content_address().as_hex().to_string());
let mut import_objects = vec![object_ref(document_id.clone(), OBJECT_DOCUMENT)];
if let Some(primary_ref) = &sbom.metadata.primary_component {
if let Some(primary) = sbom.component(primary_ref) {
import_objects.push(qualified_object_ref(
primary.bom_ref.clone(),
component_object_type(primary),
"primary-component",
));
}
}
let import_payload = serde_json::json!({
"document_id": document_id,
"format": sbom.format.tag(),
"family": sbom.format.family(),
"spec_version": sbom.spec_version,
"version": sbom.version,
"component_count": sbom.components.len(),
"dependency_count": sbom.dependencies.len(),
});
events.push(SbomOcelEvent::build(
EVENT_IMPORT,
import_objects,
import_payload,
counter,
)?);
for component in &sbom.components {
let payload = serde_json::json!({
"bom_ref": component.bom_ref,
"name": component.name,
"version": component.version,
"component_type": component.component_type.tag(),
"purl": component.purl,
"cpe": component.cpe,
"has_unique_identifier": component.has_unique_identifier(),
"license_labels": component
.licenses
.iter()
.map(|l| l.label())
.collect::<Vec<_>>(),
});
events.push(SbomOcelEvent::build(
EVENT_COMPONENT_CATALOGUED,
component_objects(component),
payload,
counter,
)?);
}
for dependency in &sbom.dependencies {
let mut objects = vec![object_ref(
dependency.dependent.clone(),
dependent_object_type(sbom, &dependency.dependent),
)];
for target in &dependency.depends_on {
objects.push(qualified_object_ref(
target.clone(),
dependent_object_type(sbom, target),
"depends-on",
));
}
let payload = serde_json::json!({
"dependent": dependency.dependent,
"depends_on": dependency.depends_on,
"edge_count": dependency.depends_on.len(),
});
events.push(SbomOcelEvent::build(
EVENT_DEPENDENCY_RESOLVED,
objects,
payload,
counter,
)?);
}
Ok(events)
}
fn dependent_object_type(sbom: &Sbom, bom_ref: &str) -> String {
sbom.component(bom_ref)
.map(component_object_type)
.unwrap_or_else(|| "sbom-component:library".to_string())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SbomCausalChain {
pub document_event_id: String,
pub component_event_id: String,
pub dependency_event_ids: Vec<String>,
pub root_component: String,
pub transitive_component_count: usize,
}
pub fn build_sbom_causal_chain(
events: &[SbomOcelEvent],
sbom: &Sbom,
root_bom_ref: &str,
) -> Result<SbomCausalChain, SbomOcelError> {
if sbom.component(root_bom_ref).is_none() {
return Err(SbomOcelError::UnknownComponent(root_bom_ref.to_string()));
}
let document_event = events
.iter()
.find(|e| e.sbom_event_type == EVENT_IMPORT)
.ok_or(SbomOcelError::EmptyDocument)?;
let component_event = events
.iter()
.find(|e| {
e.sbom_event_type == EVENT_COMPONENT_CATALOGUED
&& e.event
.objects
.first()
.is_some_and(|o| o.id == root_bom_ref)
})
.ok_or_else(|| SbomOcelError::UnknownComponent(root_bom_ref.to_string()))?;
let transitive = sbom.transitive_dependencies(root_bom_ref);
let mut in_scope: BTreeMap<String, ()> = BTreeMap::new();
in_scope.insert(root_bom_ref.to_string(), ());
for t in &transitive {
in_scope.insert(t.clone(), ());
}
let dependency_event_ids = events
.iter()
.filter(|e| e.sbom_event_type == EVENT_DEPENDENCY_RESOLVED)
.filter(|e| {
e.payload
.get("dependent")
.and_then(|v| v.as_str())
.is_some_and(|d| in_scope.contains_key(d))
})
.map(|e| e.event.id.clone())
.collect();
Ok(SbomCausalChain {
document_event_id: document_event.event.id.clone(),
component_event_id: component_event.event.id.clone(),
dependency_event_ids,
root_component: root_bom_ref.to_string(),
transitive_component_count: transitive.len(),
})
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectCorrelation {
pub object_type: String,
pub object_ids: Vec<String>,
pub event_ids: Vec<String>,
}
pub fn correlate_components_by_license(
events: &[SbomOcelEvent],
sbom: &Sbom,
) -> Vec<ObjectCorrelation> {
let mut by_type: BTreeMap<String, ObjectCorrelation> = BTreeMap::new();
for event in events
.iter()
.filter(|e| e.sbom_event_type == EVENT_COMPONENT_CATALOGUED)
{
let component_ref = match event.event.objects.first() {
Some(o) => o.id.clone(),
None => continue,
};
let component = match sbom.component(&component_ref) {
Some(c) => c,
None => continue,
};
for license in &component.licenses {
let object_type = license_object_type(&license.label());
let entry = by_type
.entry(object_type.clone())
.or_insert_with(|| ObjectCorrelation {
object_type,
object_ids: Vec::new(),
event_ids: Vec::new(),
});
if !entry.object_ids.contains(&component_ref) {
entry.object_ids.push(component_ref.clone());
}
if !entry.event_ids.contains(&event.event.id) {
entry.event_ids.push(event.event.id.clone());
}
}
}
by_type.into_values().collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sbom::{Dependency, License, SbomFormat, Supplier};
fn sample_sbom() -> Sbom {
let mut sbom = Sbom::new(SbomFormat::CycloneDx16, "1.6");
sbom.serial_number = Some("urn:uuid:test".to_string());
sbom.metadata.primary_component = Some("app".to_string());
sbom.metadata.author = Some("Build Bot".to_string());
sbom.metadata.timestamp = 1_700_000_000;
let mut app = Component::library("app", "app", "1.0");
app.licenses.push(License::id("Apache-2.0"));
let mut serde_c = Component::library("serde", "serde", "1.0.0");
serde_c.purl = Some("pkg:cargo/serde@1.0.0".to_string());
serde_c.licenses.push(License::id("MIT"));
serde_c.supplier = Some(Supplier {
name: "serde-rs".to_string(),
url: None,
contact: None,
});
let mut log_c = Component::library("log", "log", "0.4.0");
log_c.purl = Some("pkg:cargo/log@0.4.0".to_string());
log_c.licenses.push(License::id("MIT"));
log_c.supplier = Some(Supplier {
name: "rust-lang".to_string(),
url: None,
contact: None,
});
sbom.components.push(app);
sbom.components.push(serde_c);
sbom.components.push(log_c);
sbom.dependencies.push(Dependency {
dependent: "app".to_string(),
depends_on: vec!["serde".to_string()],
});
sbom.dependencies.push(Dependency {
dependent: "serde".to_string(),
depends_on: vec!["log".to_string()],
});
sbom.canonicalize();
sbom
}
#[test]
fn supported_event_types_is_non_empty_and_well_formed() {
let types = supported_event_types();
assert!(!types.is_empty());
assert_eq!(types.len(), 7);
assert!(types.iter().all(|t| t.starts_with("sbom:")));
assert!(types.contains(&EVENT_IMPORT));
assert!(types.contains(&EVENT_ATTEST));
}
#[test]
fn projection_event_count_matches_taxonomy() {
let sbom = sample_sbom();
let mut counter = SeqCounter::new();
let events = sbom_to_ocel_events(&sbom, &mut counter).unwrap();
assert_eq!(events.len(), 6);
}
#[test]
fn projection_event_ordering_is_deterministic() {
let sbom = sample_sbom();
let mut counter = SeqCounter::new();
let events = sbom_to_ocel_events(&sbom, &mut counter).unwrap();
assert_eq!(events[0].sbom_event_type, EVENT_IMPORT);
assert_eq!(events[1].sbom_event_type, EVENT_COMPONENT_CATALOGUED);
assert_eq!(events[2].sbom_event_type, EVENT_COMPONENT_CATALOGUED);
assert_eq!(events[3].sbom_event_type, EVENT_COMPONENT_CATALOGUED);
assert_eq!(events[4].sbom_event_type, EVENT_DEPENDENCY_RESOLVED);
assert_eq!(events[5].sbom_event_type, EVENT_DEPENDENCY_RESOLVED);
for (i, e) in events.iter().enumerate() {
assert_eq!(e.event.seq, i as u64);
}
}
#[test]
fn import_event_references_document_and_primary() {
let sbom = sample_sbom();
let mut counter = SeqCounter::new();
let events = sbom_to_ocel_events(&sbom, &mut counter).unwrap();
let import = &events[0];
let doc = &import.event.objects[0];
assert_eq!(doc.obj_type, OBJECT_DOCUMENT);
assert_eq!(doc.id, "urn:uuid:test");
let primary = &import.event.objects[1];
assert_eq!(primary.id, "app");
assert_eq!(primary.qualifier.as_deref(), Some("primary-component"));
assert_eq!(primary.obj_type, "sbom-component:library");
}
#[test]
fn component_object_carries_typed_tag() {
let sbom = sample_sbom();
let mut counter = SeqCounter::new();
let events = sbom_to_ocel_events(&sbom, &mut counter).unwrap();
let catalogue: Vec<&SbomOcelEvent> = events
.iter()
.filter(|e| e.sbom_event_type == EVENT_COMPONENT_CATALOGUED)
.collect();
for e in catalogue {
let comp = &e.event.objects[0];
assert_eq!(comp.obj_type, "sbom-component:library");
}
}
#[test]
fn catalogue_event_includes_license_and_supplier_objects() {
let sbom = sample_sbom();
let mut counter = SeqCounter::new();
let events = sbom_to_ocel_events(&sbom, &mut counter).unwrap();
let serde_evt = events
.iter()
.find(|e| {
e.sbom_event_type == EVENT_COMPONENT_CATALOGUED && e.event.objects[0].id == "serde"
})
.unwrap();
assert_eq!(serde_evt.event.objects.len(), 3);
assert!(serde_evt
.event
.objects
.iter()
.any(|o| o.obj_type == "sbom-license:MIT"));
assert!(serde_evt
.event
.objects
.iter()
.any(|o| o.obj_type == "sbom-supplier:serde-rs"));
}
#[test]
fn dependency_event_uses_depends_on_qualifier() {
let sbom = sample_sbom();
let mut counter = SeqCounter::new();
let events = sbom_to_ocel_events(&sbom, &mut counter).unwrap();
let dep = events
.iter()
.find(|e| {
e.sbom_event_type == EVENT_DEPENDENCY_RESOLVED && e.event.objects[0].id == "app"
})
.unwrap();
assert_eq!(dep.event.objects[0].qualifier, None);
assert_eq!(dep.event.objects[1].id, "serde");
assert_eq!(
dep.event.objects[1].qualifier.as_deref(),
Some("depends-on")
);
}
#[test]
fn empty_document_is_rejected() {
let sbom = Sbom::new(SbomFormat::Spdx23, "2.3");
let mut counter = SeqCounter::new();
let result = sbom_to_ocel_events(&sbom, &mut counter);
assert!(matches!(result, Err(SbomOcelError::EmptyDocument)));
}
#[test]
fn commitments_are_deterministic_across_runs() {
let sbom = sample_sbom();
let mut c1 = SeqCounter::new();
let mut c2 = SeqCounter::new();
let e1 = sbom_to_ocel_events(&sbom, &mut c1).unwrap();
let e2 = sbom_to_ocel_events(&sbom, &mut c2).unwrap();
assert_eq!(e1.len(), e2.len());
for (a, b) in e1.iter().zip(e2.iter()) {
assert_eq!(
a.event.payload_commitment, b.event.payload_commitment,
"commitment must be deterministic for identical inputs"
);
assert_eq!(a.event.id, b.event.id);
}
}
#[test]
fn causal_chain_links_document_component_and_deps() {
let sbom = sample_sbom();
let mut counter = SeqCounter::new();
let events = sbom_to_ocel_events(&sbom, &mut counter).unwrap();
let chain = build_sbom_causal_chain(&events, &sbom, "app").unwrap();
assert_eq!(chain.document_event_id, events[0].event.id);
assert_eq!(chain.root_component, "app");
let app_evt = events
.iter()
.find(|e| {
e.sbom_event_type == EVENT_COMPONENT_CATALOGUED && e.event.objects[0].id == "app"
})
.unwrap();
assert_eq!(chain.component_event_id, app_evt.event.id);
assert_eq!(chain.dependency_event_ids.len(), 2);
}
#[test]
fn causal_chain_transitive_count_matches() {
let sbom = sample_sbom();
let mut counter = SeqCounter::new();
let events = sbom_to_ocel_events(&sbom, &mut counter).unwrap();
let chain = build_sbom_causal_chain(&events, &sbom, "app").unwrap();
assert_eq!(chain.transitive_component_count, 2);
let leaf = build_sbom_causal_chain(&events, &sbom, "log").unwrap();
assert_eq!(leaf.transitive_component_count, 0);
assert_eq!(leaf.dependency_event_ids.len(), 0);
}
#[test]
fn causal_chain_rejects_unknown_root() {
let sbom = sample_sbom();
let mut counter = SeqCounter::new();
let events = sbom_to_ocel_events(&sbom, &mut counter).unwrap();
let result = build_sbom_causal_chain(&events, &sbom, "does-not-exist");
assert!(matches!(result, Err(SbomOcelError::UnknownComponent(_))));
}
#[test]
fn license_correlation_groups_shared_licenses() {
let sbom = sample_sbom();
let mut counter = SeqCounter::new();
let events = sbom_to_ocel_events(&sbom, &mut counter).unwrap();
let correlations = correlate_components_by_license(&events, &sbom);
let mit = correlations
.iter()
.find(|c| c.object_type == "sbom-license:MIT")
.unwrap();
assert_eq!(mit.object_ids.len(), 2);
assert!(mit.object_ids.contains(&"serde".to_string()));
assert!(mit.object_ids.contains(&"log".to_string()));
let apache = correlations
.iter()
.find(|c| c.object_type == "sbom-license:Apache-2.0")
.unwrap();
assert_eq!(apache.object_ids, vec!["app".to_string()]);
}
#[test]
fn license_correlation_is_deterministically_ordered() {
let sbom = sample_sbom();
let mut counter = SeqCounter::new();
let events = sbom_to_ocel_events(&sbom, &mut counter).unwrap();
let first = correlate_components_by_license(&events, &sbom);
let second = correlate_components_by_license(&events, &sbom);
let types1: Vec<&str> = first.iter().map(|c| c.object_type.as_str()).collect();
let types2: Vec<&str> = second.iter().map(|c| c.object_type.as_str()).collect();
assert_eq!(types1, types2);
assert_eq!(types1, vec!["sbom-license:Apache-2.0", "sbom-license:MIT"]);
}
#[test]
fn object_type_helpers_format_tags() {
assert_eq!(license_object_type("MIT"), "sbom-license:MIT");
assert_eq!(supplier_object_type("acme"), "sbom-supplier:acme");
assert_eq!(
vulnerability_object_type("CVE-2024-0001"),
"sbom-vulnerability:CVE-2024-0001"
);
let c = Component::library("x", "x", "1");
assert_eq!(component_object_type(&c), "sbom-component:library");
}
#[test]
fn import_event_commits_to_format_payload() {
let sbom = sample_sbom();
let mut counter = SeqCounter::new();
let events = sbom_to_ocel_events(&sbom, &mut counter).unwrap();
let import = &events[0];
assert_eq!(
import.payload.get("format").and_then(|v| v.as_str()),
Some("cyclonedx-1.6")
);
assert_eq!(
import
.payload
.get("component_count")
.and_then(|v| v.as_u64()),
Some(3)
);
}
}