use chrono::{DateTime, Utc};
use sbom_tools::model::{AttestationRuleFamily, CdxaResolution, EvidenceLevel, NormalizedSbom};
use sbom_tools::{parse_sbom, parse_sbom_str};
use std::path::PathBuf;
fn fixture_path(name: &str) -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures")
.join(name)
}
fn parse_fixture(name: &str) -> NormalizedSbom {
parse_sbom(&fixture_path(name)).expect("fixture must parse")
}
fn ts(s: &str) -> DateTime<Utc> {
DateTime::parse_from_rfc3339(s)
.expect("test timestamp must parse")
.with_timezone(&Utc)
}
#[test]
fn fixture_declarations_parse_into_model() {
let sbom = parse_fixture("cyclonedx/declarations-cdxa.cdx.json");
let decls = sbom.declarations().expect("declarations must be populated");
assert_eq!(decls.assessors.len(), 1);
let assessor = &decls.assessors[0];
assert_eq!(assessor.bom_ref.as_deref(), Some("assessor-1"));
assert_eq!(assessor.third_party, Some(true));
assert_eq!(
assessor.organization.as_ref().map(|o| o.name.as_str()),
Some("Auditors Inc")
);
assert_eq!(decls.claims.len(), 3);
let claim = decls
.claim_by_ref("claim-provenance")
.expect("claim-provenance");
assert_eq!(
claim.predicate.as_deref(),
Some("Build provenance is generated and archived for every release build")
);
assert_eq!(
claim.reasoning.as_deref(),
Some("CI provenance archive reviewed during the 2026-01 audit")
);
assert_eq!(claim.evidence.len(), 1);
assert_eq!(claim.evidence[0].raw, "ev-provenance");
assert_eq!(claim.evidence[0].resolution, CdxaResolution::Evidence);
let sig = claim.signature.as_ref().expect("claim signature presence");
assert_eq!(sig.algorithm.as_deref(), Some("ES256"));
assert_eq!(sig.signer_count, 1);
assert_eq!(decls.evidence.len(), 1);
let evidence = decls
.evidence_by_ref("ev-provenance")
.expect("ev-provenance");
assert_eq!(
evidence.property_name.as_deref(),
Some("internal:build:provenance")
);
assert_eq!(evidence.created, Some(ts("2026-01-10T00:00:00Z")));
assert_eq!(evidence.expires, Some(ts("2027-01-10T00:00:00Z")));
assert_eq!(evidence.data.len(), 1);
assert_eq!(
evidence.data[0].name.as_deref(),
Some("provenance-2026-01.json")
);
assert_eq!(
evidence.data[0].url.as_deref(),
Some("https://evidence.acme.example/provenance/2026-01.json")
);
assert!(!evidence.data[0].has_attachment);
assert_eq!(evidence.data[0].classification.as_deref(), Some("internal"));
assert_eq!(
evidence.author.as_ref().and_then(|a| a.email.as_deref()),
Some("jane@acme.example")
);
assert!(evidence.signature.is_some());
assert_eq!(decls.attestations.len(), 1);
let attestation = &decls.attestations[0];
assert_eq!(
attestation.summary.as_deref(),
Some("SSDF conformance attestation for acme-app 3.0.0")
);
let assessor_ref = attestation.assessor.as_ref().expect("assessor ref");
assert_eq!(assessor_ref.resolution, CdxaResolution::Assessor);
assert_eq!(attestation.map.len(), 3);
let ps1_entry = &attestation.map[0];
assert_eq!(ps1_entry.conformance_score, Some(1.0));
assert_eq!(
ps1_entry.conformance_rationale.as_deref(),
Some("All code repositories enforce least privilege")
);
assert_eq!(ps1_entry.confidence_score, Some(0.9));
let affirmation = decls.affirmation.as_ref().expect("affirmation");
assert_eq!(
affirmation.statement.as_deref(),
Some("I certify, to the best of my knowledge, that all information is correct.")
);
assert_eq!(affirmation.signatories.len(), 2);
assert!(affirmation.signatories[0].has_complete_identity());
assert!(!affirmation.signatories[1].has_complete_identity());
assert_eq!(decls.standards.len(), 1);
let standard = &decls.standards[0];
assert_eq!(standard.version.as_deref(), Some("1.1"));
assert_eq!(standard.requirements.len(), 2);
let ps1 = &standard.requirements[0];
assert_eq!(ps1.identifier.as_deref(), Some("PS.1"));
assert_eq!(ps1.descriptions.len(), 1);
assert_eq!(ps1.open_cre, vec!["CRE:764-507".to_string()]);
let targets = decls.targets.as_ref().expect("targets");
assert_eq!(targets.organizations.len(), 1);
assert_eq!(
targets.organizations[0].name.as_deref(),
Some("Acme Product BU")
);
assert_eq!(
decls.document_evidence_level(),
EvidenceLevel::SignaturePresent
);
}
#[test]
fn claim_targets_resolve_by_bom_ref_and_purl_fallback() {
let sbom = parse_fixture("cyclonedx/declarations-cdxa.cdx.json");
let decls = sbom.declarations().expect("declarations");
let by_bom_ref = decls
.claim_by_ref("claim-provenance")
.and_then(|c| c.target.as_ref())
.expect("target");
let primary_id = sbom
.primary_component_id
.clone()
.expect("primary component");
assert_eq!(
by_bom_ref.resolution,
CdxaResolution::Inventory(primary_id),
"claim target must resolve through the same id_map as dependencies"
);
let by_purl = decls
.claim_by_ref("claim-purl-target")
.and_then(|c| c.target.as_ref())
.expect("target");
let lib_id = sbom
.components
.values()
.find(|c| c.name == "libalpha")
.map(|c| c.canonical_id.clone())
.expect("libalpha component");
assert_eq!(by_purl.resolution, CdxaResolution::Inventory(lib_id));
let dangling = decls.claim_by_ref("claim-dangling").expect("claim");
assert_eq!(
dangling.target.as_ref().map(|t| t.resolution.clone()),
Some(CdxaResolution::Dangling)
);
assert_eq!(dangling.evidence[0].resolution, CdxaResolution::Dangling);
let unknown_req = &sbom.declarations().expect("declarations").attestations[0].map[2];
assert_eq!(
unknown_req
.requirement
.as_ref()
.map(|r| r.resolution.clone()),
Some(CdxaResolution::Dangling)
);
}
#[test]
fn supported_requirements_fail_closed() {
let sbom = parse_fixture("cyclonedx/declarations-cdxa.cdx.json");
let decls = sbom.declarations().expect("declarations");
let supported = decls.supported_requirements(ts("2026-06-01T00:00:00Z"));
assert_eq!(supported.len(), 1);
let ps1 = &supported[0];
assert_eq!(ps1.requirement.identifier.as_deref(), Some("PS.1"));
assert_eq!(ps1.supporting_claims.len(), 2);
assert!(ps1.third_party_assessed);
assert_eq!(ps1.evidence_level, EvidenceLevel::SignaturePresent);
assert_ne!(ps1.evidence_level, EvidenceLevel::SignatureVerified);
assert!(
decls
.supported_requirements(ts("2027-06-01T00:00:00Z"))
.is_empty()
);
assert!(
decls
.supported_requirements(ts("2025-12-01T00:00:00Z"))
.is_empty()
);
}
#[test]
fn evidence_for_family_maps_ssdf_only() {
let sbom = parse_fixture("cyclonedx/declarations-cdxa.cdx.json");
let decls = sbom.declarations().expect("declarations");
let as_of = ts("2026-06-01T00:00:00Z");
let ssdf = decls.evidence_for_family(AttestationRuleFamily::Ssdf, as_of);
assert_eq!(ssdf.len(), 1);
assert_eq!(
ssdf[0].standard.name.as_deref(),
Some("NIST Secure Software Development Framework")
);
assert!(
decls
.evidence_for_family(AttestationRuleFamily::Eo14028, as_of)
.is_empty()
);
assert!(
decls
.evidence_for_family(AttestationRuleFamily::Cra, as_of)
.is_empty()
);
}
#[test]
fn declarations_survive_serde_round_trip() {
let sbom = parse_fixture("cyclonedx/declarations-cdxa.cdx.json");
let json = serde_json::to_string(&sbom.extensions).expect("serialize extensions");
let restored: sbom_tools::model::FormatExtensions =
serde_json::from_str(&json).expect("deserialize extensions");
assert_eq!(
restored.declarations.as_ref(),
sbom.declarations(),
"declarations must round out identically through serde"
);
}
#[test]
fn absent_declarations_leave_serialized_output_unchanged() {
for fixture in [
"cyclonedx/minimal.cdx.json",
"cyclonedx/cbom-1.6.cdx.json",
"cyclonedx/with-vulnerabilities.cdx.json",
] {
let sbom = parse_fixture(fixture);
assert!(
sbom.declarations().is_none(),
"{fixture}: no declarations expected"
);
let extensions = serde_json::to_value(&sbom.extensions).expect("serialize extensions");
let extensions = extensions.as_object().expect("extensions object");
assert!(
!extensions.contains_key("declarations"),
"{fixture}: serialized output must not gain a declarations key"
);
}
}
#[test]
fn declarations_are_not_probed_on_pre_1_6_documents() {
let doc = r#"{
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"components": [
{"type": "library", "bom-ref": "c", "name": "libc", "version": "1.0"}
],
"declarations": {
"claims": [{"bom-ref": "claim-1", "target": "c", "predicate": "x"}]
}
}"#;
let sbom = parse_sbom_str(doc).expect("1.5 document must parse");
assert!(sbom.declarations().is_none());
let doc_16 = doc.replace("\"specVersion\": \"1.5\"", "\"specVersion\": \"1.6\"");
let sbom = parse_sbom_str(&doc_16).expect("1.6 document must parse");
let decls = sbom.declarations().expect("1.6 declarations parse");
assert_eq!(decls.claims.len(), 1);
}
#[test]
fn declarations_change_content_hash_only_when_present() {
let bare = r#"{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"components": [
{"type": "library", "bom-ref": "c", "name": "libc", "version": "1.0"}
]
}"#;
let with_decls = r#"{
"bomFormat": "CycloneDX",
"specVersion": "1.6",
"version": 1,
"components": [
{"type": "library", "bom-ref": "c", "name": "libc", "version": "1.0"}
],
"declarations": {
"claims": [{"bom-ref": "claim-1", "target": "c", "predicate": "x"}]
}
}"#;
let bare_a = parse_sbom_str(bare).expect("parse");
let bare_b = parse_sbom_str(bare).expect("parse");
let decorated = parse_sbom_str(with_decls).expect("parse");
assert_eq!(bare_a.content_hash, bare_b.content_hash);
assert_ne!(bare_a.content_hash, decorated.content_hash);
}