use serde_json::Value;
use super::licenses::{LicenseReport, LicensedPackage};
use super::{DepsError, check_count, check_size, clean};
pub const MAX_NESTING: usize = 32;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SbomFormat {
Spdx,
CycloneDx,
}
impl SbomFormat {
pub fn name(self) -> &'static str {
match self {
SbomFormat::Spdx => "SPDX",
SbomFormat::CycloneDx => "CycloneDX",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Sbom {
format: SbomFormat,
version: String,
root: Option<LicensedPackage>,
report: LicenseReport,
}
impl Sbom {
pub fn parse(json: &str) -> Result<Self, DepsError> {
check_size(json, "the SBOM")?;
let trimmed = json.trim_start_matches('\u{feff}').trim_start();
if trimmed.starts_with('<') {
return Err(DepsError::new(
"an XML document: give the SBOM as SPDX or CycloneDX JSON",
));
}
if trimmed.starts_with("SPDXVersion:") {
return Err(DepsError::new(
"an SPDX tag-value document: give the SBOM as SPDX JSON",
));
}
let value: Value = serde_json::from_str(trimmed)
.map_err(|e| DepsError::new(format!("not SBOM JSON: {e}")))?;
if value.get("spdxVersion").is_some() {
return Self::from_spdx(&value);
}
let cyclonedx = value.get("bomFormat").and_then(Value::as_str) == Some("CycloneDX")
|| (value.get("specVersion").is_some() && value.get("components").is_some());
if cyclonedx {
return Self::from_cyclonedx(&value);
}
if value.get("@context").is_some() {
return Err(DepsError::new(
"an SPDX 3 (JSON-LD) document: give the SBOM as SPDX 2.2 or 2.3 JSON",
));
}
Err(DepsError::new(
"not an SPDX or CycloneDX JSON document (no `spdxVersion` or `bomFormat`)",
))
}
pub fn from_spdx(value: &Value) -> Result<Self, DepsError> {
let version = value
.get("spdxVersion")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if !version.starts_with("SPDX-2.") {
return Err(DepsError::new(format!(
"SPDX version {:?}: this reads SPDX 2.2 and 2.3 JSON",
clean(&version)
)));
}
let packages = match value.get("packages") {
None => &[][..],
Some(Value::Array(list)) => list.as_slice(),
Some(_) => return Err(DepsError::new("SPDX: `packages` is not a list")),
};
check_count(packages.len(), "packages")?;
let mut read = Vec::with_capacity(packages.len());
for (index, package) in packages.iter().enumerate() {
let text = |key: &str| package.get(key).and_then(Value::as_str);
let Some(name) = text("name").map(str::trim).filter(|n| !n.is_empty()) else {
return Err(DepsError::new(format!(
"SPDX: packages[{index}] has no name"
)));
};
let version = text("versionInfo").unwrap_or_default().trim().to_string();
let id = text("SPDXID")
.map(str::to_string)
.unwrap_or_else(|| format!("{name}@{version}"));
let license = [text("licenseConcluded"), text("licenseDeclared")]
.into_iter()
.flatten()
.map(str::trim)
.find(|l| !l.is_empty() && *l != "NOASSERTION" && *l != "NONE")
.map(str::to_string);
read.push(LicensedPackage {
id,
name: name.to_string(),
version,
license,
license_file: None,
});
}
let described: Option<String> = value
.get("documentDescribes")
.and_then(Value::as_array)
.and_then(|ids| ids.first())
.and_then(Value::as_str)
.map(str::to_string)
.or_else(|| {
let document = value.get("SPDXID").and_then(Value::as_str)?;
value
.get("relationships")
.and_then(Value::as_array)?
.iter()
.find(|r| {
r.get("spdxElementId").and_then(Value::as_str) == Some(document)
&& r.get("relationshipType").and_then(Value::as_str)
== Some("DESCRIBES")
})
.and_then(|r| r.get("relatedSpdxElement").and_then(Value::as_str))
.map(str::to_string)
});
let root = described.and_then(|id| read.iter().find(|p| p.id == id).cloned());
Ok(Sbom {
format: SbomFormat::Spdx,
version: version.trim_start_matches("SPDX-").to_string(),
root,
report: LicenseReport::new(read),
})
}
pub fn from_cyclonedx(value: &Value) -> Result<Self, DepsError> {
let version = value
.get("specVersion")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let mut read = Vec::new();
let root = match value.pointer("/metadata/component") {
Some(component) if component.is_object() => {
let root = component_package(component, "metadata.component")?;
read.push(root.clone());
Some(root)
}
_ => None,
};
let mut stack: Vec<(&Value, String, usize)> = Vec::new();
match value.get("components") {
None => {}
Some(Value::Array(list)) => {
for (index, component) in list.iter().enumerate().rev() {
stack.push((component, format!("components[{index}]"), 0));
}
}
Some(_) => return Err(DepsError::new("CycloneDX: `components` is not a list")),
}
while let Some((component, at, depth)) = stack.pop() {
check_count(read.len() + 1, "components")?;
read.push(component_package(component, &at)?);
if let Some(nested) = component.get("components").and_then(Value::as_array) {
if depth + 1 >= MAX_NESTING {
return Err(DepsError::new(format!(
"CycloneDX: components nest deeper than {MAX_NESTING} levels"
)));
}
for (index, child) in nested.iter().enumerate().rev() {
stack.push((child, format!("{at}.components[{index}]"), depth + 1));
}
}
}
Ok(Sbom {
format: SbomFormat::CycloneDx,
version,
root,
report: LicenseReport::new(read),
})
}
pub fn format(&self) -> SbomFormat {
self.format
}
pub fn spec_version(&self) -> &str {
&self.version
}
pub fn root(&self) -> Option<&LicensedPackage> {
self.root.as_ref()
}
pub fn report(&self) -> &LicenseReport {
&self.report
}
pub fn into_report(self) -> LicenseReport {
self.report
}
}
fn component_package(component: &Value, at: &str) -> Result<LicensedPackage, DepsError> {
let text = |key: &str| component.get(key).and_then(Value::as_str);
let Some(name) = text("name").map(str::trim).filter(|n| !n.is_empty()) else {
return Err(DepsError::new(format!("CycloneDX: {at} has no name")));
};
let version = text("version").unwrap_or_default().trim().to_string();
let id = text("bom-ref")
.or_else(|| text("purl"))
.map(str::to_string)
.unwrap_or_else(|| format!("{name}@{version}"));
let mut parts: Vec<String> = Vec::new();
if let Some(licenses) = component.get("licenses").and_then(Value::as_array) {
for entry in licenses {
let part = entry
.get("expression")
.and_then(Value::as_str)
.or_else(|| entry.pointer("/license/id").and_then(Value::as_str))
.or_else(|| entry.pointer("/license/name").and_then(Value::as_str))
.map(str::trim)
.filter(|part| !part.is_empty());
if let Some(part) = part {
parts.push(part.to_string());
}
}
}
let license = match parts.len() {
0 => None,
1 => parts.pop(),
_ => Some(
parts
.iter()
.map(|part| {
if part.contains(' ') {
format!("({part})")
} else {
part.clone()
}
})
.collect::<Vec<_>>()
.join(" AND "),
),
};
Ok(LicensedPackage {
id,
name: name.to_string(),
version,
license,
license_file: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spdx_reads_concluded_then_declared() {
let json = r#"{"spdxVersion": "SPDX-2.3", "SPDXID": "SPDXRef-DOCUMENT",
"packages": [
{"SPDXID": "SPDXRef-app", "name": "app", "versionInfo": "0.1.0",
"licenseConcluded": "NOASSERTION", "licenseDeclared": "MIT"},
{"SPDXID": "SPDXRef-a", "name": "a", "versionInfo": "1.0.0",
"licenseConcluded": "Apache-2.0", "licenseDeclared": "MIT"},
{"SPDXID": "SPDXRef-b", "name": "b", "licenseConcluded": "NONE"}],
"relationships": [{"spdxElementId": "SPDXRef-DOCUMENT",
"relationshipType": "DESCRIBES", "relatedSpdxElement": "SPDXRef-app"}]}"#;
let sbom = Sbom::parse(json).unwrap();
assert_eq!(sbom.format(), SbomFormat::Spdx);
assert_eq!(sbom.spec_version(), "2.3");
let licenses: Vec<Option<&str>> = sbom
.report()
.packages()
.iter()
.map(|p| p.license.as_deref())
.collect();
assert_eq!(licenses, [Some("MIT"), Some("Apache-2.0"), None]);
assert_eq!(sbom.root().map(|r| r.name.as_str()), Some("app"));
}
#[test]
fn cyclonedx_nests_and_joins_licences() {
let json = r#"{"bomFormat": "CycloneDX", "specVersion": "1.6", "components": [
{"name": "a", "version": "1", "licenses": [
{"license": {"id": "MIT"}}, {"license": {"id": "Apache-2.0"}}],
"components": [{"name": "a-inner", "version": "1",
"licenses": [{"license": {"name": "Custom Licence"}}]}]},
{"name": "b", "version": "2"}]}"#;
let sbom = Sbom::parse(json).unwrap();
let packages = sbom.report().packages();
let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["a", "a-inner", "b"]);
assert_eq!(packages[0].license.as_deref(), Some("MIT AND Apache-2.0"));
assert_eq!(packages[1].license.as_deref(), Some("Custom Licence"));
assert_eq!(packages[2].license, None);
assert!(sbom.root().is_none());
}
#[test]
fn other_documents_are_refused_with_a_reason() {
for (input, expected) in [
("<bom/>", "XML"),
("SPDXVersion: SPDX-2.3\nDataLicense: CC0-1.0", "tag-value"),
(
r#"{"@context": "https://spdx.org/rdf/3.0.0/spdx-context.jsonld"}"#,
"SPDX 3",
),
(r#"{"spdxVersion": "SPDX-1.2"}"#, "SPDX 2.2 and 2.3"),
(r#"{"packages": []}"#, "not an SPDX or CycloneDX"),
(
r#"{"spdxVersion": "SPDX-2.3", "packages": [{}]}"#,
"has no name",
),
(
r#"{"bomFormat": "CycloneDX", "components": {}}"#,
"not a list",
),
(
"{\"bomFormat\": \"CycloneDX\", \"components\": [",
"not SBOM JSON",
),
] {
let err = Sbom::parse(input).unwrap_err().to_string();
assert!(err.contains(expected), "{input:?}: {err}");
}
let mut deep = String::from(r#"{"bomFormat": "CycloneDX", "components": "#);
for _ in 0..40 {
deep.push_str(r#"[{"name": "n", "components": "#);
}
deep.push_str("[]");
for _ in 0..40 {
deep.push_str("}]");
}
deep.push('}');
assert!(Sbom::parse(&deep).is_err());
}
}