use crate::types::Blake3Hash;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SbomFormat {
Spdx23,
Spdx30,
CycloneDx15,
CycloneDx16,
SwidTag,
}
impl SbomFormat {
pub fn tag(&self) -> &'static str {
match self {
SbomFormat::Spdx23 => "spdx-2.3",
SbomFormat::Spdx30 => "spdx-3.0",
SbomFormat::CycloneDx15 => "cyclonedx-1.5",
SbomFormat::CycloneDx16 => "cyclonedx-1.6",
SbomFormat::SwidTag => "swid",
}
}
pub fn family(&self) -> &'static str {
match self {
SbomFormat::Spdx23 | SbomFormat::Spdx30 => "spdx",
SbomFormat::CycloneDx15 | SbomFormat::CycloneDx16 => "cyclonedx",
SbomFormat::SwidTag => "swid",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ComponentType {
Application,
Library,
Framework,
Container,
OperatingSystem,
Device,
Firmware,
File,
Platform,
DeviceDriver,
MachineLearningModel,
Data,
}
impl ComponentType {
pub fn tag(&self) -> &'static str {
match self {
ComponentType::Application => "application",
ComponentType::Library => "library",
ComponentType::Framework => "framework",
ComponentType::Container => "container",
ComponentType::OperatingSystem => "operating-system",
ComponentType::Device => "device",
ComponentType::Firmware => "firmware",
ComponentType::File => "file",
ComponentType::Platform => "platform",
ComponentType::DeviceDriver => "device-driver",
ComponentType::MachineLearningModel => "machine-learning-model",
ComponentType::Data => "data",
}
}
pub fn parse(s: &str) -> ComponentType {
match s.trim().to_ascii_lowercase().as_str() {
"application" => ComponentType::Application,
"library" => ComponentType::Library,
"framework" => ComponentType::Framework,
"container" => ComponentType::Container,
"operating-system" | "operating_system" | "os" => ComponentType::OperatingSystem,
"device" => ComponentType::Device,
"firmware" => ComponentType::Firmware,
"file" => ComponentType::File,
"platform" => ComponentType::Platform,
"device-driver" | "driver" => ComponentType::DeviceDriver,
"machine-learning-model" | "ml-model" | "model" => ComponentType::MachineLearningModel,
"data" | "dataset" => ComponentType::Data,
_ => ComponentType::Library,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Hash {
pub algorithm: String,
pub value: String,
}
impl Hash {
pub fn new(algorithm: impl Into<String>, value: impl Into<String>) -> Self {
Hash {
algorithm: normalize_hash_algorithm(&algorithm.into()),
value: value.into().trim().to_ascii_lowercase(),
}
}
}
fn normalize_hash_algorithm(raw: &str) -> String {
match raw.trim().to_ascii_uppercase().replace('_', "-").as_str() {
"SHA1" | "SHA-1" => "SHA-1".to_string(),
"SHA256" | "SHA-256" => "SHA-256".to_string(),
"SHA384" | "SHA-384" => "SHA-384".to_string(),
"SHA512" | "SHA-512" => "SHA-512".to_string(),
"SHA3-256" => "SHA3-256".to_string(),
"SHA3-512" => "SHA3-512".to_string(),
"BLAKE2B-256" | "BLAKE2B-512" => "BLAKE2b".to_string(),
"BLAKE3" => "BLAKE3".to_string(),
"MD5" => "MD5".to_string(),
other => other.to_string(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct License {
pub spdx_id: Option<String>,
pub expression: Option<String>,
pub name: Option<String>,
pub url: Option<String>,
}
impl License {
pub fn id(spdx_id: impl Into<String>) -> Self {
License {
spdx_id: Some(spdx_id.into()),
expression: None,
name: None,
url: None,
}
}
pub fn expr(expression: impl Into<String>) -> Self {
License {
spdx_id: None,
expression: Some(expression.into()),
name: None,
url: None,
}
}
pub fn label(&self) -> String {
self.spdx_id
.clone()
.or_else(|| self.expression.clone())
.or_else(|| self.name.clone())
.unwrap_or_else(|| "NOASSERTION".to_string())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Supplier {
pub name: String,
pub url: Option<String>,
pub contact: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Component {
pub bom_ref: String,
pub name: String,
pub version: String,
pub component_type: ComponentType,
pub purl: Option<String>,
pub cpe: Option<String>,
pub supplier: Option<Supplier>,
pub author: Option<String>,
pub licenses: Vec<License>,
pub hashes: Vec<Hash>,
pub description: Option<String>,
pub scope: Option<String>,
}
impl Component {
pub fn library(
bom_ref: impl Into<String>,
name: impl Into<String>,
version: impl Into<String>,
) -> Self {
Component {
bom_ref: bom_ref.into(),
name: name.into(),
version: version.into(),
component_type: ComponentType::Library,
purl: None,
cpe: None,
supplier: None,
author: None,
licenses: Vec::new(),
hashes: Vec::new(),
description: None,
scope: None,
}
}
pub fn has_unique_identifier(&self) -> bool {
self.purl.is_some() || self.cpe.is_some() || !self.hashes.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Dependency {
pub dependent: String,
pub depends_on: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Tool {
pub vendor: Option<String>,
pub name: String,
pub version: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SbomMetadata {
pub author: Option<String>,
pub supplier: Option<Supplier>,
pub tools: Vec<Tool>,
pub primary_component: Option<String>,
pub timestamp: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Sbom {
pub format: SbomFormat,
pub spec_version: String,
pub serial_number: Option<String>,
pub version: u32,
pub metadata: SbomMetadata,
pub components: Vec<Component>,
pub dependencies: Vec<Dependency>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct NtiaMinimumElements {
pub supplier_name: bool,
pub component_name: bool,
pub version: bool,
pub unique_identifiers: bool,
pub dependency_relationship: bool,
pub author: bool,
pub timestamp: bool,
}
impl NtiaMinimumElements {
pub fn is_conformant(&self) -> bool {
self.supplier_name
&& self.component_name
&& self.version
&& self.unique_identifiers
&& self.dependency_relationship
&& self.author
&& self.timestamp
}
pub fn missing(&self) -> Vec<&'static str> {
let mut out = Vec::new();
if !self.supplier_name {
out.push("supplier_name");
}
if !self.component_name {
out.push("component_name");
}
if !self.version {
out.push("version");
}
if !self.unique_identifiers {
out.push("unique_identifiers");
}
if !self.dependency_relationship {
out.push("dependency_relationship");
}
if !self.author {
out.push("author");
}
if !self.timestamp {
out.push("timestamp");
}
out
}
}
impl Sbom {
pub fn new(format: SbomFormat, spec_version: impl Into<String>) -> Self {
Sbom {
format,
spec_version: spec_version.into(),
serial_number: None,
version: 1,
metadata: SbomMetadata::default(),
components: Vec::new(),
dependencies: Vec::new(),
}
}
pub fn component(&self, bom_ref: &str) -> Option<&Component> {
self.components.iter().find(|c| c.bom_ref == bom_ref)
}
pub fn ntia_minimum_elements(&self) -> NtiaMinimumElements {
let non_empty = !self.components.is_empty();
NtiaMinimumElements {
supplier_name: non_empty
&& self.components.iter().all(|c| {
c.supplier
.as_ref()
.is_some_and(|s| !s.name.trim().is_empty())
}),
component_name: non_empty && self.components.iter().all(|c| !c.name.trim().is_empty()),
version: non_empty && self.components.iter().all(|c| !c.version.trim().is_empty()),
unique_identifiers: non_empty
&& self.components.iter().all(|c| c.has_unique_identifier()),
dependency_relationship: !self.dependencies.is_empty(),
author: self
.metadata
.author
.as_ref()
.is_some_and(|a| !a.trim().is_empty()),
timestamp: self.metadata.timestamp > 0,
}
}
pub fn canonicalize(&mut self) {
self.components.sort_by(|a, b| a.bom_ref.cmp(&b.bom_ref));
for c in &mut self.components {
c.licenses.sort();
c.hashes.sort();
}
for d in &mut self.dependencies {
d.depends_on.sort();
d.depends_on.dedup();
}
self.dependencies
.sort_by(|a, b| a.dependent.cmp(&b.dependent));
}
pub fn content_address(&self) -> Blake3Hash {
let mut canon = self.clone();
canon.canonicalize();
let bytes = serde_json::to_vec(&canon).unwrap_or_default();
Blake3Hash::from_bytes(&bytes)
}
pub fn transitive_dependencies(&self, root: &str) -> Vec<String> {
let mut index: BTreeMap<&str, &[String]> = BTreeMap::new();
for d in &self.dependencies {
index.insert(d.dependent.as_str(), &d.depends_on);
}
let mut seen: BTreeMap<String, ()> = BTreeMap::new();
let mut stack: Vec<String> = index.get(root).map(|d| d.to_vec()).unwrap_or_default();
while let Some(node) = stack.pop() {
if seen.insert(node.clone(), ()).is_none() {
if let Some(children) = index.get(node.as_str()) {
stack.extend(children.iter().cloned());
}
}
}
seen.into_keys().collect()
}
pub fn license_labels(&self) -> Vec<String> {
let mut set: BTreeMap<String, ()> = BTreeMap::new();
for c in &self.components {
for l in &c.licenses {
set.insert(l.label(), ());
}
}
set.into_keys().collect()
}
}
#[derive(Debug, thiserror::Error)]
pub enum SbomError {
#[error("sbom parse error: {0}")]
Parse(String),
#[error("unrecognized sbom format: {0}")]
UnrecognizedFormat(String),
#[error("missing required field: {0}")]
MissingField(String),
}
pub fn detect_format(doc: &serde_json::Value) -> Result<SbomFormat, SbomError> {
if let Some(v) = doc.get("spdxVersion").and_then(|v| v.as_str()) {
if v.contains("3.") {
return Ok(SbomFormat::Spdx30);
}
return Ok(SbomFormat::Spdx23);
}
if let Some(v) = doc.get("specVersion").and_then(|v| v.as_str()) {
if v.starts_with("1.6") {
return Ok(SbomFormat::CycloneDx16);
}
return Ok(SbomFormat::CycloneDx15);
}
if doc.get("SoftwareIdentity").is_some() || doc.get("swid").is_some() {
return Ok(SbomFormat::SwidTag);
}
Err(SbomError::UnrecognizedFormat(
"no spdxVersion/specVersion/SWID marker".to_string(),
))
}
pub fn parse_spdx(doc: &serde_json::Value) -> Result<Sbom, SbomError> {
let spec_version = doc
.get("spdxVersion")
.and_then(|v| v.as_str())
.unwrap_or("SPDX-2.3")
.trim_start_matches("SPDX-")
.to_string();
let mut sbom = Sbom::new(SbomFormat::Spdx23, spec_version);
sbom.serial_number = doc
.get("documentNamespace")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(ci) = doc.get("creationInfo") {
if let Some(creators) = ci.get("creators").and_then(|v| v.as_array()) {
sbom.metadata.author = creators
.iter()
.filter_map(|c| c.as_str())
.find(|s| s.starts_with("Person:") || s.starts_with("Organization:"))
.map(|s| s.to_string());
}
if let Some(created) = ci.get("created").and_then(|v| v.as_str()) {
sbom.metadata.timestamp = parse_iso8601_to_unix(created);
}
}
if let Some(packages) = doc.get("packages").and_then(|v| v.as_array()) {
for pkg in packages {
let bom_ref = pkg
.get("SPDXID")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let name = pkg
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let version = pkg
.get("versionInfo")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let mut component = Component::library(bom_ref, name, version);
component.supplier = pkg
.get("supplier")
.and_then(|v| v.as_str())
.filter(|s| *s != "NOASSERTION")
.map(|s| Supplier {
name: s.trim_start_matches("Organization:").trim().to_string(),
url: None,
contact: None,
});
if let Some(lic) = pkg
.get("licenseConcluded")
.or_else(|| pkg.get("licenseDeclared"))
.and_then(|v| v.as_str())
.filter(|s| *s != "NOASSERTION")
{
component
.licenses
.push(if lic.contains(" OR ") || lic.contains(" AND ") {
License::expr(lic)
} else {
License::id(lic)
});
}
if let Some(refs) = pkg.get("externalRefs").and_then(|v| v.as_array()) {
for r in refs {
let ref_type = r
.get("referenceType")
.and_then(|v| v.as_str())
.unwrap_or("");
let locator = r
.get("referenceLocator")
.and_then(|v| v.as_str())
.unwrap_or("");
match ref_type {
"purl" => component.purl = Some(locator.to_string()),
t if t.starts_with("cpe") => component.cpe = Some(locator.to_string()),
_ => {}
}
}
}
if let Some(sums) = pkg.get("checksums").and_then(|v| v.as_array()) {
for s in sums {
let algo = s.get("algorithm").and_then(|v| v.as_str()).unwrap_or("");
let val = s
.get("checksumValue")
.and_then(|v| v.as_str())
.unwrap_or("");
if !algo.is_empty() && !val.is_empty() {
component.hashes.push(Hash::new(algo, val));
}
}
}
sbom.components.push(component);
}
}
if let Some(rels) = doc.get("relationships").and_then(|v| v.as_array()) {
let mut edges: BTreeMap<String, Vec<String>> = BTreeMap::new();
for r in rels {
let kind = r
.get("relationshipType")
.and_then(|v| v.as_str())
.unwrap_or("");
if kind == "DEPENDS_ON" {
let from = r
.get("spdxElementId")
.and_then(|v| v.as_str())
.unwrap_or("");
let to = r
.get("relatedSpdxElement")
.and_then(|v| v.as_str())
.unwrap_or("");
if !from.is_empty() && !to.is_empty() {
edges
.entry(from.to_string())
.or_default()
.push(to.to_string());
}
}
}
for (dependent, depends_on) in edges {
sbom.dependencies.push(Dependency {
dependent,
depends_on,
});
}
}
sbom.canonicalize();
Ok(sbom)
}
pub fn parse_cyclonedx(doc: &serde_json::Value) -> Result<Sbom, SbomError> {
let spec_version = doc
.get("specVersion")
.and_then(|v| v.as_str())
.unwrap_or("1.5")
.to_string();
let format = if spec_version.starts_with("1.6") {
SbomFormat::CycloneDx16
} else {
SbomFormat::CycloneDx15
};
let mut sbom = Sbom::new(format, spec_version);
sbom.serial_number = doc
.get("serialNumber")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
sbom.version = doc.get("version").and_then(|v| v.as_u64()).unwrap_or(1) as u32;
if let Some(meta) = doc.get("metadata") {
if let Some(ts) = meta.get("timestamp").and_then(|v| v.as_str()) {
sbom.metadata.timestamp = parse_iso8601_to_unix(ts);
}
if let Some(authors) = meta.get("authors").and_then(|v| v.as_array()) {
sbom.metadata.author = authors
.iter()
.filter_map(|a| a.get("name").and_then(|v| v.as_str()))
.next()
.map(|s| s.to_string());
}
if let Some(tools) = meta.get("tools").and_then(|v| v.as_array()) {
for t in tools {
sbom.metadata.tools.push(Tool {
vendor: t
.get("vendor")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
name: t
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
version: t
.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
});
}
}
if let Some(comp) = meta.get("component") {
sbom.metadata.primary_component = comp
.get("bom-ref")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(sup) = comp
.get("supplier")
.and_then(|s| s.get("name"))
.and_then(|v| v.as_str())
{
sbom.metadata.supplier = Some(Supplier {
name: sup.to_string(),
url: None,
contact: None,
});
}
}
}
if let Some(components) = doc.get("components").and_then(|v| v.as_array()) {
for comp in components {
sbom.components.push(parse_cyclonedx_component(comp));
}
}
if let Some(deps) = doc.get("dependencies").and_then(|v| v.as_array()) {
for d in deps {
let dependent = d
.get("ref")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let depends_on = d
.get("dependsOn")
.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str())
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
if !dependent.is_empty() {
sbom.dependencies.push(Dependency {
dependent,
depends_on,
});
}
}
}
sbom.canonicalize();
Ok(sbom)
}
fn parse_cyclonedx_component(comp: &serde_json::Value) -> Component {
let name = comp
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let version = comp
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let bom_ref = comp
.get("bom-ref")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| format!("{name}@{version}"));
let mut component = Component::library(bom_ref, name, version);
component.component_type = comp
.get("type")
.and_then(|v| v.as_str())
.map(ComponentType::parse)
.unwrap_or(ComponentType::Library);
component.purl = comp
.get("purl")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
component.cpe = comp
.get("cpe")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
component.author = comp
.get("author")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
component.description = comp
.get("description")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
component.scope = comp
.get("scope")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(sup) = comp
.get("supplier")
.and_then(|s| s.get("name"))
.and_then(|v| v.as_str())
{
component.supplier = Some(Supplier {
name: sup.to_string(),
url: comp
.get("supplier")
.and_then(|s| s.get("url"))
.and_then(|v| v.as_array())
.and_then(|a| a.first())
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
contact: None,
});
}
if let Some(lics) = comp.get("licenses").and_then(|v| v.as_array()) {
for l in lics {
if let Some(expr) = l.get("expression").and_then(|v| v.as_str()) {
component.licenses.push(License::expr(expr));
} else if let Some(lic) = l.get("license") {
let mut license = License {
spdx_id: lic
.get("id")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
expression: None,
name: lic
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
url: lic
.get("url")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
};
if license.spdx_id.is_none() && license.name.is_none() {
license.name = Some("NOASSERTION".to_string());
}
component.licenses.push(license);
}
}
}
if let Some(hashes) = comp.get("hashes").and_then(|v| v.as_array()) {
for h in hashes {
let alg = h.get("alg").and_then(|v| v.as_str()).unwrap_or("");
let content = h.get("content").and_then(|v| v.as_str()).unwrap_or("");
if !alg.is_empty() && !content.is_empty() {
component.hashes.push(Hash::new(alg, content));
}
}
}
component
}
pub fn parse_sbom_json(json: &str) -> Result<Sbom, SbomError> {
let doc: serde_json::Value =
serde_json::from_str(json).map_err(|e| SbomError::Parse(e.to_string()))?;
match detect_format(&doc)? {
SbomFormat::Spdx23 | SbomFormat::Spdx30 => parse_spdx(&doc),
SbomFormat::CycloneDx15 | SbomFormat::CycloneDx16 => parse_cyclonedx(&doc),
SbomFormat::SwidTag => Err(SbomError::UnrecognizedFormat(
"SWID ingest not yet implemented".to_string(),
)),
}
}
fn parse_iso8601_to_unix(s: &str) -> u64 {
let bytes = s.as_bytes();
if s.len() < 19 || bytes.get(4) != Some(&b'-') || bytes.get(10) != Some(&b'T') {
return 0;
}
let parse = |a: usize, b: usize| -> Option<i64> { s.get(a..b)?.parse().ok() };
let (year, month, day, hour, min, sec) = match (
parse(0, 4),
parse(5, 7),
parse(8, 10),
parse(11, 13),
parse(14, 16),
parse(17, 19),
) {
(Some(y), Some(mo), Some(d), Some(h), Some(mi), Some(se)) => (y, mo, d, h, mi, se),
_ => return 0,
};
days_from_civil(year, month, day)
.map(|days| (days * 86400 + hour * 3600 + min * 60 + sec).max(0) as u64)
.unwrap_or(0)
}
fn days_from_civil(y: i64, m: i64, d: i64) -> Option<i64> {
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
Some(era * 146097 + doe - 719468)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_cyclonedx() -> &'static str {
r#"{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"serialNumber": "urn:uuid:1234",
"version": 1,
"metadata": {
"timestamp": "2024-01-15T10:30:00Z",
"authors": [{"name": "Build Bot"}],
"tools": [{"vendor": "acme", "name": "cdxgen", "version": "9.0"}],
"component": {"bom-ref": "app@1.0", "name": "app", "version": "1.0", "type": "application"}
},
"components": [
{
"bom-ref": "pkg:cargo/serde@1.0.0",
"type": "library",
"name": "serde",
"version": "1.0.0",
"purl": "pkg:cargo/serde@1.0.0",
"supplier": {"name": "serde-rs"},
"licenses": [{"expression": "MIT OR Apache-2.0"}],
"hashes": [{"alg": "SHA-256", "content": "ABCDEF"}]
},
{
"bom-ref": "pkg:cargo/log@0.4.0",
"type": "library",
"name": "log",
"version": "0.4.0",
"purl": "pkg:cargo/log@0.4.0",
"supplier": {"name": "rust-lang"},
"licenses": [{"license": {"id": "MIT"}}],
"hashes": [{"alg": "SHA-256", "content": "123456"}]
}
],
"dependencies": [
{"ref": "app@1.0", "dependsOn": ["pkg:cargo/serde@1.0.0"]},
{"ref": "pkg:cargo/serde@1.0.0", "dependsOn": ["pkg:cargo/log@0.4.0"]}
]
}"#
}
fn sample_spdx() -> &'static str {
r#"{
"spdxVersion": "SPDX-2.3",
"documentNamespace": "https://example/spdx/1",
"creationInfo": {
"created": "2024-02-20T08:00:00Z",
"creators": ["Organization: Acme Corp", "Tool: spdx-tool-1.0"]
},
"packages": [
{
"SPDXID": "SPDXRef-serde",
"name": "serde",
"versionInfo": "1.0.0",
"supplier": "Organization: serde-rs",
"licenseConcluded": "MIT OR Apache-2.0",
"externalRefs": [
{"referenceType": "purl", "referenceLocator": "pkg:cargo/serde@1.0.0"}
],
"checksums": [{"algorithm": "SHA256", "checksumValue": "ABCDEF"}]
},
{
"SPDXID": "SPDXRef-log",
"name": "log",
"versionInfo": "0.4.0",
"supplier": "Organization: rust-lang",
"licenseConcluded": "MIT",
"externalRefs": [
{"referenceType": "purl", "referenceLocator": "pkg:cargo/log@0.4.0"}
],
"checksums": [{"algorithm": "SHA256", "checksumValue": "123456"}]
}
],
"relationships": [
{"spdxElementId": "SPDXRef-serde", "relationshipType": "DEPENDS_ON", "relatedSpdxElement": "SPDXRef-log"}
]
}"#
}
#[test]
fn detects_cyclonedx_and_spdx() {
let cdx: serde_json::Value = serde_json::from_str(sample_cyclonedx()).unwrap();
assert_eq!(detect_format(&cdx).unwrap(), SbomFormat::CycloneDx16);
let spdx: serde_json::Value = serde_json::from_str(sample_spdx()).unwrap();
assert_eq!(detect_format(&spdx).unwrap(), SbomFormat::Spdx23);
}
#[test]
fn parses_cyclonedx_components_and_deps() {
let sbom = parse_cyclonedx(&serde_json::from_str(sample_cyclonedx()).unwrap()).unwrap();
assert_eq!(sbom.components.len(), 2);
assert_eq!(sbom.dependencies.len(), 2);
let serde = sbom.component("pkg:cargo/serde@1.0.0").unwrap();
assert_eq!(serde.name, "serde");
assert_eq!(serde.purl.as_deref(), Some("pkg:cargo/serde@1.0.0"));
assert_eq!(serde.licenses[0].label(), "MIT OR Apache-2.0");
}
#[test]
fn parses_spdx_packages_and_relationships() {
let sbom = parse_spdx(&serde_json::from_str(sample_spdx()).unwrap()).unwrap();
assert_eq!(sbom.components.len(), 2);
assert_eq!(sbom.dependencies.len(), 1);
assert_eq!(
sbom.metadata.author.as_deref(),
Some("Organization: Acme Corp")
);
assert!(sbom.metadata.timestamp > 0);
}
#[test]
fn auto_parse_dispatches_by_format() {
assert_eq!(
parse_sbom_json(sample_cyclonedx())
.unwrap()
.components
.len(),
2
);
assert_eq!(parse_sbom_json(sample_spdx()).unwrap().components.len(), 2);
}
#[test]
fn ntia_minimum_elements_conformant_sbom() {
let sbom = parse_cyclonedx(&serde_json::from_str(sample_cyclonedx()).unwrap()).unwrap();
let ntia = sbom.ntia_minimum_elements();
assert!(ntia.component_name);
assert!(ntia.version);
assert!(ntia.unique_identifiers);
assert!(ntia.dependency_relationship);
assert!(ntia.supplier_name);
assert!(ntia.author);
assert!(ntia.timestamp);
assert!(ntia.is_conformant(), "missing: {:?}", ntia.missing());
}
#[test]
fn ntia_flags_missing_elements() {
let mut sbom = Sbom::new(SbomFormat::CycloneDx16, "1.6");
sbom.components.push(Component::library("a", "a", "")); let ntia = sbom.ntia_minimum_elements();
assert!(!ntia.is_conformant());
let missing = ntia.missing();
assert!(missing.contains(&"version"));
assert!(missing.contains(&"unique_identifiers"));
assert!(missing.contains(&"dependency_relationship"));
assert!(missing.contains(&"author"));
assert!(missing.contains(&"timestamp"));
}
#[test]
fn content_address_is_format_independent_and_deterministic() {
let from_cdx = parse_sbom_json(sample_cyclonedx()).unwrap();
let a1 = from_cdx.content_address();
let a2 = from_cdx.content_address();
assert_eq!(a1, a2, "content address must be deterministic");
let mut shuffled = from_cdx.clone();
shuffled.components.reverse();
assert_eq!(
shuffled.content_address(),
a1,
"content address must be canonicalization-stable"
);
}
#[test]
fn transitive_dependencies_walks_the_graph() {
let sbom = parse_sbom_json(sample_cyclonedx()).unwrap();
let trans = sbom.transitive_dependencies("app@1.0");
assert!(trans.contains(&"pkg:cargo/serde@1.0.0".to_string()));
assert!(
trans.contains(&"pkg:cargo/log@0.4.0".to_string()),
"transitive dep through serde must be reached"
);
}
#[test]
fn license_labels_aggregate_distinct() {
let sbom = parse_sbom_json(sample_cyclonedx()).unwrap();
let labels = sbom.license_labels();
assert!(labels.contains(&"MIT OR Apache-2.0".to_string()));
assert!(labels.contains(&"MIT".to_string()));
}
#[test]
fn iso8601_parses_known_epoch() {
assert_eq!(parse_iso8601_to_unix("2024-01-15T10:30:00Z"), 1_705_314_600);
assert_eq!(parse_iso8601_to_unix("garbage"), 0);
}
#[test]
fn component_type_parse_is_total() {
assert_eq!(
ComponentType::parse("application"),
ComponentType::Application
);
assert_eq!(ComponentType::parse("os"), ComponentType::OperatingSystem);
assert_eq!(ComponentType::parse("unknown-xyz"), ComponentType::Library);
}
#[test]
fn hash_normalizes_algorithm_and_casing() {
let h = Hash::new("sha256", "ABCDEF");
assert_eq!(h.algorithm, "SHA-256");
assert_eq!(h.value, "abcdef");
}
}