use crate::model::{Component, ComponentType, CreatorType, NormalizedSbom, SbomFormat};
use serde::{Deserialize, Serialize};
pub const SBOMQS_PARITY_TARGET: &str = "v2.0.11";
pub const SBOMQS_ENGINE_VERSION: &str = "7";
const CAT_NTIA: &str = "NTIA-minimum-elements";
const CAT_SEMANTIC: &str = "Semantic";
const CAT_QUALITY: &str = "Quality";
const CAT_SHARING: &str = "Sharing";
const CAT_STRUCTURAL: &str = "Structural";
pub const CATEGORY_ORDER: [&str; 5] = [
CAT_NTIA,
CAT_SEMANTIC,
CAT_QUALITY,
CAT_SHARING,
CAT_STRUCTURAL,
];
const CDX_SPEC_VERSIONS: &[&str] = &["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6", "1.7"];
const SPDX_SPEC_VERSIONS: &[&str] = &["2.1", "2.2", "2.3"];
const CDX_FILE_FORMATS: &[&str] = &["json", "xml"];
const SPDX_FILE_FORMATS: &[&str] = &["json", "yaml", "rdf", "tag-value"];
const CDX_PRIMARY_PURPOSES: &[&str] = &[
"application",
"framework",
"library",
"container",
"operating-system",
"device",
"firmware",
"file",
];
const SPDX_PRIMARY_PURPOSES: &[&str] = &[
"application",
"framework",
"library",
"container",
"operating-system",
"device",
"firmware",
"source",
"archive",
"file",
"install",
"other",
];
const NO_COMPONENTS_DESC: &str = "N/A (no components)";
#[derive(Debug, Clone, Serialize)]
pub struct SbomqsScoreEntry {
pub category: String,
pub feature: String,
pub score: f64,
pub max_score: f64,
pub description: String,
pub ignored: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct SbomqsFileReport {
pub file_name: String,
pub spec: String,
pub spec_version: String,
pub file_format: String,
pub avg_score: f64,
pub num_components: usize,
pub creation_time: String,
pub gen_tool_name: String,
pub gen_tool_version: String,
pub scores: Vec<SbomqsScoreEntry>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SbomqsCreationInfo {
pub name: String,
pub version: String,
pub scoring_engine_version: String,
pub vendor: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SbomqsReport {
pub run_id: String,
pub timestamp: String,
pub creation_info: SbomqsCreationInfo,
pub files: Vec<SbomqsFileReport>,
}
pub struct SbomqsCompatInput<'a> {
pub sbom: &'a NormalizedSbom,
pub file_name: &'a str,
pub raw_content: Option<&'a str>,
}
#[must_use]
pub fn build_report(input: &SbomqsCompatInput<'_>) -> SbomqsReport {
let scores = compute_scores(input);
let avg = avg_score(&scores);
let facts = input
.raw_content
.map(|raw| extract_raw_facts(raw, &input.sbom.document.format));
let comps = compat_components(input.sbom);
let (tool_name, tool_version) = first_tool_name_version(input.sbom);
let doc = &input.sbom.document;
let file = SbomqsFileReport {
file_name: input.file_name.to_string(),
spec: spec_type_string(&doc.format).to_string(),
spec_version: doc.spec_version.clone(),
file_format: facts
.as_ref()
.map_or("unknown", |f| f.file_format)
.to_string(),
avg_score: avg,
num_components: comps.len(),
creation_time: if doc.has_known_timestamp() {
doc.created
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
} else {
String::new()
},
gen_tool_name: tool_name,
gen_tool_version: tool_version,
scores,
};
SbomqsReport {
run_id: uuid::Uuid::new_v4().to_string(),
timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
creation_info: SbomqsCreationInfo {
name: "sbom-tools".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
scoring_engine_version: format!(
"sbomqs-compat-v1 (parity target sbomqs {SBOMQS_PARITY_TARGET} engine {SBOMQS_ENGINE_VERSION}; sbom-tools engine {})",
crate::quality::SCORING_ENGINE_VERSION
),
vendor: "sbom-tools project".to_string(),
},
files: vec![file],
}
}
#[must_use]
pub fn render_json(input: &SbomqsCompatInput<'_>) -> String {
serde_json::to_string_pretty(&build_report(input)).unwrap_or_else(|_| "{}".to_string())
}
#[derive(Debug, Clone)]
pub struct CategoryRollup {
pub category: &'static str,
pub score: Option<f64>,
pub reason: Option<String>,
}
#[must_use]
pub fn category_rollups(scores: &[SbomqsScoreEntry]) -> Vec<CategoryRollup> {
CATEGORY_ORDER
.iter()
.map(|cat| {
let entries: Vec<&SbomqsScoreEntry> =
scores.iter().filter(|s| s.category == *cat).collect();
let all_ignored = !entries.is_empty() && entries.iter().all(|s| s.ignored);
if all_ignored {
CategoryRollup {
category: cat,
score: None,
reason: entries.first().map(|s| s.description.clone()),
}
} else {
let sum: f64 = entries.iter().filter(|s| !s.ignored).map(|s| s.score).sum();
#[allow(clippy::cast_precision_loss)]
let count = entries.len() as f64;
CategoryRollup {
category: cat,
score: Some(if entries.is_empty() { 0.0 } else { sum / count }),
reason: None,
}
}
})
.collect()
}
#[must_use]
pub fn render_summary_table(input: &SbomqsCompatInput<'_>) -> String {
let scores = compute_scores(input);
let avg = avg_score(&scores);
let rollups = category_rollups(&scores);
let mut lines = Vec::new();
lines.push(format!(
"sbomqs-Comparable Scores (sbomqs {SBOMQS_PARITY_TARGET} score model, 0-10):"
));
for rollup in rollups {
match rollup.score {
Some(score) => lines.push(format!(" {:<22} {:>4.1}/10", rollup.category, score)),
None => lines.push(format!(
" {:<22} n/a ({})",
rollup.category,
rollup
.reason
.unwrap_or_else(|| "not computable".to_string())
)),
}
}
lines.push(format!(
" {:<22} {:>4.1}/10 (sbomqs grade: {})",
"avg_score",
avg,
sbomqs_grade(avg)
));
lines
.push(" Recomputed per-feature from the SBOM with sbomqs' formulas — NOT the".to_string());
lines.push(" 0-100 score above divided by 10 (the scales are not convertible).".to_string());
lines.push(" Full detail: --output sbomqs-json".to_string());
lines.join("\n")
}
#[must_use]
pub fn sbomqs_grade(avg: f64) -> &'static str {
if avg >= 9.0 {
"A"
} else if avg >= 8.0 {
"B"
} else if avg >= 7.0 {
"C"
} else if avg >= 5.0 {
"D"
} else {
"F"
}
}
#[must_use]
pub fn avg_score(scores: &[SbomqsScoreEntry]) -> f64 {
if scores.is_empty() {
return 0.0;
}
let sum: f64 = scores.iter().filter(|s| !s.ignored).map(|s| s.score).sum();
#[allow(clippy::cast_precision_loss)]
let count = scores.len() as f64;
sum / count
}
#[must_use]
pub fn compute_scores(input: &SbomqsCompatInput<'_>) -> Vec<SbomqsScoreEntry> {
let sbom = input.sbom;
let comps = compat_components(sbom);
let facts = input
.raw_content
.map(|raw| extract_raw_facts(raw, &sbom.document.format));
let tokens: Vec<Vec<LicenseToken>> =
comps.iter().map(|c| component_license_tokens(c)).collect();
let mut scores = Vec::with_capacity(23);
scores.push(proportional(
CAT_NTIA,
"comp_with_name",
count_by(&comps, |c| !c.name.trim().is_empty()),
comps.len(),
"have names",
));
scores.push(proportional(
CAT_NTIA,
"comp_with_version",
count_by(&comps, |c| {
c.version.as_deref().is_some_and(|v| !v.trim().is_empty())
}),
comps.len(),
"have versions",
));
scores.push(proportional(
CAT_NTIA,
"comp_with_uniq_ids",
count_by(&comps, |c| !c.identifiers.format_id.trim().is_empty()),
comps.len(),
"have unique ID's",
));
scores.push(proportional(
CAT_NTIA,
"comp_with_supplier",
count_by(&comps, |c| has_supplier(c, &sbom.document.format)),
comps.len(),
"have supplier names",
));
scores.push(timestamp_check(sbom));
scores.push(authors_check(sbom));
scores.push(dependencies_check(sbom));
scores.push(required_fields_check(sbom, &comps, facts.as_ref()));
scores.push(proportional(
CAT_SEMANTIC,
"comp_with_licenses",
tokens.iter().filter(|t| !t.is_empty()).count(),
comps.len(),
"have licenses",
));
scores.push(proportional(
CAT_SEMANTIC,
"comp_with_checksums",
count_by(&comps, |c| !c.hashes.is_empty()),
comps.len(),
"have checksums",
));
scores.push(valid_licenses_check(&comps, &tokens));
scores.push(primary_purpose_check(&comps, &sbom.document.format));
scores.push(inverted_license_check(
"comp_with_deprecated_licenses",
&comps,
&tokens,
|t| t.deprecated,
"have deprecated licenses",
));
scores.push(inverted_license_check(
"comp_with_restrictive_licenses",
&comps,
&tokens,
|t| t.restrictive,
"have restricted licenses",
));
scores.push(proportional(
CAT_QUALITY,
"comp_with_any_vuln_lookup_id",
count_by(&comps, |c| has_purl(c) || has_cpe(c)),
comps.len(),
"components have any lookup id",
));
scores.push(proportional(
CAT_QUALITY,
"comp_with_multi_vuln_lookup_id",
count_by(&comps, |c| has_purl(c) && has_cpe(c)),
comps.len(),
"components have multiple lookup id",
));
scores.push(creator_and_version_check(sbom));
scores.push(primary_component_check(sbom));
scores.push(sharable_check(facts.as_ref()));
scores.push(spec_check(sbom));
scores.push(spec_version_check(sbom));
scores.push(file_format_check(sbom, facts.as_ref()));
scores.push(SbomqsScoreEntry {
category: CAT_STRUCTURAL.to_string(),
feature: "sbom_parsable".to_string(),
score: 10.0,
max_score: 10.0,
description: "provided sbom is parsable (this pipeline only scores documents it \
successfully parsed; sbomqs can additionally score an unparsable file 0)"
.to_string(),
ignored: false,
});
scores
}
fn compat_components(sbom: &NormalizedSbom) -> Vec<&Component> {
sbom.components
.values()
.filter(|c| match sbom.document.format {
SbomFormat::Spdx => c.component_type != ComponentType::File,
SbomFormat::CycloneDx => {
!matches!(&c.component_type, ComponentType::Other(s) if s == "service")
}
})
.collect()
}
fn count_by(comps: &[&Component], pred: impl Fn(&Component) -> bool) -> usize {
comps.iter().filter(|c| pred(c)).count()
}
fn has_purl(c: &Component) -> bool {
c.identifiers
.purl
.as_deref()
.is_some_and(|p| !p.trim().is_empty())
}
fn has_cpe(c: &Component) -> bool {
c.identifiers.cpe.iter().any(|c| !c.trim().is_empty())
}
fn has_supplier(c: &Component, format: &SbomFormat) -> bool {
let supplier = c
.supplier
.as_ref()
.is_some_and(|s| !s.name.trim().is_empty());
match format {
SbomFormat::Spdx => supplier || c.author.as_deref().is_some_and(|a| !a.trim().is_empty()),
SbomFormat::CycloneDx => supplier,
}
}
fn zero_components_entry(category: &str, feature: &str) -> SbomqsScoreEntry {
SbomqsScoreEntry {
category: category.to_string(),
feature: feature.to_string(),
score: 0.0,
max_score: 10.0,
description: NO_COMPONENTS_DESC.to_string(),
ignored: true,
}
}
fn proportional(
category: &str,
feature: &str,
have: usize,
total: usize,
what: &str,
) -> SbomqsScoreEntry {
if total == 0 {
return zero_components_entry(category, feature);
}
#[allow(clippy::cast_precision_loss)]
let score = (have as f64 / total as f64) * 10.0;
SbomqsScoreEntry {
category: category.to_string(),
feature: feature.to_string(),
score,
max_score: 10.0,
description: format!("{have}/{total} {what}"),
ignored: false,
}
}
fn timestamp_check(sbom: &NormalizedSbom) -> SbomqsScoreEntry {
let doc = &sbom.document;
let (score, description) = if doc.has_known_timestamp() {
(
10.0,
format!(
"doc has creation timestamp {}",
doc.created
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
),
)
} else {
(
0.0,
"doc has no (or an unparseable) creation timestamp".to_string(),
)
};
SbomqsScoreEntry {
category: CAT_NTIA.to_string(),
feature: "sbom_creation_timestamp".to_string(),
score,
max_score: 10.0,
description,
ignored: false,
}
}
fn authors_check(sbom: &NormalizedSbom) -> SbomqsScoreEntry {
let total = sbom.document.creators.len();
SbomqsScoreEntry {
category: CAT_NTIA.to_string(),
feature: "sbom_authors".to_string(),
score: if total > 0 { 10.0 } else { 0.0 },
max_score: 10.0,
description: format!("doc has {total} authors"),
ignored: false,
}
}
fn dependencies_check(sbom: &NormalizedSbom) -> SbomqsScoreEntry {
let total = sbom.primary_component_id.as_ref().map_or(0, |primary| {
sbom.edges.iter().filter(|e| &e.from == primary).count()
});
SbomqsScoreEntry {
category: CAT_NTIA.to_string(),
feature: "sbom_dependencies".to_string(),
score: if total > 0 { 10.0 } else { 0.0 },
max_score: 10.0,
description: format!("primary comp has {total} dependencies"),
ignored: false,
}
}
fn required_fields_check(
sbom: &NormalizedSbom,
comps: &[&Component],
facts: Option<&RawFacts>,
) -> SbomqsScoreEntry {
let doc = &sbom.document;
let doc_ok = match doc.format {
SbomFormat::CycloneDx => {
!doc.spec_version.trim().is_empty() && doc.doc_version.unwrap_or(0) >= 1
}
SbomFormat::Spdx => {
!doc.spec_version.trim().is_empty()
&& doc.name.as_deref().is_some_and(|n| !n.trim().is_empty())
&& doc
.serial_number
.as_deref()
.is_some_and(|n| !n.trim().is_empty())
&& !doc.creators.is_empty()
&& doc.has_known_timestamp()
&& facts.is_none_or(|f| f.has_data_license)
}
};
let total = comps.len();
let ok = count_by(comps, |c| match doc.format {
SbomFormat::CycloneDx => !c.name.trim().is_empty(),
SbomFormat::Spdx => !c.name.trim().is_empty() && !c.identifiers.format_id.trim().is_empty(),
});
let pkgs_ok = total > 0 && ok == total;
let score = if !doc_ok {
0.0
} else if pkgs_ok {
10.0
} else {
#[allow(clippy::cast_precision_loss)]
let pkg_score = if total > 0 {
(ok as f64 / total as f64) * 10.0
} else {
0.0
};
(10.0 + pkg_score) / 2.0
};
SbomqsScoreEntry {
category: CAT_SEMANTIC.to_string(),
feature: "sbom_required_fields".to_string(),
score,
max_score: 10.0,
description: format!("Doc Fields:{doc_ok} Pkg Fields:{pkgs_ok}"),
ignored: false,
}
}
fn creator_and_version_check(sbom: &NormalizedSbom) -> SbomqsScoreEntry {
let tools: Vec<&crate::model::Creator> = sbom
.document
.creators
.iter()
.filter(|c| c.creator_type == CreatorType::Tool)
.collect();
let with_version = tools
.iter()
.filter(|t| {
let (name, version) = split_tool_name_version(&t.name);
!name.is_empty() && !version.is_empty()
})
.count();
#[allow(clippy::cast_precision_loss)]
let score = if tools.is_empty() {
0.0
} else {
(with_version as f64 / tools.len() as f64) * 10.0
};
SbomqsScoreEntry {
category: CAT_QUALITY.to_string(),
feature: "sbom_with_creator_and_version".to_string(),
score,
max_score: 10.0,
description: format!(
"{with_version}/{} tools have creator and version",
tools.len()
),
ignored: false,
}
}
fn primary_component_check(sbom: &NormalizedSbom) -> SbomqsScoreEntry {
let present = sbom.primary_component_id.is_some();
SbomqsScoreEntry {
category: CAT_QUALITY.to_string(),
feature: "sbom_with_primary_component".to_string(),
score: if present { 10.0 } else { 0.0 },
max_score: 10.0,
description: if present {
"primary component found".to_string()
} else {
"no primary component found".to_string()
},
ignored: false,
}
}
fn sharable_check(facts: Option<&RawFacts>) -> SbomqsScoreEntry {
match facts {
Some(f) => SbomqsScoreEntry {
category: CAT_SHARING.to_string(),
feature: "sbom_sharable".to_string(),
score: 0.0,
max_score: 10.0,
description: format!(
"doc has a sharable license free 0 :: of {} (sbomqs {SBOMQS_PARITY_TARGET}'s \
vendored SPDX list carries no isFreeAnyUse flags, so every SPDX-listed data \
license — including CC0-1.0 — scores non-free; only AboutCode \
'Public Domain'-category ids not on the SPDX list would score 10 there)",
f.doc_license_count
),
ignored: false,
},
None => SbomqsScoreEntry {
category: CAT_SHARING.to_string(),
feature: "sbom_sharable".to_string(),
score: 0.0,
max_score: 10.0,
description: "sbom-tools cannot compute this without the original document text \
(document data license is not retained in the normalized model); \
entry emitted as ignored and still counted in the avg_score \
denominator, exactly as sbomqs treats a disabled check"
.to_string(),
ignored: true,
},
}
}
fn spec_type_string(format: &SbomFormat) -> &'static str {
match format {
SbomFormat::CycloneDx => "cyclonedx",
SbomFormat::Spdx => "spdx",
}
}
fn spec_check(sbom: &NormalizedSbom) -> SbomqsScoreEntry {
SbomqsScoreEntry {
category: CAT_STRUCTURAL.to_string(),
feature: "sbom_spec".to_string(),
score: 10.0,
max_score: 10.0,
description: format!(
"provided sbom is in a supported sbom format of spdx,cyclonedx (detected: {})",
spec_type_string(&sbom.document.format)
),
ignored: false,
}
}
fn spec_version_check(sbom: &NormalizedSbom) -> SbomqsScoreEntry {
let doc = &sbom.document;
let versions: &[&str] = match doc.format {
SbomFormat::CycloneDx => CDX_SPEC_VERSIONS,
SbomFormat::Spdx => SPDX_SPEC_VERSIONS,
};
let recognized = versions.contains(&doc.spec_version.trim());
SbomqsScoreEntry {
category: CAT_STRUCTURAL.to_string(),
feature: "sbom_spec_version".to_string(),
score: if recognized { 10.0 } else { 0.0 },
max_score: 10.0,
description: format!(
"spec version {} against sbomqs-supported versions: {}",
doc.spec_version,
versions.join(",")
),
ignored: false,
}
}
fn file_format_check(sbom: &NormalizedSbom, facts: Option<&RawFacts>) -> SbomqsScoreEntry {
let formats: &[&str] = match sbom.document.format {
SbomFormat::CycloneDx => CDX_FILE_FORMATS,
SbomFormat::Spdx => SPDX_FILE_FORMATS,
};
match facts {
Some(f) => {
let recognized = formats.contains(&f.file_format);
SbomqsScoreEntry {
category: CAT_STRUCTURAL.to_string(),
feature: "sbom_file_format".to_string(),
score: if recognized { 10.0 } else { 0.0 },
max_score: 10.0,
description: format!(
"detected file format {} against sbomqs-supported formats: {}",
f.file_format,
formats.join(",")
),
ignored: false,
}
}
None => SbomqsScoreEntry {
category: CAT_STRUCTURAL.to_string(),
feature: "sbom_file_format".to_string(),
score: 0.0,
max_score: 10.0,
description: format!(
"sbom-tools cannot compute this without the original document text (the \
serialization format is not retained in the normalized model; sbomqs scores \
10 when the file format is one of: {}); entry emitted as ignored and still \
counted in the avg_score denominator",
formats.join(",")
),
ignored: true,
},
}
}
#[derive(Debug, Clone, Copy)]
struct LicenseToken {
listed: bool,
deprecated: bool,
restrictive: bool,
}
fn expression_tokens(expr: &str) -> Vec<LicenseToken> {
let trimmed = expr.trim();
if trimmed.is_empty() {
return Vec::new();
}
let lower = trimmed.to_ascii_lowercase();
if lower == "none" || lower == "noassertion" {
return Vec::new();
}
let mut tokens = Vec::new();
let mut skip_next = false; for tok in trimmed
.split(|c: char| c.is_whitespace() || c == '(' || c == ')')
.filter(|t| !t.is_empty())
{
if tok == "AND" || tok == "OR" {
continue;
}
if tok == "WITH" {
skip_next = true;
continue;
}
if skip_next {
skip_next = false;
continue;
}
let id = spdx::license_id(tok).or_else(|| spdx::license_id(tok.trim_end_matches('+')));
tokens.push(id.map_or(
LicenseToken {
listed: false,
deprecated: false,
restrictive: false,
},
|id| LicenseToken {
listed: true,
deprecated: id.is_deprecated(),
restrictive: id.is_copyleft(),
},
));
}
if tokens.is_empty() {
tokens.push(LicenseToken {
listed: false,
deprecated: false,
restrictive: false,
});
}
tokens
}
fn component_license_tokens(comp: &Component) -> Vec<LicenseToken> {
comp.licenses
.all_licenses()
.into_iter()
.flat_map(|l| expression_tokens(&l.expression))
.collect()
}
fn valid_licenses_check(comps: &[&Component], tokens: &[Vec<LicenseToken>]) -> SbomqsScoreEntry {
if comps.is_empty() {
return zero_components_entry(CAT_QUALITY, "comp_valid_licenses");
}
let mut total_score = 0.0;
let mut with_valid = 0usize;
for toks in tokens {
if toks.is_empty() {
continue;
}
let listed = toks.iter().filter(|t| t.listed).count();
if listed == 0 {
continue;
}
#[allow(clippy::cast_precision_loss)]
let ratio = (listed as f64 / toks.len() as f64) * 10.0;
total_score += ratio;
with_valid += 1;
}
#[allow(clippy::cast_precision_loss)]
let score = total_score / comps.len() as f64;
SbomqsScoreEntry {
category: CAT_QUALITY.to_string(),
feature: "comp_valid_licenses".to_string(),
score,
max_score: 10.0,
description: format!("{with_valid}/{} components with valid license", comps.len()),
ignored: false,
}
}
fn primary_purpose_check(comps: &[&Component], format: &SbomFormat) -> SbomqsScoreEntry {
if comps.is_empty() {
return zero_components_entry(CAT_QUALITY, "comp_with_primary_purpose");
}
let supported: &[&str] = match format {
SbomFormat::CycloneDx => CDX_PRIMARY_PURPOSES,
SbomFormat::Spdx => SPDX_PRIMARY_PURPOSES,
};
let with_purpose = comps
.iter()
.filter(|c| {
let purpose = c.component_type.to_string().to_lowercase();
!purpose.trim().is_empty() && supported.contains(&purpose.as_str())
})
.count();
proportional(
CAT_QUALITY,
"comp_with_primary_purpose",
with_purpose,
comps.len(),
"components have primary purpose specified",
)
}
fn inverted_license_check(
feature: &str,
comps: &[&Component],
tokens: &[Vec<LicenseToken>],
flag: impl Fn(&LicenseToken) -> bool,
what: &str,
) -> SbomqsScoreEntry {
if comps.is_empty() {
return zero_components_entry(CAT_QUALITY, feature);
}
let total_licenses: usize = tokens.iter().map(Vec::len).sum();
if total_licenses == 0 {
return SbomqsScoreEntry {
category: CAT_QUALITY.to_string(),
feature: feature.to_string(),
score: 0.0,
max_score: 10.0,
description: "no licenses found".to_string(),
ignored: false,
};
}
let affected = tokens.iter().filter(|toks| toks.iter().any(&flag)).count();
#[allow(clippy::cast_precision_loss)]
let score = ((comps.len() - affected) as f64 / comps.len() as f64) * 10.0;
SbomqsScoreEntry {
category: CAT_QUALITY.to_string(),
feature: feature.to_string(),
score,
max_score: 10.0,
description: format!("{affected}/{} components {what}", comps.len()),
ignored: false,
}
}
struct RawFacts {
file_format: &'static str,
doc_license_count: usize,
has_data_license: bool,
}
fn detect_file_format(raw: &str, format: &SbomFormat) -> &'static str {
let trimmed = raw.trim_start_matches('\u{feff}').trim_start();
if trimmed.starts_with('{') {
return "json";
}
if trimmed.starts_with('<') {
return "xml";
}
match format {
SbomFormat::Spdx => {
if raw
.lines()
.any(|l| l.trim_start().starts_with("SPDXVersion:"))
{
"tag-value"
} else if raw.contains("spdxVersion") {
"yaml"
} else {
"unknown"
}
}
SbomFormat::CycloneDx => "unknown",
}
}
fn extract_raw_facts(raw: &str, format: &SbomFormat) -> RawFacts {
let file_format = detect_file_format(raw, format);
match format {
SbomFormat::Spdx => {
let has = match file_format {
"json" => {
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SpdxDataLicense {
data_license: Option<String>,
}
serde_json::from_str::<SpdxDataLicense>(raw)
.ok()
.and_then(|d| d.data_license)
.is_some_and(|l| !l.trim().is_empty())
}
"tag-value" => raw.lines().any(|l| {
l.trim_start()
.strip_prefix("DataLicense:")
.is_some_and(|v| !v.trim().is_empty())
}),
_ => raw.contains("dataLicense") || raw.contains("DataLicense"),
};
RawFacts {
file_format,
doc_license_count: usize::from(has),
has_data_license: has,
}
}
SbomFormat::CycloneDx => {
let count = if file_format == "json" {
#[derive(Deserialize)]
struct CdxMetadata {
licenses: Option<Vec<serde_json::Value>>,
}
#[derive(Deserialize)]
struct CdxDoc {
metadata: Option<CdxMetadata>,
}
serde_json::from_str::<CdxDoc>(raw)
.ok()
.and_then(|d| d.metadata)
.and_then(|m| m.licenses)
.map_or(0, |l| l.len())
} else {
0
};
RawFacts {
file_format,
doc_license_count: count,
has_data_license: true,
}
}
}
}
fn split_tool_name_version(raw_name: &str) -> (&str, &str) {
let name = raw_name.trim();
if let Some((n, v)) = name.rsplit_once(' ')
&& v.chars().any(|c| c.is_ascii_digit())
{
return (n.trim_end(), v);
}
if let Some((n, v)) = name.rsplit_once('-')
&& v.chars().any(|c| c.is_ascii_digit())
{
return (n, v);
}
(name, "")
}
fn first_tool_name_version(sbom: &NormalizedSbom) -> (String, String) {
sbom.document
.creators
.iter()
.find(|c| c.creator_type == CreatorType::Tool)
.map_or_else(
|| (String::new(), String::new()),
|t| {
let (name, version) = split_tool_name_version(&t.name);
(name.to_string(), version.to_string())
},
)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::float_cmp)]
use super::*;
use crate::model::{
Component, ComponentType, Creator, CreatorType, DependencyEdge, DependencyType,
DocumentMetadata, Hash, HashAlgorithm, LicenseExpression, Organization,
};
const EPS: f64 = 1e-9;
fn approx(a: f64, b: f64) -> bool {
(a - b).abs() < EPS
}
fn score<'a>(scores: &'a [SbomqsScoreEntry], feature: &str) -> &'a SbomqsScoreEntry {
scores
.iter()
.find(|s| s.feature == feature)
.unwrap_or_else(|| panic!("missing feature {feature}"))
}
fn cdx_fixture() -> NormalizedSbom {
let mut doc = DocumentMetadata {
spec_version: "1.5".to_string(),
format_version: "1.5".to_string(),
doc_version: Some(1),
..DocumentMetadata::default()
};
doc.creators.push(Creator {
creator_type: CreatorType::Tool,
name: "syft 1.0.0".to_string(),
email: None,
});
doc.creators.push(Creator {
creator_type: CreatorType::Person,
name: "Alice".to_string(),
email: None,
});
let mut sbom = NormalizedSbom::new(doc);
let mut c1 = Component::new("pkg-a".to_string(), "ref-a".to_string())
.with_version("1.0.0".to_string());
c1.component_type = ComponentType::Application;
c1.identifiers.purl = Some("pkg:npm/pkg-a@1.0.0".to_string());
c1.identifiers
.cpe
.push("cpe:2.3:a:acme:pkg-a:1.0.0:*:*:*:*:*:*:*".to_string());
c1.licenses
.add_declared(LicenseExpression::new("MIT".to_string()));
c1.licenses
.add_declared(LicenseExpression::new("MIT OR FooBar".to_string()));
c1.supplier = Some(Organization::new("Acme".to_string()));
c1.hashes
.push(Hash::new(HashAlgorithm::Sha256, "a".repeat(64)));
let mut c2 = Component::new("pkg-b".to_string(), "ref-b".to_string());
c2.licenses
.add_declared(LicenseExpression::new("GPL-2.0".to_string()));
let mut c3 = Component::new(String::new(), "ref-c".to_string());
c3.component_type = ComponentType::MachineLearningModel;
let primary = c1.canonical_id.clone();
let dep = c2.canonical_id.clone();
sbom.add_component(c1);
sbom.add_component(c2);
sbom.add_component(c3);
sbom.set_primary_component(primary.clone());
sbom.add_edge(DependencyEdge::new(primary, dep, DependencyType::DependsOn));
sbom
}
const CDX_RAW: &str = r#"{"bomFormat":"CycloneDX","specVersion":"1.5","version":1,
"metadata":{"licenses":[{"license":{"id":"CC0-1.0"}}]},"components":[]}"#;
fn fixture_scores(raw: Option<&str>) -> Vec<SbomqsScoreEntry> {
let sbom = cdx_fixture();
let input = SbomqsCompatInput {
sbom: &sbom,
file_name: "app.cdx.json",
raw_content: raw,
};
compute_scores(&input)
}
#[test]
fn emits_all_23_default_features_with_exact_categories() {
let scores = fixture_scores(Some(CDX_RAW));
assert_eq!(scores.len(), 23);
for s in &scores {
assert!(
CATEGORY_ORDER.contains(&s.category.as_str()),
"unexpected category {}",
s.category
);
assert_eq!(s.max_score, 10.0);
assert!(
(0.0..=10.0).contains(&s.score),
"{} out of range",
s.feature
);
}
assert!(scores.iter().any(|s| s.category == "NTIA-minimum-elements"));
assert!(scores.iter().any(|s| s.category == "Structural"));
}
#[test]
fn proportional_ntia_features_hand_computed() {
let scores = fixture_scores(Some(CDX_RAW));
assert!(approx(score(&scores, "comp_with_name").score, 20.0 / 3.0));
assert!(approx(
score(&scores, "comp_with_version").score,
10.0 / 3.0
));
assert!(approx(score(&scores, "comp_with_uniq_ids").score, 10.0));
assert!(approx(
score(&scores, "comp_with_supplier").score,
10.0 / 3.0
));
assert!(approx(score(&scores, "sbom_dependencies").score, 10.0));
assert!(approx(score(&scores, "sbom_authors").score, 10.0));
}
#[test]
fn required_fields_is_fractionally_blended_not_binary() {
let scores = fixture_scores(Some(CDX_RAW));
let entry = score(&scores, "sbom_required_fields");
assert!(approx(entry.score, (10.0 + 20.0 / 3.0) / 2.0));
assert_eq!(entry.description, "Doc Fields:true Pkg Fields:false");
assert!(!entry.ignored);
}
#[test]
fn required_fields_doc_not_ok_scores_zero_regardless_of_packages() {
let mut sbom = cdx_fixture();
sbom.document.doc_version = None; let input = SbomqsCompatInput {
sbom: &sbom,
file_name: "x",
raw_content: Some(CDX_RAW),
};
let scores = compute_scores(&input);
assert!(approx(score(&scores, "sbom_required_fields").score, 0.0));
}
#[test]
fn valid_licenses_is_ratio_mean_over_all_components() {
let scores = fixture_scores(Some(CDX_RAW));
assert!(approx(
score(&scores, "comp_valid_licenses").score,
50.0 / 9.0
));
}
#[test]
fn deprecated_and_restrictive_are_inverted_proportionals() {
let scores = fixture_scores(Some(CDX_RAW));
assert!(approx(
score(&scores, "comp_with_deprecated_licenses").score,
20.0 / 3.0
));
assert!(approx(
score(&scores, "comp_with_restrictive_licenses").score,
20.0 / 3.0
));
}
#[test]
fn primary_purpose_gates_on_sbomqs_purpose_list() {
let scores = fixture_scores(Some(CDX_RAW));
assert!(approx(
score(&scores, "comp_with_primary_purpose").score,
20.0 / 3.0
));
}
#[test]
fn lookup_id_features_hand_computed() {
let scores = fixture_scores(Some(CDX_RAW));
assert!(approx(
score(&scores, "comp_with_any_vuln_lookup_id").score,
10.0 / 3.0
));
assert!(approx(
score(&scores, "comp_with_multi_vuln_lookup_id").score,
10.0 / 3.0
));
}
#[test]
fn creator_and_version_uses_digit_heuristic_and_zero_tools_scores_zero() {
let scores = fixture_scores(Some(CDX_RAW));
assert!(approx(
score(&scores, "sbom_with_creator_and_version").score,
10.0
));
let mut sbom = cdx_fixture();
sbom.document
.creators
.retain(|c| c.creator_type != CreatorType::Tool);
let input = SbomqsCompatInput {
sbom: &sbom,
file_name: "x",
raw_content: Some(CDX_RAW),
};
let scores = compute_scores(&input);
let entry = score(&scores, "sbom_with_creator_and_version");
assert!(approx(entry.score, 0.0));
assert!(!entry.ignored);
}
#[test]
fn sharable_replicates_v2_0_11_no_free_flags_quirk() {
let scores = fixture_scores(Some(CDX_RAW));
let entry = score(&scores, "sbom_sharable");
assert!(approx(entry.score, 0.0));
assert!(!entry.ignored);
assert!(entry.description.contains("free 0 :: of 1"));
}
#[test]
fn structural_scores_with_raw_json() {
let scores = fixture_scores(Some(CDX_RAW));
assert!(approx(score(&scores, "sbom_spec").score, 10.0));
assert!(approx(score(&scores, "sbom_spec_version").score, 10.0));
assert!(approx(score(&scores, "sbom_file_format").score, 10.0));
assert!(approx(score(&scores, "sbom_parsable").score, 10.0));
}
#[test]
fn unrecognized_spec_version_scores_zero() {
let mut sbom = cdx_fixture();
sbom.document.spec_version = "3.0".to_string(); let input = SbomqsCompatInput {
sbom: &sbom,
file_name: "x",
raw_content: Some(CDX_RAW),
};
let scores = compute_scores(&input);
assert!(approx(score(&scores, "sbom_spec_version").score, 0.0));
}
#[test]
fn missing_raw_content_emits_ignored_with_reason_never_fabricated() {
let scores = fixture_scores(None);
for feature in ["sbom_file_format", "sbom_sharable"] {
let entry = score(&scores, feature);
assert!(entry.ignored, "{feature} must be ignored without raw text");
assert!(
entry.description.contains("cannot compute"),
"{feature} must carry a reason: {}",
entry.description
);
}
assert_eq!(scores.len(), 23);
}
#[test]
fn avg_score_is_sum_of_non_ignored_over_count_of_all() {
let scores = fixture_scores(None);
let expected: f64 = scores
.iter()
.filter(|s| !s.ignored)
.map(|s| s.score)
.sum::<f64>()
/ scores.len() as f64;
assert!(approx(avg_score(&scores), expected));
let synthetic = vec![
SbomqsScoreEntry {
category: CAT_NTIA.to_string(),
feature: "a".to_string(),
score: 10.0,
max_score: 10.0,
description: String::new(),
ignored: false,
},
SbomqsScoreEntry {
category: CAT_NTIA.to_string(),
feature: "b".to_string(),
score: 5.0,
max_score: 10.0,
description: String::new(),
ignored: false,
},
SbomqsScoreEntry {
category: CAT_NTIA.to_string(),
feature: "c".to_string(),
score: 10.0,
max_score: 10.0,
description: String::new(),
ignored: true,
},
];
assert!(approx(avg_score(&synthetic), 15.0 / 3.0));
}
#[test]
fn zero_component_sbom_marks_component_checks_ignored_in_denominator() {
let sbom = NormalizedSbom::new(DocumentMetadata {
spec_version: "1.5".to_string(),
doc_version: Some(1),
..DocumentMetadata::default()
});
let input = SbomqsCompatInput {
sbom: &sbom,
file_name: "empty.cdx.json",
raw_content: Some(r#"{"bomFormat":"CycloneDX","specVersion":"1.5","version":1}"#),
};
let scores = compute_scores(&input);
assert_eq!(scores.len(), 23);
let ignored: Vec<&str> = scores
.iter()
.filter(|s| s.ignored)
.map(|s| s.feature.as_str())
.collect();
assert_eq!(ignored.len(), 12);
for feature in [
"comp_with_name",
"comp_valid_licenses",
"comp_with_checksums",
] {
let entry = score(&scores, feature);
assert!(entry.ignored);
assert_eq!(entry.description, NO_COMPONENTS_DESC);
assert!(approx(entry.score, 0.0));
}
assert!(approx(score(&scores, "sbom_required_fields").score, 5.0));
}
#[test]
fn grade_brackets_match_sbomqs_source_not_repo_grades() {
assert_eq!(sbomqs_grade(9.0), "A");
assert_eq!(sbomqs_grade(8.999), "B");
assert_eq!(sbomqs_grade(8.0), "B");
assert_eq!(sbomqs_grade(7.0), "C");
assert_eq!(sbomqs_grade(6.9), "D");
assert_eq!(sbomqs_grade(5.0), "D");
assert_eq!(sbomqs_grade(4.9), "F");
}
#[test]
fn json_report_matches_sbomqs_field_names_and_identity() {
let sbom = cdx_fixture();
let input = SbomqsCompatInput {
sbom: &sbom,
file_name: "app.cdx.json",
raw_content: Some(CDX_RAW),
};
let json = render_json(&input);
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
for key in ["run_id", "timestamp", "creation_info", "files"] {
assert!(value.get(key).is_some(), "missing top-level key {key}");
}
let ci = &value["creation_info"];
for key in ["name", "version", "scoring_engine_version", "vendor"] {
assert!(ci.get(key).is_some(), "missing creation_info key {key}");
}
assert_eq!(ci["name"], "sbom-tools");
assert!(
ci["scoring_engine_version"]
.as_str()
.unwrap()
.contains("sbomqs-compat"),
);
let file = &value["files"][0];
for key in [
"file_name",
"spec",
"spec_version",
"file_format",
"avg_score",
"num_components",
"creation_time",
"gen_tool_name",
"gen_tool_version",
"scores",
] {
assert!(file.get(key).is_some(), "missing file key {key}");
}
assert_eq!(file["spec"], "cyclonedx");
assert_eq!(file["spec_version"], "1.5");
assert_eq!(file["file_format"], "json");
assert_eq!(file["num_components"], 3);
assert_eq!(file["gen_tool_name"], "syft");
assert_eq!(file["gen_tool_version"], "1.0.0");
let entries = file["scores"].as_array().unwrap();
assert_eq!(entries.len(), 23);
for entry in entries {
for key in [
"category",
"feature",
"score",
"max_score",
"description",
"ignored",
] {
assert!(entry.get(key).is_some(), "missing score key {key}");
}
assert_eq!(entry["max_score"], 10.0);
}
let sum: f64 = entries
.iter()
.filter(|e| !e["ignored"].as_bool().unwrap())
.map(|e| e["score"].as_f64().unwrap())
.sum();
let expected = sum / entries.len() as f64;
assert!(approx(file["avg_score"].as_f64().unwrap(), expected));
}
#[test]
fn summary_table_shows_categories_grade_and_non_convertibility_note() {
let sbom = cdx_fixture();
let input = SbomqsCompatInput {
sbom: &sbom,
file_name: "app.cdx.json",
raw_content: Some(CDX_RAW),
};
let table = render_summary_table(&input);
for cat in CATEGORY_ORDER {
assert!(table.contains(cat), "table must list category {cat}");
}
assert!(table.contains("avg_score"));
assert!(table.contains("sbomqs grade:"));
assert!(table.contains("not convertible"));
assert!(table.contains("sbomqs-json"));
}
#[test]
fn summary_table_renders_null_category_with_reason_without_raw() {
let sbom = cdx_fixture();
let input = SbomqsCompatInput {
sbom: &sbom,
file_name: "app.cdx.json",
raw_content: None,
};
let scores = compute_scores(&input);
let rollups = category_rollups(&scores);
let sharing = rollups.iter().find(|r| r.category == CAT_SHARING).unwrap();
assert!(sharing.score.is_none());
assert!(
sharing
.reason
.as_deref()
.is_some_and(|r| r.contains("cannot compute"))
);
let table = render_summary_table(&input);
assert!(table.contains("n/a"));
}
#[test]
fn expression_tokens_mirror_lookup_expression_semantics() {
assert!(expression_tokens("NOASSERTION").is_empty());
assert!(expression_tokens("NONE").is_empty());
assert!(expression_tokens("").is_empty());
let toks = expression_tokens("MIT OR FooBar");
assert_eq!(toks.len(), 2);
assert!(toks[0].listed && !toks[1].listed);
let toks = expression_tokens("Apache-2.0 WITH LLVM-exception");
assert_eq!(toks.len(), 1);
assert!(toks[0].listed);
let toks = expression_tokens("GPL-2.0+");
assert_eq!(toks.len(), 1);
assert!(toks[0].listed && toks[0].deprecated);
}
#[test]
fn spdx_file_components_are_excluded_from_denominators() {
let mut doc = DocumentMetadata::default();
doc.format = SbomFormat::Spdx;
doc.spec_version = "2.3".to_string();
let mut sbom = NormalizedSbom::new(doc);
let pkg = Component::new("pkg".to_string(), "SPDXRef-pkg".to_string())
.with_version("1.0".to_string());
let mut file = Component::new("a.c".to_string(), "SPDXRef-file".to_string());
file.component_type = ComponentType::File;
sbom.add_component(pkg);
sbom.add_component(file);
let input = SbomqsCompatInput {
sbom: &sbom,
file_name: "doc.spdx.json",
raw_content: None,
};
let scores = compute_scores(&input);
assert!(approx(score(&scores, "comp_with_version").score, 10.0));
let report = build_report(&input);
assert_eq!(report.files[0].num_components, 1);
}
#[test]
fn file_format_detection_covers_json_xml_tag_value() {
assert_eq!(
detect_file_format(" {\"a\":1}", &SbomFormat::CycloneDx),
"json"
);
assert_eq!(detect_file_format("<bom/>", &SbomFormat::CycloneDx), "xml");
assert_eq!(
detect_file_format(
"SPDXVersion: SPDX-2.3\nDataLicense: CC0-1.0",
&SbomFormat::Spdx
),
"tag-value"
);
assert_eq!(
detect_file_format("hello", &SbomFormat::CycloneDx),
"unknown"
);
}
}