use crate::model::{
Annotation, CanonicalId, Component, ComponentType, Creator, CreatorType, CvssScore,
CvssVersion, DependencyEdge, DependencyType, DocumentMetadata, ExternalRefType,
ExternalReference, Hash, HashAlgorithm, LicenseExpression, NormalizedSbom, Organization,
SbomFormat, Severity, SignatureInfo, VexJustification, VexResponse, VexState, VexStatus,
VulnerabilityRef, VulnerabilitySource,
};
use crate::parsers::traits::{FormatConfidence, FormatDetection, ParseError, SbomParser};
use chrono::{DateTime, Utc};
use serde::Deserialize;
use std::collections::{HashMap, HashSet};
pub struct Spdx3Parser {
#[allow(dead_code)]
strict: bool,
}
impl Spdx3Parser {
#[must_use]
pub const fn new() -> Self {
Self { strict: false }
}
fn parse_json_ld(&self, content: &str) -> Result<NormalizedSbom, ParseError> {
let content = super::strip_bom(content);
let mut doc: Spdx3Document =
serde_json::from_str(content).map_err(|e| ParseError::JsonError(e.to_string()))?;
if Self::is_bare_element(&doc) {
let element: Spdx3Element =
serde_json::from_str(content).map_err(|e| ParseError::JsonError(e.to_string()))?;
doc.element = Some(vec![element]);
}
Ok(self.convert_to_normalized(doc))
}
fn is_bare_element(doc: &Spdx3Document) -> bool {
doc.element.is_none()
&& doc.graph.is_none()
&& doc.type_.as_deref().is_some_and(|t| t != "SpdxDocument")
}
pub fn parse_json_reader<R: std::io::Read>(
&self,
reader: R,
) -> Result<NormalizedSbom, ParseError> {
let value: serde_json::Value =
serde_json::from_reader(reader).map_err(|e| ParseError::JsonError(e.to_string()))?;
let is_bare = value.get("element").is_none()
&& value.get("@graph").is_none()
&& value
.get("type")
.and_then(serde_json::Value::as_str)
.is_some_and(|t| t != "SpdxDocument");
let bare_element = if is_bare {
Some(
serde_json::from_value::<Spdx3Element>(value.clone())
.map_err(|e| ParseError::JsonError(e.to_string()))?,
)
} else {
None
};
let mut doc: Spdx3Document =
serde_json::from_value(value).map_err(|e| ParseError::JsonError(e.to_string()))?;
if let Some(element) = bare_element {
doc.element = Some(vec![element]);
}
Ok(self.convert_to_normalized(doc))
}
fn convert_to_normalized(&self, mut doc: Spdx3Document) -> NormalizedSbom {
let mut agent_map: HashMap<String, Spdx3Agent> = HashMap::new();
let mut vuln_map: HashMap<String, Spdx3Vulnerability> = HashMap::new();
let mut relationships: Vec<Spdx3Relationship> = Vec::new();
let mut packages: Vec<(Spdx3Package, PackageKind)> = Vec::new();
let mut files: Vec<Spdx3File> = Vec::new();
let mut snippets: Vec<Spdx3Snippet> = Vec::new();
let mut annotations: Vec<Spdx3Annotation> = Vec::new();
let mut vuln_assessments: Vec<Spdx3VulnAssessment> = Vec::new();
let mut license_elements: HashMap<String, String> = HashMap::new();
let mut creation_info_map: HashMap<String, Spdx3CreationInfo> = HashMap::new();
let mut collection_map: HashMap<String, Spdx3Collection> = HashMap::new();
let mut seen_ids: HashSet<String> = HashSet::new();
let mut duplicate_count: usize = 0;
let elements = doc
.element
.take()
.into_iter()
.flatten()
.chain(doc.graph.take().into_iter().flatten());
{
for element in elements {
let element_id = match &element {
Spdx3Element::Package(pkg)
| Spdx3Element::AiPackage(pkg)
| Spdx3Element::DatasetPackage(pkg) => pkg.spdx_id.clone(),
Spdx3Element::File(file) => file.spdx_id.clone(),
Spdx3Element::Relationship(rel) => rel.spdx_id.clone(),
Spdx3Element::Vulnerability(vuln) => vuln.spdx_id.clone(),
Spdx3Element::Snippet(s) => s.spdx_id.clone(),
Spdx3Element::Annotation(a) => a.spdx_id.clone(),
Spdx3Element::VexAffected(va)
| Spdx3Element::VexFixed(va)
| Spdx3Element::VexNotAffected(va)
| Spdx3Element::VexUnderInvestigation(va)
| Spdx3Element::CvssV3Assessment(va)
| Spdx3Element::CvssV4Assessment(va)
| Spdx3Element::EpssAssessment(va)
| Spdx3Element::SsvcAssessment(va) => va.spdx_id.clone(),
Spdx3Element::Person(a)
| Spdx3Element::Organization(a)
| Spdx3Element::Tool(a)
| Spdx3Element::SoftwareAgent(a) => a.spdx_id.clone(),
Spdx3Element::LicenseExpression(l) => l.spdx_id.clone(),
Spdx3Element::SimpleLicensingText(l) => l.spdx_id.clone(),
Spdx3Element::Bom(c) | Spdx3Element::Bundle(c) => c.spdx_id.clone(),
Spdx3Element::CreationInfo(ci) => ci.id.clone(),
_ => None,
};
if let Some(id) = &element_id
&& !seen_ids.insert(id.clone())
{
duplicate_count += 1;
tracing::warn!(id = %id, "duplicate SPDX 3.0 element ID");
}
match element {
Spdx3Element::Package(pkg) => packages.push((*pkg, PackageKind::Software)),
Spdx3Element::AiPackage(pkg) => packages.push((*pkg, PackageKind::AiModel)),
Spdx3Element::DatasetPackage(pkg) => {
packages.push((*pkg, PackageKind::Dataset));
}
Spdx3Element::File(file) => files.push(*file),
Spdx3Element::Snippet(snippet) => snippets.push(snippet),
Spdx3Element::Relationship(rel)
| Spdx3Element::LifecycleScopedRelationship(rel) => {
relationships.push(rel);
}
Spdx3Element::Person(mut agent) => {
agent.resolved_type_tag = Some("Person");
if let Some(id) = &agent.spdx_id {
agent_map.insert(id.clone(), agent);
}
}
Spdx3Element::Organization(mut agent) => {
agent.resolved_type_tag = Some("Organization");
if let Some(id) = &agent.spdx_id {
agent_map.insert(id.clone(), agent);
}
}
Spdx3Element::Tool(mut agent) => {
agent.resolved_type_tag = Some("Tool");
if let Some(id) = &agent.spdx_id {
agent_map.insert(id.clone(), agent);
}
}
Spdx3Element::SoftwareAgent(mut agent) => {
agent.resolved_type_tag = Some("SoftwareAgent");
if let Some(id) = &agent.spdx_id {
agent_map.insert(id.clone(), agent);
}
}
Spdx3Element::CreationInfo(ci) => {
if let Some(id) = &ci.id {
creation_info_map.insert(id.clone(), ci);
}
}
Spdx3Element::Bom(coll) | Spdx3Element::Bundle(coll) => {
if let Some(id) = &coll.spdx_id {
collection_map.insert(id.clone(), coll);
}
}
Spdx3Element::Vulnerability(vuln) => {
if let Some(id) = &vuln.spdx_id {
let id = id.clone();
vuln_map.insert(id, *vuln);
}
}
Spdx3Element::VexAffected(mut a) => {
a.resolved_vex_state = Some(VexState::Affected);
vuln_assessments.push(a);
}
Spdx3Element::VexFixed(mut a) => {
a.resolved_vex_state = Some(VexState::Fixed);
vuln_assessments.push(a);
}
Spdx3Element::VexNotAffected(mut a) => {
a.resolved_vex_state = Some(VexState::NotAffected);
vuln_assessments.push(a);
}
Spdx3Element::VexUnderInvestigation(mut a) => {
a.resolved_vex_state = Some(VexState::UnderInvestigation);
vuln_assessments.push(a);
}
Spdx3Element::CvssV3Assessment(mut a) => {
a.resolved_cvss_version = Some(CvssVersion::V3);
vuln_assessments.push(a);
}
Spdx3Element::CvssV4Assessment(mut a) => {
a.resolved_cvss_version = Some(CvssVersion::V4);
vuln_assessments.push(a);
}
Spdx3Element::EpssAssessment(a) | Spdx3Element::SsvcAssessment(a) => {
vuln_assessments.push(a);
}
Spdx3Element::Annotation(annotation) => {
annotations.push(annotation);
}
Spdx3Element::LicenseExpression(lic) => {
if let (Some(id), Some(expr)) = (lic.spdx_id, lic.license_expression) {
license_elements.insert(id, expr);
}
}
Spdx3Element::SimpleLicensingText(lic) => {
if let Some(id) = lic.spdx_id {
let text = lic.license_text.unwrap_or_default();
license_elements.insert(id, text);
}
}
Spdx3Element::SpdxDocument(inner) => {
let inner = *inner;
if doc.name.is_none() {
doc.name = inner.name;
}
if doc.creation_info.is_none() {
doc.creation_info = inner.creation_info;
}
if doc.root_element.is_none() {
doc.root_element = inner.root_element;
}
if doc.spdx_id.is_none() {
doc.spdx_id = inner.spdx_id;
}
if doc.description.is_none() {
doc.description = inner.description;
}
if doc.profile_conformance.is_none() {
doc.profile_conformance = inner.profile_conformance;
}
if doc.data_license.is_none() {
doc.data_license = inner.data_license;
}
if doc.namespace_map.is_none() {
doc.namespace_map = inner.namespace_map;
}
if doc.imports.is_none() {
doc.imports = inner.imports;
}
if doc.verified_using.is_none() {
doc.verified_using = inner.verified_using;
}
}
_ => {} }
}
}
if duplicate_count > 0 {
tracing::info!(
duplicate_count,
"SPDX 3.0 document contains duplicate element IDs"
);
}
let mut document = self.convert_metadata(&doc, &agent_map, &creation_info_map);
document.completeness_declaration = derive_completeness_declaration(&relationships);
let mut sbom = NormalizedSbom::new(document);
let mut id_map: HashMap<String, CanonicalId> = HashMap::new();
for (pkg, kind) in &packages {
let comp = self.convert_package(pkg, *kind, &agent_map);
let spdx_id = pkg.spdx_id.clone().unwrap_or_else(|| comp.name.clone());
id_map.insert(spdx_id, comp.canonical_id.clone());
sbom.add_component(comp);
}
for file in &files {
let comp = self.convert_file(file);
let spdx_id = file.spdx_id.clone().unwrap_or_else(|| comp.name.clone());
id_map.insert(spdx_id, comp.canonical_id.clone());
sbom.add_component(comp);
}
for snippet in &snippets {
let comp = self.convert_snippet(snippet);
let spdx_id = snippet.spdx_id.clone().unwrap_or_else(|| comp.name.clone());
id_map.insert(spdx_id, comp.canonical_id.clone());
sbom.add_component(comp);
}
if let Some(root_elements) = &doc.root_element {
'roots: for root_id in root_elements {
if let Some(canonical_id) = id_map.get(root_id) {
sbom.set_primary_component(canonical_id.clone());
break; }
if let Some(coll) = collection_map.get(root_id) {
for inner_id in coll.root_element.iter().flatten() {
if let Some(canonical_id) = id_map.get(inner_id) {
sbom.set_primary_component(canonical_id.clone());
break 'roots;
}
}
}
}
}
for rel in &relationships {
self.process_relationship(rel, &id_map, &mut sbom, &vuln_map, &license_elements);
}
for assessment in &vuln_assessments {
self.process_vuln_assessment(assessment, &id_map, &mut sbom, &vuln_map);
}
for comp in sbom.components.values_mut() {
if !comp.vulnerabilities.is_empty() || comp.vex_status.is_some() {
comp.calculate_content_hash();
}
}
for annotation in &annotations {
self.process_annotation(annotation, &id_map, &mut sbom);
}
let mut spdx_ext = serde_json::Map::new();
if let Some(data_license) = &doc.data_license {
let resolved = license_elements
.get(data_license)
.cloned()
.unwrap_or_else(|| data_license.clone());
spdx_ext.insert(
"dataLicense".to_string(),
serde_json::Value::String(resolved),
);
}
if let Some(ns_map) = &doc.namespace_map
&& !ns_map.is_empty()
{
let ns_entries: Vec<_> = ns_map
.iter()
.filter_map(|ns| {
Some(serde_json::json!({
"prefix": ns.prefix.as_deref()?,
"namespace": ns.namespace.as_deref()?
}))
})
.collect();
if !ns_entries.is_empty() {
spdx_ext.insert(
"namespaceMap".to_string(),
serde_json::Value::Array(ns_entries),
);
}
}
if let Some(imports) = &doc.imports
&& !imports.is_empty()
{
let import_entries: Vec<_> = imports
.iter()
.filter_map(|imp| {
Some(serde_json::json!({
"externalSpdxId": imp.external_spdx_id.as_deref()?,
"locationHint": imp.location_hint
}))
})
.collect();
if !import_entries.is_empty() {
spdx_ext.insert(
"imports".to_string(),
serde_json::Value::Array(import_entries),
);
}
}
if !spdx_ext.is_empty() {
sbom.extensions.spdx = Some(serde_json::Value::Object(spdx_ext));
}
sbom.calculate_content_hash();
sbom
}
fn convert_metadata(
&self,
doc: &Spdx3Document,
agent_map: &HashMap<String, Spdx3Agent>,
ci_map: &HashMap<String, Spdx3CreationInfo>,
) -> DocumentMetadata {
let creation_info = doc
.creation_info
.as_ref()
.and_then(|ci_ref| ci_ref.resolve(ci_map))
.or_else(|| {
if ci_map.len() == 1 {
ci_map.values().next()
} else {
None
}
});
let created = creation_info
.and_then(|ci| ci.created.as_ref())
.and_then(|t| DateTime::parse_from_rfc3339(t).ok())
.map_or(DateTime::UNIX_EPOCH, |dt| dt.with_timezone(&Utc));
let mut creators = Vec::new();
if let Some(ci) = creation_info {
if let Some(created_by) = &ci.created_by {
for agent_ref in created_by {
if let Some(agent) = agent_map.get(agent_ref) {
let (creator_type, type_str) = agent.agent_type();
creators.push(Creator {
creator_type,
name: agent
.name
.clone()
.unwrap_or_else(|| format!("Unknown {type_str}")),
email: None,
});
} else {
creators.push(Creator {
creator_type: CreatorType::Tool,
name: agent_ref.clone(),
email: None,
});
}
}
}
if let Some(created_using) = &ci.created_using {
for tool_ref in created_using {
if let Some(agent) = agent_map.get(tool_ref) {
creators.push(Creator {
creator_type: CreatorType::Tool,
name: agent
.name
.clone()
.unwrap_or_else(|| "Unknown Tool".to_string()),
email: None,
});
}
}
}
}
let spec_version = creation_info
.and_then(|ci| ci.spec_version.clone())
.unwrap_or_else(|| "3.0".to_string());
let profile_conformance = doc.profile_conformance.as_ref().map(|ps| ps.join(", "));
let signature = doc.verified_using.as_ref().and_then(|methods| {
methods.iter().find_map(|m| {
if m.method_type.as_deref() == Some("signature") {
Some(SignatureInfo {
algorithm: m.algorithm.clone().unwrap_or_else(|| "unknown".to_string()),
has_value: m.hash_value.is_some(),
})
} else {
None
}
})
});
DocumentMetadata {
format: SbomFormat::Spdx,
format_version: spec_version.clone(),
spec_version,
serial_number: doc.spdx_id.clone(),
doc_version: None, created,
creators,
name: doc.name.clone(),
security_contact: None,
vulnerability_disclosure_url: None,
support_end_date: None,
lifecycle_phase: None,
completeness_declaration: crate::model::CompletenessDeclaration::Unknown,
signature,
distribution_classification: profile_conformance,
citations_count: 0,
}
}
fn convert_package(
&self,
pkg: &Spdx3Package,
kind: PackageKind,
agent_map: &HashMap<String, Spdx3Agent>,
) -> Component {
let format_id = pkg
.spdx_id
.clone()
.unwrap_or_else(|| pkg.name.clone().unwrap_or_default());
let name = pkg.name.clone().unwrap_or_default();
let mut comp = Component::new(name, format_id);
if let Some(version) = &pkg.package_version {
comp = comp.with_version(version.clone());
}
if let Some(purl) = &pkg.package_url {
comp = comp.with_purl(purl.clone());
}
if let Some(ext_ids) = &pkg.external_identifier {
for ext_id in ext_ids {
match ext_id.external_identifier_type.as_deref() {
Some("cpe23") | Some("cpe22") => {
comp.identifiers.cpe.push(ext_id.identifier.clone());
}
Some("swid") => {
comp.identifiers.swid = Some(ext_id.identifier.clone());
}
Some("swhid") => {
comp = comp.with_swhid(ext_id.identifier.clone());
}
Some("packageUrl") if comp.identifiers.purl.is_none() => {
comp = comp.with_purl(ext_id.identifier.clone());
}
_ => {}
}
}
}
if let Some(purpose) = &pkg.primary_purpose {
comp.component_type = match purpose.to_lowercase().as_str() {
"application" => ComponentType::Application,
"framework" => ComponentType::Framework,
"library" => ComponentType::Library,
"container" => ComponentType::Container,
"operatingsystem" | "operating-system" => ComponentType::OperatingSystem,
"device" => ComponentType::Device,
"firmware" => ComponentType::Firmware,
"file" | "source" | "archive" => ComponentType::File,
"data" | "documentation" => ComponentType::Data,
"model" => ComponentType::MachineLearningModel,
"platform" => ComponentType::Platform,
other => ComponentType::Other(other.to_string()),
};
}
if let Some(methods) = &pkg.verified_using {
for method in methods {
if let (Some(algo_str), Some(value)) = (&method.algorithm, &method.hash_value) {
let algorithm = map_spdx3_hash_algorithm(algo_str);
comp.hashes.push(Hash::new(algorithm, value.clone()));
}
}
}
if let Some(supplied_by) = &pkg.supplied_by {
for supplier_ref in supplied_by {
if let Some(agent) = agent_map.get(supplier_ref) {
comp.supplier = Some(Organization::new(
agent.name.clone().unwrap_or_else(|| supplier_ref.clone()),
));
break;
}
}
}
if let Some(ext_refs) = &pkg.external_ref {
for ext_ref in ext_refs {
let ref_type = ext_ref.external_ref_type.as_deref().map_or(
ExternalRefType::Other("unknown".to_string()),
|t| match t.to_lowercase().as_str() {
"securityadvisory" | "advisories" => ExternalRefType::Advisories,
"documentation" => ExternalRefType::Documentation,
"vcs" => ExternalRefType::Vcs,
"issuetracker" | "issue-tracker" => ExternalRefType::IssueTracker,
"bom" => ExternalRefType::Bom,
other => ExternalRefType::Other(other.to_string()),
},
);
if let Some(locators) = &ext_ref.locator {
for url in locators {
comp.external_refs.push(ExternalReference {
ref_type: ref_type.clone(),
url: url.clone(),
comment: ext_ref.comment.clone(),
hashes: Vec::new(),
});
}
}
}
}
comp.description.clone_from(&pkg.description);
comp.copyright.clone_from(&pkg.copyright_text);
match kind {
PackageKind::AiModel => {
comp.component_type = ComponentType::MachineLearningModel;
let model_card_url = comp
.external_refs
.iter()
.find(|r| r.ref_type == ExternalRefType::Documentation)
.map(|r| r.url.clone());
let fairness: Vec<crate::model::FairnessAssessment> = pkg
.ai_model_explainability
.clone()
.unwrap_or_default()
.into_iter()
.chain(pkg.ai_safety_risk_assessment.clone())
.map(|text| crate::model::FairnessAssessment {
group_at_risk: Some(text),
..Default::default()
})
.collect();
let use_cases: Vec<String> = pkg
.ai_information_about_application
.clone()
.into_iter()
.chain(pkg.ai_domain.clone().unwrap_or_default())
.collect();
let ethical: Vec<crate::model::EthicalConsideration> = pkg
.ai_standard_compliance
.clone()
.unwrap_or_default()
.into_iter()
.chain(pkg.ai_use_sensitive_personal_information.clone())
.map(|text| crate::model::EthicalConsideration {
name: Some(text),
mitigation_strategy: None,
})
.collect();
let performance_metrics: Vec<crate::model::MetricEntry> = pkg
.ai_metric
.clone()
.unwrap_or_default()
.into_iter()
.chain(pkg.ai_metric_decision_threshold.clone().unwrap_or_default())
.map(spdx3_metric_to_entry)
.collect();
comp.ml_model = Some(crate::model::MlModelInfo {
architecture_family: pkg
.ai_type_of_model
.as_ref()
.and_then(|v| v.first().cloned()),
limitations: pkg.ai_limitation.clone(),
energy_kwh_training: pkg
.ai_energy_consumption
.as_ref()
.and_then(Spdx3EnergyConsumption::training_energy_kwh),
model_card_url,
fairness,
ethical_considerations: ethical,
use_cases,
performance_metrics,
data_preprocessing: pkg.ai_model_data_preprocessing.clone().unwrap_or_default(),
..Default::default()
});
}
PackageKind::Dataset => {
comp.component_type = ComponentType::Data;
let mut sensitivity = Vec::new();
if pkg.dataset_has_sensitive_personal_information.as_deref() == Some("yes") {
sensitivity.push("pii".to_string());
}
if let Some(level) = &pkg.dataset_confidentiality_level {
sensitivity.push(level.clone());
}
let mut owners: Vec<String> = Vec::new();
for refs in [pkg.supplied_by.as_ref(), pkg.originated_by.as_ref()]
.into_iter()
.flatten()
{
for agent_ref in refs {
if let Some(name) = agent_map.get(agent_ref).and_then(|a| a.name.clone())
&& !owners.contains(&name)
{
owners.push(name);
}
}
}
comp.dataset = Some(crate::model::DatasetInfo {
dataset_type: pkg
.dataset_dataset_type
.as_ref()
.and_then(|v| v.first().cloned()),
sensitivity_classifications: sensitivity,
governance_owners: owners,
intended_use: pkg.dataset_intended_use.clone(),
confidentiality_level: pkg.dataset_confidentiality_level.clone(),
preprocessing: pkg.dataset_data_preprocessing.clone().unwrap_or_default(),
anonymization: pkg
.dataset_anonymization_method_used
.clone()
.unwrap_or_default(),
});
}
PackageKind::Software => {}
}
comp.calculate_content_hash();
comp
}
fn convert_file(&self, file: &Spdx3File) -> Component {
let format_id = file
.spdx_id
.clone()
.unwrap_or_else(|| file.name.clone().unwrap_or_default());
let name = file.name.clone().unwrap_or_default();
let mut comp = Component::new(name, format_id);
comp.component_type = ComponentType::File;
if let Some(methods) = &file.verified_using {
for method in methods {
if let (Some(algo_str), Some(value)) = (&method.algorithm, &method.hash_value) {
let algorithm = map_spdx3_hash_algorithm(algo_str);
comp.hashes.push(Hash::new(algorithm, value.clone()));
}
}
}
comp.copyright.clone_from(&file.copyright_text);
comp.description.clone_from(&file.description);
comp.calculate_content_hash();
comp
}
fn convert_snippet(&self, snippet: &Spdx3Snippet) -> Component {
let format_id = snippet
.spdx_id
.clone()
.unwrap_or_else(|| snippet.name.clone().unwrap_or_default());
let name = snippet.name.clone().unwrap_or_default();
let mut comp = Component::new(name, format_id);
comp.component_type = ComponentType::File;
let mut range_info = Vec::new();
if let Some(byte_range) = &snippet.byte_range {
range_info.push(format!("bytes {byte_range}"));
}
if let Some(line_range) = &snippet.line_range {
range_info.push(format!("lines {line_range}"));
}
if !range_info.is_empty() {
comp.description = Some(format!("Snippet ({})", range_info.join(", ")));
}
if let Some(methods) = &snippet.verified_using {
for method in methods {
if let (Some(algo_str), Some(value)) = (&method.algorithm, &method.hash_value) {
let algorithm = map_spdx3_hash_algorithm(algo_str);
comp.hashes.push(Hash::new(algorithm, value.clone()));
}
}
}
comp.copyright.clone_from(&snippet.copyright_text);
comp.calculate_content_hash();
comp
}
fn process_vuln_assessment(
&self,
assessment: &Spdx3VulnAssessment,
id_map: &HashMap<String, CanonicalId>,
sbom: &mut NormalizedSbom,
vuln_map: &HashMap<String, Spdx3Vulnerability>,
) {
let vuln_ref_id = assessment.from.as_deref();
let assessed_id = assessment.assessed_element.as_deref().or_else(|| {
assessment
.to
.as_ref()
.and_then(|to| to.first().map(String::as_str))
});
let Some(assessed_id) = assessed_id else {
return;
};
let Some(canonical_id) = id_map.get(assessed_id) else {
return;
};
let vex_state = assessment.resolved_vex_state.clone();
let cvss_score = assessment.score.filter(|s| s.is_finite()).map(|score| {
let version = assessment.vector.as_deref().map_or_else(
|| {
assessment
.resolved_cvss_version
.clone()
.unwrap_or(CvssVersion::V3)
},
|v| {
if v.starts_with("CVSS:4") {
CvssVersion::V4
} else if v.starts_with("CVSS:3.1") {
CvssVersion::V31
} else if v.starts_with("CVSS:3") {
CvssVersion::V3
} else if v.starts_with("CVSS:2") || v.starts_with("AV:") {
CvssVersion::V2
} else {
assessment
.resolved_cvss_version
.clone()
.unwrap_or(CvssVersion::V3)
}
},
);
let mut cs = CvssScore::new(version, score);
cs.vector.clone_from(&assessment.vector);
cs
});
let justification = assessment.justification.as_deref().and_then(|j| {
match j.to_lowercase().replace(['-', '_'], "").as_str() {
"componentnotpresent" => Some(VexJustification::ComponentNotPresent),
"vulnerablecodenotpresent" => Some(VexJustification::VulnerableCodeNotPresent),
"vulnerablecodenotinexecutepath" => {
Some(VexJustification::VulnerableCodeNotInExecutePath)
}
"vulnerablecodecannotbecontrolledbyadversary" => {
Some(VexJustification::VulnerableCodeCannotBeControlledByAdversary)
}
"inlinemitigationsalreadyexist" => {
Some(VexJustification::InlineMitigationsAlreadyExist)
}
_ => None,
}
});
let responses: Vec<VexResponse> = assessment
.action_statement
.as_deref()
.and_then(|a| {
let lower = a.to_lowercase();
if lower.contains("update") || lower.contains("upgrade") {
Some(VexResponse::Update)
} else if lower.contains("rollback") {
Some(VexResponse::Rollback)
} else if lower.contains("workaround") {
Some(VexResponse::Workaround)
} else if lower.contains("will not fix") || lower.contains("wontfix") {
Some(VexResponse::WillNotFix)
} else if lower.contains("cannot fix") || lower.contains("can not fix") {
Some(VexResponse::CanNotFix)
} else {
None
}
})
.into_iter()
.collect();
let vex_status = vex_state.map(|status| VexStatus {
status,
justification: justification.clone(),
action_statement: assessment.action_statement.clone(),
impact_statement: assessment.impact_statement.clone(),
responses,
detail: assessment.status_notes.clone(),
});
if let Some(vuln_id) = vuln_ref_id {
if let Some(vuln) = vuln_map.get(vuln_id) {
let mut vuln_ref = self.convert_vulnerability(vuln);
if let Some(cs) = cvss_score {
vuln_ref.severity = Some(Severity::from_cvss(cs.base_score));
vuln_ref.cvss.push(cs);
}
vuln_ref.vex_status = vex_status.clone();
if let Some(comp) = sbom.components.get_mut(canonical_id) {
comp.vulnerabilities.push(vuln_ref);
if let Some(vs) = vex_status {
comp.vex_status = Some(vs);
}
}
}
} else if let Some(comp) = sbom.components.get_mut(canonical_id) {
if let Some(vs) = vex_status {
comp.vex_status = Some(vs);
}
}
}
fn process_annotation(
&self,
annotation: &Spdx3Annotation,
id_map: &HashMap<String, CanonicalId>,
sbom: &mut NormalizedSbom,
) {
let Some(subject) = &annotation.subject else {
return;
};
let Some(canonical_id) = id_map.get(subject) else {
return;
};
let Some(comp) = sbom.components.get_mut(canonical_id) else {
return;
};
let ann_type = annotation
.annotation_type
.clone()
.unwrap_or_else(|| "OTHER".to_string());
let statement = annotation.statement.clone().unwrap_or_default();
comp.extensions.annotations.push(Annotation {
annotator: annotation
.spdx_id
.clone()
.unwrap_or_else(|| "unknown".to_string()),
annotation_date: DateTime::UNIX_EPOCH,
annotation_type: ann_type,
comment: statement,
});
}
fn process_relationship(
&self,
rel: &Spdx3Relationship,
id_map: &HashMap<String, CanonicalId>,
sbom: &mut NormalizedSbom,
vuln_map: &HashMap<String, Spdx3Vulnerability>,
license_elements: &HashMap<String, String>,
) {
let rel_type = rel.relationship_type.as_deref().unwrap_or("");
let normalized = normalize_relationship_type(rel_type);
let swap = is_inverse_dependency(&normalized);
match normalized.as_str() {
"DEPENDS_ON" | "DEPENDENCY_OF" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::DependsOn, swap);
}
"DEV_DEPENDENCY_OF" | "DEV_DEPENDS_ON" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::DevDependsOn, swap);
}
"BUILD_DEPENDENCY_OF" | "BUILD_DEPENDS_ON" | "BUILD_TOOL_OF" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::BuildDependsOn, swap);
}
"TEST_DEPENDENCY_OF" | "TEST_DEPENDS_ON" | "TEST_TOOL_OF" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::TestDependsOn, swap);
}
"RUNTIME_DEPENDENCY_OF" | "RUNTIME_DEPENDS_ON" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::RuntimeDependsOn, swap);
}
"OPTIONAL_DEPENDENCY_OF" | "OPTIONAL_DEPENDS_ON" => {
self.add_dependency_edge(
rel,
id_map,
sbom,
DependencyType::OptionalDependsOn,
swap,
);
}
"PROVIDED_DEPENDENCY_OF" => {
self.add_dependency_edge(
rel,
id_map,
sbom,
DependencyType::ProvidedDependsOn,
swap,
);
}
"CONTAINS" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::Contains, false);
}
"DESCRIBES" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::Describes, false);
}
"GENERATES" | "GENERATED_FROM" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::Generates, swap);
}
"ANCESTOR_OF" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::AncestorOf, false);
}
"VARIANT_OF" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::VariantOf, false);
}
"DISTRIBUTION_ARTIFACT" => {
self.add_dependency_edge(
rel,
id_map,
sbom,
DependencyType::DistributionArtifact,
false,
);
}
"PATCH_FOR" | "PATCH_APPLIED" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::PatchFor, false);
}
"COPY_OF" | "EXPANDED_FROM_ARCHIVE" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::CopyOf, false);
}
"DYNAMIC_LINK" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::DynamicLink, false);
}
"STATIC_LINK" => {
self.add_dependency_edge(rel, id_map, sbom, DependencyType::StaticLink, false);
}
"HAS_DECLARED_LICENSE" | "HAS_CONCLUDED_LICENSE" => {
if let Some(from_ref) = &rel.from
&& let Some(canonical_id) = id_map.get(from_ref)
&& let Some(to_refs) = &rel.to
{
for to_ref in to_refs {
if let Some(expr) = license_elements.get(to_ref)
&& let Some(comp) = sbom.components.get_mut(canonical_id)
{
let lic = LicenseExpression::new(expr.clone());
if normalized.contains("CONCLUDED") {
comp.licenses.concluded = Some(lic);
} else {
comp.licenses.add_declared(lic);
}
}
}
}
}
"AFFECTS" => {
if let Some(from_ref) = &rel.from
&& let Some(vuln) = vuln_map.get(from_ref)
&& let Some(to_refs) = &rel.to
{
let vuln_ref = self.convert_vulnerability(vuln);
let mut seen: HashSet<&CanonicalId> = HashSet::new();
for to_ref in to_refs {
if let Some(canonical_id) = id_map.get(to_ref)
&& seen.insert(canonical_id)
&& let Some(comp) = sbom.components.get_mut(canonical_id)
{
comp.vulnerabilities.push(vuln_ref.clone());
}
}
}
}
"FIXED_IN" => {
if let Some(from_ref) = &rel.from
&& let Some(vuln) = vuln_map.get(from_ref)
&& let Some(to_refs) = &rel.to
{
let mut base = self.convert_vulnerability(vuln);
base.vex_status = Some(VexStatus {
status: VexState::Fixed,
justification: None,
action_statement: None,
impact_statement: None,
responses: Vec::new(),
detail: None,
});
let mut seen: HashSet<&CanonicalId> = HashSet::new();
for to_ref in to_refs {
if let Some(canonical_id) = id_map.get(to_ref)
&& seen.insert(canonical_id)
&& let Some(comp) = sbom.components.get_mut(canonical_id)
{
comp.vulnerabilities.push(base.clone());
comp.vex_status = Some(VexStatus {
status: VexState::Fixed,
justification: None,
action_statement: None,
impact_statement: None,
responses: Vec::new(),
detail: None,
});
}
}
}
}
"HAS_ASSESSMENT_FOR" => {
}
"TRAINED_ON" => {
if let Some(from_ref) = &rel.from
&& let Some(from_id) = id_map.get(from_ref)
&& let Some(to_refs) = &rel.to
{
for to_ref in to_refs {
let name = id_map
.get(to_ref)
.and_then(|cid| sbom.components.get(cid))
.map(|c| c.name.clone())
.filter(|n| !n.is_empty());
if let Some(comp) = sbom.components.get_mut(from_id) {
let ml = comp.ml_model.get_or_insert_with(Default::default);
ml.training_datasets.push(crate::model::DatasetRef {
reference: Some(to_ref.clone()),
name,
purl: None,
});
}
}
}
}
_ => {
if !rel_type.is_empty() {
self.add_dependency_edge(
rel,
id_map,
sbom,
DependencyType::Other(rel_type.to_string()),
false,
);
}
}
}
}
fn add_dependency_edge(
&self,
rel: &Spdx3Relationship,
id_map: &HashMap<String, CanonicalId>,
sbom: &mut NormalizedSbom,
dep_type: DependencyType,
swap: bool,
) {
if let Some(from_ref) = &rel.from
&& let Some(from_id) = id_map.get(from_ref)
&& let Some(to_refs) = &rel.to
{
for to_ref in to_refs {
if let Some(to_id) = id_map.get(to_ref) {
let (src, dst) = if swap {
(to_id.clone(), from_id.clone())
} else {
(from_id.clone(), to_id.clone())
};
sbom.add_edge(DependencyEdge::new(src, dst, dep_type.clone()));
}
}
}
}
fn convert_vulnerability(&self, vuln: &Spdx3Vulnerability) -> VulnerabilityRef {
let id = vuln
.external_identifier
.as_ref()
.and_then(|ids| {
ids.iter()
.find_map(|id| match id.external_identifier_type.as_deref() {
Some("cve") | Some("securityOther") => Some(id.identifier.clone()),
_ => None,
})
})
.or_else(|| vuln.name.clone())
.unwrap_or_else(|| {
vuln.spdx_id
.clone()
.unwrap_or_else(|| "UNKNOWN".to_string())
});
let source = if id.starts_with("CVE-") {
VulnerabilitySource::Cve
} else if id.starts_with("GHSA-") {
VulnerabilitySource::Ghsa
} else {
VulnerabilitySource::Other("SPDX".to_string())
};
let mut vuln_ref = VulnerabilityRef::new(id, source);
vuln_ref.description = super::capped_description(&vuln.description);
if let Some(t) = &vuln.published_time {
vuln_ref.published = DateTime::parse_from_rfc3339(t)
.ok()
.map(|dt| dt.with_timezone(&Utc));
}
if let Some(t) = &vuln.modified_time {
vuln_ref.modified = DateTime::parse_from_rfc3339(t)
.ok()
.map(|dt| dt.with_timezone(&Utc));
}
vuln_ref
}
}
impl Default for Spdx3Parser {
fn default() -> Self {
Self::new()
}
}
impl SbomParser for Spdx3Parser {
fn parse_str(&self, content: &str) -> Result<NormalizedSbom, ParseError> {
self.parse_json_ld(content)
}
fn supported_versions(&self) -> Vec<&str> {
vec!["3.0", "3.0.1"]
}
fn format_name(&self) -> &'static str {
"SPDX"
}
fn detect(&self, content: &str) -> FormatDetection {
use crate::parsers::contains_json_key;
let content = super::strip_bom(content);
let trimmed = content.trim();
if !trimmed.starts_with('{') {
return FormatDetection::no_match();
}
let has_context = contains_json_key(content, "@context");
let has_spdx3_context =
content.contains("spdx.org/rdf/3") || content.contains("spdx.org/rdf/v3");
let has_type_spdx_document =
contains_json_key(content, "type") && content.contains("\"SpdxDocument\"");
let has_spdx_id = contains_json_key(content, "spdxId");
let has_creation_info = contains_json_key(content, "creationInfo");
let has_element = contains_json_key(content, "element");
let has_root_element = contains_json_key(content, "rootElement");
let version = Self::extract_spec_version(content);
if has_context && has_spdx3_context {
let mut detection =
FormatDetection::with_confidence(FormatConfidence::CERTAIN).variant("JSON-LD");
if let Some(v) = version {
detection = detection.version(&v);
}
return detection;
}
if has_type_spdx_document && has_spdx_id {
let mut detection =
FormatDetection::with_confidence(FormatConfidence::HIGH).variant("JSON-LD");
if let Some(v) = version {
detection = detection.version(&v);
}
return detection;
}
if has_context && (has_creation_info || has_element || has_root_element) {
let mut detection = FormatDetection::with_confidence(FormatConfidence::MEDIUM)
.variant("JSON-LD")
.warning("Missing SpdxDocument type marker");
if let Some(v) = version {
detection = detection.version(&v);
}
return detection;
}
FormatDetection::no_match()
}
}
impl Spdx3Parser {
fn extract_spec_version(content: &str) -> Option<String> {
if let Some(idx) = content.find("\"specVersion\"") {
let after = &content[idx..];
if let Some(colon_idx) = after.find(':') {
let value_part = &after[colon_idx + 1..];
if let Some(quote_start) = value_part.find('"') {
let after_quote = &value_part[quote_start + 1..];
if let Some(quote_end) = after_quote.find('"') {
let version = &after_quote[..quote_end];
if version.starts_with("3.") {
return Some(version.to_string());
}
}
}
}
}
None
}
}
fn normalize_relationship_type(rel_type: &str) -> String {
let mut out = String::with_capacity(rel_type.len() + 4);
let mut prev_lower_or_digit = false;
for ch in rel_type.chars() {
if ch.is_ascii_uppercase() && prev_lower_or_digit {
out.push('_');
}
out.push(ch.to_ascii_uppercase());
prev_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
}
out
}
fn is_inverse_dependency(normalized: &str) -> bool {
matches!(
normalized,
"DEPENDENCY_OF"
| "DEV_DEPENDENCY_OF"
| "BUILD_DEPENDENCY_OF"
| "TEST_DEPENDENCY_OF"
| "RUNTIME_DEPENDENCY_OF"
| "OPTIONAL_DEPENDENCY_OF"
| "PROVIDED_DEPENDENCY_OF"
| "BUILD_TOOL_OF"
| "TEST_TOOL_OF"
| "GENERATED_FROM"
)
}
fn derive_completeness_declaration(
relationships: &[Spdx3Relationship],
) -> crate::model::CompletenessDeclaration {
use crate::model::CompletenessDeclaration;
let mut any_complete = false;
let mut any_no_assertion = false;
for rel in relationships {
let Some(completeness) = rel.completeness.as_deref() else {
continue;
};
let normalized =
normalize_relationship_type(rel.relationship_type.as_deref().unwrap_or(""));
let is_dependency = matches!(
normalized.as_str(),
"DEPENDS_ON"
| "DEV_DEPENDS_ON"
| "BUILD_DEPENDS_ON"
| "TEST_DEPENDS_ON"
| "RUNTIME_DEPENDS_ON"
| "OPTIONAL_DEPENDS_ON"
| "DEPENDENCY_OF"
| "DEV_DEPENDENCY_OF"
| "BUILD_DEPENDENCY_OF"
| "TEST_DEPENDENCY_OF"
| "RUNTIME_DEPENDENCY_OF"
| "OPTIONAL_DEPENDENCY_OF"
| "PROVIDED_DEPENDENCY_OF"
);
if !is_dependency {
continue;
}
match completeness.trim().to_ascii_lowercase().as_str() {
"incomplete" => return CompletenessDeclaration::Incomplete,
"complete" => any_complete = true,
"noassertion" => any_no_assertion = true,
_ => {}
}
}
if any_complete {
CompletenessDeclaration::Complete
} else if any_no_assertion {
CompletenessDeclaration::NotSpecified
} else {
CompletenessDeclaration::Unknown
}
}
fn map_spdx3_hash_algorithm(algo: &str) -> HashAlgorithm {
match algo.to_uppercase().as_str() {
"MD5" => HashAlgorithm::Md5,
"SHA1" | "SHA-1" => HashAlgorithm::Sha1,
"SHA256" | "SHA-256" => HashAlgorithm::Sha256,
"SHA384" | "SHA-384" => HashAlgorithm::Sha384,
"SHA512" | "SHA-512" => HashAlgorithm::Sha512,
"SHA3-256" | "SHA3_256" => HashAlgorithm::Sha3_256,
"SHA3-384" | "SHA3_384" => HashAlgorithm::Sha3_384,
"SHA3-512" | "SHA3_512" => HashAlgorithm::Sha3_512,
"BLAKE2B-256" | "BLAKE2B256" => HashAlgorithm::Blake2b256,
"BLAKE2B-384" | "BLAKE2B384" => HashAlgorithm::Blake2b384,
"BLAKE2B-512" | "BLAKE2B512" => HashAlgorithm::Blake2b512,
"BLAKE3" => HashAlgorithm::Blake3,
"STREEBOG-256" | "STREEBOG256" => HashAlgorithm::Streebog256,
"STREEBOG-512" | "STREEBOG512" => HashAlgorithm::Streebog512,
other => HashAlgorithm::Other(other.to_string()),
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3Document {
#[serde(rename = "@context")]
context: Option<serde_json::Value>,
spdx_id: Option<String>,
#[serde(rename = "type")]
type_: Option<String>,
name: Option<String>,
creation_info: Option<Spdx3CreationInfoRef>,
data_license: Option<String>,
namespace_map: Option<Vec<Spdx3NamespaceMap>>,
#[serde(rename = "import")]
imports: Option<Vec<Spdx3ExternalMap>>,
root_element: Option<Vec<String>>,
element: Option<Vec<Spdx3Element>>,
#[serde(rename = "@graph")]
graph: Option<Vec<Spdx3Element>>,
profile_conformance: Option<Vec<String>>,
verified_using: Option<Vec<Spdx3IntegrityMethod>>,
description: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3CreationInfo {
#[serde(rename = "@id")]
id: Option<String>,
created: Option<String>,
created_by: Option<Vec<String>>,
created_using: Option<Vec<String>>,
spec_version: Option<String>,
comment: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
enum Spdx3CreationInfoRef {
Inline(Spdx3CreationInfo),
Ref(String),
}
impl Spdx3CreationInfoRef {
fn resolve<'a>(
&'a self,
ci_map: &'a HashMap<String, Spdx3CreationInfo>,
) -> Option<&'a Spdx3CreationInfo> {
match self {
Self::Inline(ci) => Some(ci),
Self::Ref(id) => ci_map.get(id),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type")]
#[allow(dead_code)]
enum Spdx3Element {
#[serde(alias = "software_Package", alias = "Software_Package")]
Package(Box<Spdx3Package>),
#[serde(alias = "software_File", alias = "Software_File")]
File(Box<Spdx3File>),
#[serde(alias = "ai_AIPackage", alias = "AIPackage")]
AiPackage(Box<Spdx3Package>),
#[serde(alias = "dataset_DatasetPackage", alias = "DatasetPackage")]
DatasetPackage(Box<Spdx3Package>),
#[serde(alias = "software_Snippet", alias = "Software_Snippet")]
Snippet(Spdx3Snippet),
Relationship(Spdx3Relationship),
#[serde(alias = "security_Vulnerability")]
Vulnerability(Box<Spdx3Vulnerability>),
Annotation(Spdx3Annotation),
Person(Spdx3Agent),
Organization(Spdx3Agent),
Tool(Spdx3Agent),
SoftwareAgent(Spdx3Agent),
#[serde(alias = "software_Sbom", alias = "Sbom")]
Bom(Spdx3Collection),
Bundle(Spdx3Collection),
#[serde(
alias = "simplelicensing_LicenseExpression",
alias = "SimpleLicensingLicenseExpression"
)]
LicenseExpression(Spdx3LicenseExpression),
#[serde(alias = "simplelicensing_SimpleLicensingText", alias = "CustomLicense")]
SimpleLicensingText(Spdx3SimpleLicensingText),
#[serde(
alias = "security_VexAffectedVulnAssessmentRelationship",
alias = "VexAffectedVulnAssessmentRelationship"
)]
VexAffected(Spdx3VulnAssessment),
#[serde(
alias = "security_VexFixedVulnAssessmentRelationship",
alias = "VexFixedVulnAssessmentRelationship"
)]
VexFixed(Spdx3VulnAssessment),
#[serde(
alias = "security_VexNotAffectedVulnAssessmentRelationship",
alias = "VexNotAffectedVulnAssessmentRelationship"
)]
VexNotAffected(Spdx3VulnAssessment),
#[serde(
alias = "security_VexUnderInvestigationVulnAssessmentRelationship",
alias = "VexUnderInvestigationVulnAssessmentRelationship"
)]
VexUnderInvestigation(Spdx3VulnAssessment),
#[serde(
alias = "security_CvssV3VulnAssessmentRelationship",
alias = "CvssV3VulnAssessmentRelationship"
)]
CvssV3Assessment(Spdx3VulnAssessment),
#[serde(
alias = "security_CvssV4VulnAssessmentRelationship",
alias = "CvssV4VulnAssessmentRelationship"
)]
CvssV4Assessment(Spdx3VulnAssessment),
#[serde(
alias = "security_EpssVulnAssessmentRelationship",
alias = "EpssVulnAssessmentRelationship"
)]
EpssAssessment(Spdx3VulnAssessment),
#[serde(
alias = "security_SsvcVulnAssessmentRelationship",
alias = "SsvcVulnAssessmentRelationship"
)]
SsvcAssessment(Spdx3VulnAssessment),
LifecycleScopedRelationship(Spdx3Relationship),
SpdxDocument(Box<Spdx3DocumentMeta>),
CreationInfo(Spdx3CreationInfo),
#[serde(other)]
Unknown,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
struct Spdx3DocumentMeta {
spdx_id: Option<String>,
name: Option<String>,
creation_info: Option<Spdx3CreationInfoRef>,
root_element: Option<Vec<String>>,
description: Option<String>,
profile_conformance: Option<Vec<String>>,
data_license: Option<String>,
namespace_map: Option<Vec<Spdx3NamespaceMap>>,
#[serde(rename = "import")]
imports: Option<Vec<Spdx3ExternalMap>>,
verified_using: Option<Vec<Spdx3IntegrityMethod>>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3Package {
spdx_id: Option<String>,
name: Option<String>,
#[serde(alias = "software_packageVersion")]
package_version: Option<String>,
#[serde(alias = "software_packageUrl")]
package_url: Option<String>,
#[serde(alias = "software_downloadLocation")]
download_location: Option<String>,
#[serde(alias = "software_homePage")]
home_page: Option<String>,
#[serde(alias = "software_copyrightText")]
copyright_text: Option<String>,
description: Option<String>,
supplied_by: Option<Vec<String>>,
originated_by: Option<Vec<String>>,
verified_using: Option<Vec<Spdx3IntegrityMethod>>,
external_identifier: Option<Vec<Spdx3ExternalIdentifier>>,
external_ref: Option<Vec<Spdx3ExternalRef>>,
#[serde(alias = "software_primaryPurpose")]
primary_purpose: Option<String>,
#[serde(alias = "software_additionalPurpose")]
additional_purpose: Option<Vec<String>>,
content_identifier: Option<String>,
#[serde(alias = "software_attributionText")]
attribution_text: Option<Vec<String>>,
support_level: Option<String>,
valid_until_time: Option<String>,
built_time: Option<String>,
release_time: Option<String>,
creation_info: Option<Spdx3CreationInfoRef>,
#[serde(rename = "ai_typeOfModel")]
ai_type_of_model: Option<Vec<String>>,
#[serde(rename = "ai_energyConsumption")]
ai_energy_consumption: Option<Spdx3EnergyConsumption>,
#[serde(rename = "ai_informationAboutTraining")]
ai_information_about_training: Option<String>,
#[serde(rename = "ai_informationAboutApplication")]
ai_information_about_application: Option<String>,
#[serde(rename = "ai_limitation")]
ai_limitation: Option<String>,
#[serde(rename = "ai_modelExplainability")]
ai_model_explainability: Option<Vec<String>>,
#[serde(rename = "ai_safetyRiskAssessment")]
ai_safety_risk_assessment: Option<String>,
#[serde(rename = "ai_domain")]
ai_domain: Option<Vec<String>>,
#[serde(rename = "ai_standardCompliance")]
ai_standard_compliance: Option<Vec<String>>,
#[serde(rename = "ai_metric")]
ai_metric: Option<Vec<serde_json::Value>>,
#[serde(rename = "ai_metricDecisionThreshold")]
ai_metric_decision_threshold: Option<Vec<serde_json::Value>>,
#[serde(rename = "ai_useSensitivePersonalInformation")]
ai_use_sensitive_personal_information: Option<String>,
#[serde(rename = "ai_modelDataPreprocessing")]
ai_model_data_preprocessing: Option<Vec<String>>,
#[serde(rename = "dataset_datasetType")]
dataset_dataset_type: Option<Vec<String>>,
#[serde(rename = "dataset_hasSensitivePersonalInformation")]
dataset_has_sensitive_personal_information: Option<String>,
#[serde(rename = "dataset_confidentialityLevel")]
dataset_confidentiality_level: Option<String>,
#[serde(rename = "dataset_intendedUse")]
dataset_intended_use: Option<String>,
#[serde(rename = "dataset_knownBias")]
dataset_known_bias: Option<Vec<String>>,
#[serde(rename = "dataset_anonymizationMethodUsed")]
dataset_anonymization_method_used: Option<Vec<String>>,
#[serde(rename = "dataset_dataPreprocessing")]
dataset_data_preprocessing: Option<Vec<String>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PackageKind {
Software,
AiModel,
Dataset,
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
struct Spdx3EnergyConsumption {
#[serde(rename = "ai_trainingEnergyConsumption")]
ai_training_energy_consumption: Option<Vec<Spdx3EnergyConsumptionDescription>>,
#[serde(rename = "ai_finetuningEnergyConsumption")]
ai_finetuning_energy_consumption: Option<Vec<Spdx3EnergyConsumptionDescription>>,
#[serde(rename = "ai_inferenceEnergyConsumption")]
ai_inference_energy_consumption: Option<Vec<Spdx3EnergyConsumptionDescription>>,
}
#[derive(Debug, Deserialize, Clone)]
struct Spdx3EnergyConsumptionDescription {
#[serde(
rename = "ai_energyQuantity",
default,
deserialize_with = "de_string_or_number_opt"
)]
ai_energy_quantity: Option<f64>,
#[serde(rename = "ai_energyUnit")]
ai_energy_unit: Option<String>,
}
impl Spdx3EnergyConsumption {
fn training_energy_kwh(&self) -> Option<f64> {
let entries = self.ai_training_energy_consumption.as_ref()?;
let total: f64 = entries
.iter()
.filter_map(Spdx3EnergyConsumptionDescription::as_kwh)
.sum();
(total > 0.0).then_some(total)
}
}
impl Spdx3EnergyConsumptionDescription {
fn as_kwh(&self) -> Option<f64> {
let value = self.ai_energy_quantity?;
let kwh = match self.ai_energy_unit.as_deref() {
Some("kilowattHour") | None => value,
Some("wattHour") => value / 1_000.0,
Some("megawattHour") => value * 1_000.0,
Some("joule") => value / 3_600_000.0,
Some("kilojoule") => value / 3_600.0,
Some("megajoule") => value / 3.6,
Some(_) => return None,
};
Some(kwh)
}
}
fn de_string_or_number_opt<'de, D>(deserializer: D) -> Result<Option<f64>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StrOrNum {
Num(f64),
Str(String),
}
Ok(match Option::<StrOrNum>::deserialize(deserializer)? {
None => None,
Some(StrOrNum::Num(n)) => Some(n),
Some(StrOrNum::Str(s)) => s.trim().parse::<f64>().ok(),
})
}
fn de_range_opt<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(value.and_then(|v| match v {
serde_json::Value::String(s) => Some(s),
serde_json::Value::Object(map) => {
let get = |k: &str| map.get(k).and_then(serde_json::Value::as_u64);
match (get("beginIntegerRange"), get("endIntegerRange")) {
(Some(begin), Some(end)) => Some(format!("{begin}:{end}")),
(Some(begin), None) => Some(format!("{begin}:")),
(None, Some(end)) => Some(format!(":{end}")),
(None, None) => None,
}
}
_ => None,
}))
}
fn spdx3_metric_to_entry(value: serde_json::Value) -> crate::model::MetricEntry {
let stringify = |v: &serde_json::Value| -> Option<String> {
match v {
serde_json::Value::Null => None,
serde_json::Value::String(text) => Some(text.clone()),
other => Some(other.to_string()),
}
};
match value {
serde_json::Value::Object(map) => {
let pick = |keys: &[&str]| keys.iter().find_map(|k| map.get(*k).and_then(stringify));
crate::model::MetricEntry {
metric_type: pick(&["ai_name", "name", "type", "ai_type", "key"]),
value: pick(&["ai_value", "value", "ai_decisionThreshold"]),
slice: pick(&["ai_slice", "slice"]),
}
}
serde_json::Value::String(text) => crate::model::MetricEntry {
value: Some(text),
..Default::default()
},
serde_json::Value::Null => crate::model::MetricEntry::default(),
other => crate::model::MetricEntry {
value: Some(other.to_string()),
..Default::default()
},
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3File {
spdx_id: Option<String>,
name: Option<String>,
#[serde(alias = "software_copyrightText")]
copyright_text: Option<String>,
description: Option<String>,
verified_using: Option<Vec<Spdx3IntegrityMethod>>,
#[serde(alias = "software_fileKind")]
file_kind: Option<String>,
creation_info: Option<Spdx3CreationInfoRef>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3Snippet {
spdx_id: Option<String>,
name: Option<String>,
#[serde(alias = "software_copyrightText")]
copyright_text: Option<String>,
description: Option<String>,
#[serde(
alias = "software_byteRange",
default,
deserialize_with = "de_range_opt"
)]
byte_range: Option<String>,
#[serde(
alias = "software_lineRange",
default,
deserialize_with = "de_range_opt"
)]
line_range: Option<String>,
#[serde(alias = "software_snippetFromFile")]
snippet_from_file: Option<String>,
verified_using: Option<Vec<Spdx3IntegrityMethod>>,
creation_info: Option<Spdx3CreationInfoRef>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3Relationship {
spdx_id: Option<String>,
from: Option<String>,
to: Option<Vec<String>>,
relationship_type: Option<String>,
start_time: Option<String>,
end_time: Option<String>,
completeness: Option<String>,
creation_info: Option<Spdx3CreationInfoRef>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3Vulnerability {
spdx_id: Option<String>,
name: Option<String>,
description: Option<String>,
#[serde(alias = "security_publishedTime")]
published_time: Option<String>,
#[serde(alias = "security_modifiedTime")]
modified_time: Option<String>,
#[serde(alias = "security_withdrawnTime")]
withdrawn_time: Option<String>,
external_identifier: Option<Vec<Spdx3ExternalIdentifier>>,
external_ref: Option<Vec<Spdx3ExternalRef>>,
creation_info: Option<Spdx3CreationInfoRef>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3VulnAssessment {
spdx_id: Option<String>,
from: Option<String>,
to: Option<Vec<String>>,
#[serde(alias = "security_assessedElement")]
assessed_element: Option<String>,
#[serde(alias = "security_publishedTime")]
published_time: Option<String>,
#[serde(alias = "security_modifiedTime")]
modified_time: Option<String>,
supplied_by: Option<String>,
#[serde(alias = "security_score")]
score: Option<f32>,
#[serde(alias = "security_severity")]
severity: Option<String>,
#[serde(alias = "security_vectorString", alias = "vectorString")]
vector: Option<String>,
#[serde(alias = "security_statusNotes")]
status_notes: Option<String>,
#[serde(alias = "security_justificationType", alias = "justificationType")]
justification: Option<String>,
#[serde(alias = "security_impactStatement")]
impact_statement: Option<String>,
#[serde(alias = "security_actionStatement")]
action_statement: Option<String>,
#[serde(alias = "security_vexVersion")]
vex_version: Option<String>,
#[serde(skip)]
resolved_vex_state: Option<VexState>,
#[serde(skip)]
resolved_cvss_version: Option<CvssVersion>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3Annotation {
spdx_id: Option<String>,
subject: Option<String>,
annotation_type: Option<String>,
statement: Option<String>,
content_type: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3Agent {
spdx_id: Option<String>,
name: Option<String>,
description: Option<String>,
#[serde(skip)]
resolved_type_tag: Option<&'static str>,
}
impl Spdx3Agent {
fn agent_type(&self) -> (CreatorType, &'static str) {
match self.resolved_type_tag {
Some("Person") => (CreatorType::Person, "Person"),
Some("Organization") => (CreatorType::Organization, "Organization"),
Some("Tool" | "SoftwareAgent") => (CreatorType::Tool, "Tool"),
_ => (CreatorType::Tool, "Agent"),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3Collection {
spdx_id: Option<String>,
name: Option<String>,
element: Option<Vec<String>>,
root_element: Option<Vec<String>>,
context: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3IntegrityMethod {
#[serde(rename = "type")]
method_type: Option<String>,
algorithm: Option<String>,
hash_value: Option<String>,
comment: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3ExternalIdentifier {
identifier: String,
external_identifier_type: Option<String>,
identifier_locator: Option<Vec<String>>,
issuing_authority: Option<String>,
comment: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3ExternalRef {
locator: Option<Vec<String>>,
external_ref_type: Option<String>,
content_type: Option<String>,
comment: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3LicenseExpression {
spdx_id: Option<String>,
#[serde(alias = "simplelicensing_licenseExpression")]
license_expression: Option<String>,
#[serde(alias = "simplelicensing_licenseListVersion")]
license_list_version: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3SimpleLicensingText {
spdx_id: Option<String>,
#[serde(alias = "simplelicensing_licenseText")]
license_text: Option<String>,
license_name: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3NamespaceMap {
prefix: Option<String>,
namespace: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Spdx3ExternalMap {
external_spdx_id: Option<String>,
location_hint: Option<String>,
verified_using: Option<Vec<Spdx3IntegrityMethod>>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spdx3_detection_with_context() {
let parser = Spdx3Parser::new();
let content = r#"{"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld", "type": "SpdxDocument"}"#;
let detection = parser.detect(content);
assert!(detection.confidence.value() >= 0.95);
assert_eq!(detection.variant, Some("JSON-LD".to_string()));
}
#[test]
fn test_spdx3_detection_without_context() {
let parser = Spdx3Parser::new();
let content = r#"{"type": "SpdxDocument", "spdxId": "urn:spdx:document:test"}"#;
let detection = parser.detect(content);
assert!(detection.confidence.value() >= 0.5);
}
#[test]
fn test_spdx3_detection_not_spdx() {
let parser = Spdx3Parser::new();
let content = r#"{"bomFormat": "CycloneDX", "specVersion": "1.7"}"#;
let detection = parser.detect(content);
assert!(detection.confidence.value() < 0.25);
}
#[test]
fn test_spdx3_detection_spdx2_not_matched() {
let parser = Spdx3Parser::new();
let content = r#"{"spdxVersion": "SPDX-2.3", "SPDXID": "SPDXRef-DOCUMENT"}"#;
let detection = parser.detect(content);
assert!(detection.confidence.value() < 0.25);
}
#[test]
fn test_spdx3_supported_versions() {
let parser = Spdx3Parser::new();
assert!(parser.supported_versions().contains(&"3.0"));
assert!(parser.supported_versions().contains(&"3.0.1"));
}
#[test]
fn test_spdx3_ai_dataset_type_tags_deserialize() {
let ai: Spdx3Element =
serde_json::from_str(r#"{"type": "ai_AIPackage", "spdxId": "urn:x:m", "name": "m"}"#)
.expect("ai_AIPackage should deserialize");
assert!(matches!(ai, Spdx3Element::AiPackage(_)));
let ds: Spdx3Element = serde_json::from_str(
r#"{"type": "dataset_DatasetPackage", "spdxId": "urn:x:d", "name": "d"}"#,
)
.expect("dataset_DatasetPackage should deserialize");
assert!(matches!(ds, Spdx3Element::DatasetPackage(_)));
}
#[test]
fn test_spdx3_dataset_provenance_fields_mapped() {
let pkg: Spdx3Package = serde_json::from_str(
r#"{
"type": "dataset_DatasetPackage",
"spdxId": "urn:x:d",
"name": "reviews",
"dataset_intendedUse": "model fine-tuning",
"dataset_confidentialityLevel": "amber",
"dataset_dataPreprocessing": ["dedup", "normalize"],
"dataset_anonymizationMethodUsed": ["k-anonymity"],
"dataset_hasSensitivePersonalInformation": "no"
}"#,
)
.expect("dataset package should deserialize");
let converter = Spdx3Parser::new();
let comp = converter.convert_package(&pkg, PackageKind::Dataset, &HashMap::new());
let ds = comp.dataset.expect("dataset info present");
assert_eq!(ds.intended_use.as_deref(), Some("model fine-tuning"));
assert_eq!(ds.confidentiality_level.as_deref(), Some("amber"));
assert_eq!(ds.preprocessing, vec!["dedup", "normalize"]);
assert_eq!(ds.anonymization, vec!["k-anonymity"]);
assert!(
ds.sensitivity_classifications
.contains(&"amber".to_string())
);
}
fn spdx3_lib(elements: &str) -> NormalizedSbom {
let doc = format!(
r#"{{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"type": "SpdxDocument", "spdxId": "urn:doc",
"element": [
{{"type": "software_Package", "spdxId": "urn:pkg:lib", "name": "lib",
"software_packageVersion": "1.0",
"software_packageUrl": "pkg:npm/lib@1.0"}},
{{"type": "security_Vulnerability", "spdxId": "urn:vuln:cve",
"name": "CVE-2025-9999", "description": "boom"}},
{elements}
]
}}"#
);
Spdx3Parser::new().parse_str(&doc).expect("parse")
}
fn spdx3_component<'a>(sbom: &'a NormalizedSbom, name: &str) -> &'a Component {
sbom.components
.values()
.find(|c| c.name == name)
.expect("component present")
}
#[test]
fn normalize_relationship_type_handles_camel_and_snake() {
assert_eq!(normalize_relationship_type("dependsOn"), "DEPENDS_ON");
assert_eq!(normalize_relationship_type("DEPENDS_ON"), "DEPENDS_ON");
assert_eq!(normalize_relationship_type("dependencyOf"), "DEPENDENCY_OF");
assert_eq!(
normalize_relationship_type("hasDeclaredLicense"),
"HAS_DECLARED_LICENSE"
);
assert_eq!(normalize_relationship_type("fixedIn"), "FIXED_IN");
assert_eq!(normalize_relationship_type("buildToolOf"), "BUILD_TOOL_OF");
assert!(is_inverse_dependency("DEPENDENCY_OF"));
assert!(is_inverse_dependency("GENERATED_FROM"));
assert!(!is_inverse_dependency("DEPENDS_ON"));
assert!(!is_inverse_dependency("CONTAINS"));
}
fn edge_between<'a>(sbom: &'a NormalizedSbom, from_name: &str, to_name: &str) -> bool {
let id = |name: &str| {
sbom.components
.iter()
.find(|(_, c)| c.name == name)
.map(|(id, _)| id.clone())
};
let (Some(f), Some(t)) = (id(from_name), id(to_name)) else {
return false;
};
sbom.edges.iter().any(|e| e.from == f && e.to == t)
}
#[test]
fn spdx3_camelcase_relationship_produces_edge() {
let doc = r#"{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"type": "SpdxDocument", "spdxId": "urn:doc",
"element": [
{"type": "software_Package", "spdxId": "urn:app", "name": "app",
"software_packageUrl": "pkg:npm/app@1.0"},
{"type": "software_Package", "spdxId": "urn:lib", "name": "lib",
"software_packageUrl": "pkg:npm/lib@1.0"},
{"type": "Relationship", "spdxId": "urn:rel",
"relationshipType": "dependsOn", "from": "urn:app", "to": ["urn:lib"]}
]
}"#;
let sbom = Spdx3Parser::new().parse_str(doc).expect("parse");
assert!(
edge_between(&sbom, "app", "lib"),
"camelCase dependsOn must yield app→lib, edges={:?}",
sbom.edges
);
}
#[test]
fn spdx3_inverse_dependency_swaps_direction() {
let doc = r#"{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"type": "SpdxDocument", "spdxId": "urn:doc",
"element": [
{"type": "software_Package", "spdxId": "urn:app", "name": "app",
"software_packageUrl": "pkg:npm/app@1.0"},
{"type": "software_Package", "spdxId": "urn:lib", "name": "lib",
"software_packageUrl": "pkg:npm/lib@1.0"},
{"type": "Relationship", "spdxId": "urn:rel",
"relationshipType": "dependencyOf", "from": "urn:lib", "to": ["urn:app"]}
]
}"#;
let sbom = Spdx3Parser::new().parse_str(doc).expect("parse");
assert!(
edge_between(&sbom, "app", "lib"),
"dependencyOf must swap to app→lib, edges={:?}",
sbom.edges
);
assert!(
!edge_between(&sbom, "lib", "app"),
"the un-swapped lib→app edge must not exist"
);
}
#[test]
fn spdx3_graph_envelope_is_not_empty() {
let doc = r#"{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"@graph": [
{"type": "SpdxDocument", "spdxId": "urn:doc", "name": "d",
"rootElement": ["urn:app"], "element": ["urn:app", "urn:lib", "urn:rel"]},
{"type": "software_Package", "spdxId": "urn:app", "name": "app",
"software_packageUrl": "pkg:npm/app@1.0"},
{"type": "software_Package", "spdxId": "urn:lib", "name": "lib",
"software_packageUrl": "pkg:npm/lib@1.0"},
{"type": "Relationship", "spdxId": "urn:rel",
"relationshipType": "dependsOn", "from": "urn:app", "to": ["urn:lib"]}
]
}"#;
let sbom = Spdx3Parser::new().parse_str(doc).expect("parse");
assert_eq!(sbom.component_count(), 2, "@graph elements must be parsed");
assert!(edge_between(&sbom, "app", "lib"));
assert_eq!(sbom.document.name.as_deref(), Some("d"));
assert_eq!(
sbom.primary_component().map(|c| c.name.as_str()),
Some("app"),
"rootElement must designate the primary component in @graph mode"
);
}
#[test]
fn spdx3_affects_dedups_duplicate_targets() {
let sbom = spdx3_lib(
r#"{"type": "Relationship", "spdxId": "urn:rel:affects",
"relationshipType": "AFFECTS", "from": "urn:vuln:cve",
"to": ["urn:pkg:lib", "urn:pkg:lib", "urn:pkg:lib"]}"#,
);
assert_eq!(
spdx3_component(&sbom, "lib").vulnerabilities.len(),
1,
"duplicated AFFECTS targets must dedup, not attach N times"
);
}
#[test]
fn spdx3_unknown_vex_justification_is_none() {
let sbom = spdx3_lib(
r#"{"type": "security_VexNotAffectedVulnAssessmentRelationship",
"spdxId": "urn:assess:vex", "from": "urn:vuln:cve",
"assessedElement": "urn:pkg:lib",
"security_justificationType": "totally_made_up"}"#,
);
if let Some(vex) = &spdx3_component(&sbom, "lib").vex_status {
assert_eq!(
vex.justification, None,
"unknown justification must not fabricate a strong claim"
);
}
}
#[test]
fn test_spdx3_model_data_preprocessing_mapped() {
let pkg: Spdx3Package = serde_json::from_str(
r#"{
"type": "ai_AIPackage",
"spdxId": "urn:x:m",
"name": "model",
"ai_modelDataPreprocessing": ["tokenize", "truncate"]
}"#,
)
.expect("ai package should deserialize");
let converter = Spdx3Parser::new();
let comp = converter.convert_package(&pkg, PackageKind::AiModel, &HashMap::new());
let ml = comp.ml_model.expect("ml model info present");
assert_eq!(ml.data_preprocessing, vec!["tokenize", "truncate"]);
}
#[test]
fn test_spdx3_training_energy_kwh_sum_and_units() {
let ec: Spdx3EnergyConsumption = serde_json::from_str(
r#"{
"ai_trainingEnergyConsumption": [
{"ai_energyQuantity": "100", "ai_energyUnit": "kilowattHour"},
{"ai_energyQuantity": 3.6, "ai_energyUnit": "megajoule"}
],
"ai_inferenceEnergyConsumption": [
{"ai_energyQuantity": 999, "ai_energyUnit": "kilowattHour"}
]
}"#,
)
.expect("energy consumption should deserialize");
let total = ec.training_energy_kwh().expect("training energy");
assert!((total - 101.0).abs() < 1e-9, "got {total}");
}
#[test]
fn spdx3_issue_305_canonical_reproducer_parses() {
let doc = r#"{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"@graph": [
{ "type": "CreationInfo", "@id": "_:ci", "specVersion": "3.0.1",
"created": "2026-07-10T15:23:05Z",
"createdBy": ["urn:example:creator"], "createdUsing": ["urn:example:tool"] },
{ "type": "Organization", "spdxId": "urn:example:creator",
"creationInfo": "_:ci", "name": "Example Org" },
{ "type": "Tool", "spdxId": "urn:example:tool",
"creationInfo": "_:ci", "name": "ExampleTool" },
{ "type": "SpdxDocument", "spdxId": "urn:example:document",
"creationInfo": "_:ci", "name": "example",
"profileConformance": ["core", "software", "simpleLicensing"],
"dataLicense": "urn:example:license-cc0",
"rootElement": ["urn:example:sbom"] },
{ "type": "simplelicensing_LicenseExpression",
"spdxId": "urn:example:license-cc0", "creationInfo": "_:ci",
"simplelicensing_licenseExpression": "CC0-1.0" },
{ "type": "software_Sbom", "spdxId": "urn:example:sbom",
"creationInfo": "_:ci", "rootElement": ["urn:example:package"],
"software_sbomType": ["source"] },
{ "type": "software_Package", "spdxId": "urn:example:package",
"creationInfo": "_:ci", "name": "example-package",
"software_packageVersion": "1.0.0",
"software_primaryPurpose": "source" }
]
}"#;
let sbom = Spdx3Parser::new()
.parse_str(doc)
.expect("canonical SPDX 3.0.1 JSON-LD must parse");
assert_eq!(sbom.component_count(), 1, "the software_Package must parse");
let pkg = spdx3_component(&sbom, "example-package");
assert_eq!(pkg.version.as_deref(), Some("1.0.0"));
assert_eq!(sbom.document.name.as_deref(), Some("example"));
assert_eq!(sbom.document.spec_version, "3.0.1");
assert_eq!(
sbom.document.created,
DateTime::parse_from_rfc3339("2026-07-10T15:23:05Z")
.expect("valid RFC3339")
.with_timezone(&Utc)
);
assert!(
sbom.document
.creators
.iter()
.any(|c| matches!(c.creator_type, CreatorType::Organization)
&& c.name == "Example Org"),
"createdBy must resolve through the CreationInfo node to the \
Organization agent, got {:?}",
sbom.document.creators
);
assert!(
sbom.document
.creators
.iter()
.any(|c| matches!(c.creator_type, CreatorType::Tool) && c.name == "ExampleTool"),
"createdUsing must resolve to the Tool agent, got {:?}",
sbom.document.creators
);
assert_eq!(
sbom.primary_component().map(|c| c.name.as_str()),
Some("example-package"),
"rootElement must resolve through the software_Sbom container"
);
assert_eq!(
sbom.document.distribution_classification.as_deref(),
Some("core, software, simpleLicensing"),
"profileConformance must lift from the @graph SpdxDocument node"
);
let ext = sbom
.extensions
.spdx
.as_ref()
.expect("spdx extensions present");
assert_eq!(
ext.get("dataLicense").and_then(|v| v.as_str()),
Some("CC0-1.0"),
"dataLicense IRI ref must resolve through the LicenseExpression element"
);
}
#[test]
fn spdx3_bare_single_element_document_parses() {
let doc = r#"{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"type": "software_Package", "spdxId": "urn:pkg", "name": "solo",
"software_packageVersion": "3.2.1",
"creationInfo": {"specVersion": "3.0.1", "created": "2026-01-01T00:00:00Z"}
}"#;
let sbom = Spdx3Parser::new()
.parse_str(doc)
.expect("bare element must parse");
assert_eq!(sbom.component_count(), 1);
let pkg = spdx3_component(&sbom, "solo");
assert_eq!(pkg.version.as_deref(), Some("3.2.1"));
let sbom2 = Spdx3Parser::new()
.parse_json_reader(doc.as_bytes())
.expect("reader path must parse the bare element too");
assert_eq!(sbom2.component_count(), 1);
}
#[test]
fn spdx3_creation_info_inline_object_form_parses() {
let doc = r#"{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"@graph": [
{"type": "Organization", "spdxId": "urn:org", "name": "Acme"},
{"type": "SpdxDocument", "spdxId": "urn:doc", "name": "d",
"creationInfo": {"specVersion": "3.0.1",
"created": "2026-01-01T00:00:00Z", "createdBy": ["urn:org"]},
"rootElement": ["urn:pkg"]},
{"type": "software_Package", "spdxId": "urn:pkg", "name": "p",
"creationInfo": {"specVersion": "3.0.1", "created": "2026-01-01T00:00:00Z"}}
]
}"#;
let sbom = Spdx3Parser::new().parse_str(doc).expect("parse");
assert_eq!(sbom.component_count(), 1);
assert_eq!(sbom.document.spec_version, "3.0.1");
assert!(
sbom.document
.creators
.iter()
.any(|c| matches!(c.creator_type, CreatorType::Organization) && c.name == "Acme"),
"inline creationInfo createdBy must resolve, got {:?}",
sbom.document.creators
);
}
#[test]
fn spdx3_agent_kinds_resolved_from_variant() {
let doc = r#"{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"@graph": [
{"type": "CreationInfo", "@id": "_:ci", "specVersion": "3.0.1",
"created": "2026-01-01T00:00:00Z",
"createdBy": ["urn:alice", "urn:acme"], "createdUsing": ["urn:tool"]},
{"type": "Person", "spdxId": "urn:alice", "creationInfo": "_:ci", "name": "Alice"},
{"type": "Organization", "spdxId": "urn:acme", "creationInfo": "_:ci", "name": "Acme"},
{"type": "Tool", "spdxId": "urn:tool", "creationInfo": "_:ci", "name": "maker"},
{"type": "SpdxDocument", "spdxId": "urn:doc", "creationInfo": "_:ci", "name": "d"}
]
}"#;
let sbom = Spdx3Parser::new().parse_str(doc).expect("parse");
let kind_of = |name: &str| {
sbom.document
.creators
.iter()
.find(|c| c.name == name)
.map(|c| c.creator_type.clone())
.unwrap_or_else(|| panic!("creator {name} missing: {:?}", sbom.document.creators))
};
assert!(matches!(kind_of("Alice"), CreatorType::Person));
assert!(matches!(kind_of("Acme"), CreatorType::Organization));
assert!(matches!(kind_of("maker"), CreatorType::Tool));
}
#[test]
fn spdx3_prefixed_software_properties_accepted() {
let doc = r#"{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"type": "SpdxDocument", "spdxId": "urn:doc",
"element": [
{"type": "software_Package", "spdxId": "urn:pkg", "name": "p",
"software_packageVersion": "2.1.0",
"software_packageUrl": "pkg:cargo/p@2.1.0",
"software_copyrightText": "(c) Example",
"software_primaryPurpose": "library"}
]
}"#;
let sbom = Spdx3Parser::new().parse_str(doc).expect("parse");
let pkg = spdx3_component(&sbom, "p");
assert_eq!(pkg.version.as_deref(), Some("2.1.0"));
assert_eq!(pkg.identifiers.purl.as_deref(), Some("pkg:cargo/p@2.1.0"));
assert_eq!(pkg.copyright.as_deref(), Some("(c) Example"));
assert!(matches!(pkg.component_type, ComponentType::Library));
}
#[test]
fn spdx3_prefixed_license_expression_resolves() {
let doc = r#"{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"type": "SpdxDocument", "spdxId": "urn:doc",
"element": [
{"type": "software_Package", "spdxId": "urn:pkg", "name": "p"},
{"type": "simplelicensing_LicenseExpression", "spdxId": "urn:lic",
"simplelicensing_licenseExpression": "MIT"},
{"type": "Relationship", "spdxId": "urn:rel",
"relationshipType": "hasDeclaredLicense",
"from": "urn:pkg", "to": ["urn:lic"]}
]
}"#;
let sbom = Spdx3Parser::new().parse_str(doc).expect("parse");
let pkg = spdx3_component(&sbom, "p");
assert!(
pkg.licenses.declared.iter().any(|l| l.expression == "MIT"),
"prefixed license expression must resolve, got {:?}",
pkg.licenses.declared
);
}
#[test]
fn spdx3_single_creation_info_fallback_resolves_metadata() {
let doc = r#"{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"@graph": [
{"type": "CreationInfo", "@id": "_:ci", "specVersion": "3.0.1",
"created": "2026-02-02T00:00:00Z", "createdBy": ["urn:org"]},
{"type": "Organization", "spdxId": "urn:org", "creationInfo": "_:ci", "name": "Acme"},
{"type": "software_Package", "spdxId": "urn:pkg", "creationInfo": "_:ci", "name": "p"}
]
}"#;
let sbom = Spdx3Parser::new().parse_str(doc).expect("parse");
assert_eq!(sbom.document.spec_version, "3.0.1");
assert!(
sbom.document
.creators
.iter()
.any(|c| matches!(c.creator_type, CreatorType::Organization) && c.name == "Acme"),
"the lone CreationInfo node must supply document metadata, got {:?}",
sbom.document.creators
);
}
#[test]
fn spdx3_bundle_rootelement_indirection_sets_primary() {
let doc = r#"{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"@graph": [
{"type": "SpdxDocument", "spdxId": "urn:doc", "name": "d",
"rootElement": ["urn:bundle"]},
{"type": "Bundle", "spdxId": "urn:bundle", "rootElement": ["urn:pkg"]},
{"type": "software_Package", "spdxId": "urn:pkg", "name": "p"}
]
}"#;
let sbom = Spdx3Parser::new().parse_str(doc).expect("parse");
assert_eq!(
sbom.primary_component().map(|c| c.name.as_str()),
Some("p"),
"rootElement must resolve through the Bundle"
);
}
#[test]
fn spdx3_canonical_snippet_ranges_accepted() {
let doc = r#"{
"@context": "https://spdx.org/rdf/3.0.1/spdx-context.jsonld",
"type": "SpdxDocument", "spdxId": "urn:doc",
"element": [
{"type": "software_Snippet", "spdxId": "urn:snip", "name": "s",
"software_byteRange": {"beginIntegerRange": 310, "endIntegerRange": 420},
"software_lineRange": {"beginIntegerRange": 5, "endIntegerRange": 23}}
]
}"#;
let sbom = Spdx3Parser::new()
.parse_str(doc)
.expect("canonical range objects must not fail the parse");
let snip = spdx3_component(&sbom, "s");
let desc = snip.description.as_deref().unwrap_or_default();
assert!(
desc.contains("310:420") && desc.contains("5:23"),
"ranges must normalize to begin:end, got {desc:?}"
);
}
#[test]
fn spdx3_canonical_security_assessment_fields_extracted() {
let sbom = spdx3_lib(
r#"{"type": "security_CvssV3VulnAssessmentRelationship",
"spdxId": "urn:assess:cvss", "from": "urn:vuln:cve",
"security_assessedElement": "urn:pkg:lib",
"security_score": 9.8, "security_severity": "critical",
"security_vectorString": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"}"#,
);
let lib = spdx3_component(&sbom, "lib");
let vuln = lib.vulnerabilities.first().expect("vulnerability attached");
let cvss = vuln.cvss.first().expect("canonical CVSS payload extracted");
assert!(
(cvss.base_score - 9.8_f32).abs() < 1e-4,
"got {}",
cvss.base_score
);
assert!(matches!(cvss.version, CvssVersion::V31));
}
#[test]
fn spdx3_sbom_container_type_tags_deserialize() {
for tag in ["software_Sbom", "Sbom", "Bom"] {
let el: Spdx3Element = serde_json::from_str(&format!(
r#"{{"type": "{tag}", "spdxId": "urn:x:bom", "rootElement": ["urn:p"]}}"#
))
.unwrap_or_else(|e| panic!("{tag} should deserialize: {e}"));
assert!(
matches!(el, Spdx3Element::Bom(_)),
"tag {tag} must map to the Bom collection variant"
);
}
}
}