1use crate::model::{NormalizedSbom, SbomFormat};
11use serde::{Deserialize, Serialize};
12
13mod bsi;
14mod bsi_sbom_for_ai;
15mod context;
16mod cra;
17mod crypto;
18mod eo14028;
19mod eu_ai_act;
20mod eucc;
21mod generic;
22mod registry;
23mod shared;
24mod ssdf;
25
26use context::{ComplianceContext, checker_for};
27use registry::REMEDIATION_GENERIC;
28pub use registry::{RuleMeta, rule_meta};
29use shared::{is_valid_email_format, truncate_list};
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33pub enum CraPhase {
34 Phase1,
37 Phase2,
40}
41
42impl CraPhase {
43 pub const fn name(self) -> &'static str {
44 match self {
45 Self::Phase1 => "Phase 1 (2027)",
46 Self::Phase2 => "Phase 2 (2029)",
47 }
48 }
49
50 pub const fn deadline(self) -> &'static str {
51 match self {
52 Self::Phase1 => "11 December 2027",
53 Self::Phase2 => "11 December 2029",
54 }
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[non_exhaustive]
61pub enum ComplianceLevel {
62 Minimum,
64 Standard,
66 NtiaMinimum,
68 CraPhase1,
70 CraPhase2,
72 FdaMedicalDevice,
74 NistSsdf,
76 Eo14028,
78 Cnsa2,
80 NistPqc,
82 BsiTr03183_2,
85 CraOssSteward,
90 EuccSubstantial,
96 EuAiAct,
104 BsiSbomForAi,
114 Comprehensive,
116}
117
118impl ComplianceLevel {
119 #[must_use]
121 pub const fn name(&self) -> &'static str {
122 match self {
123 Self::Minimum => "Minimum",
124 Self::Standard => "Standard",
125 Self::NtiaMinimum => "NTIA Minimum Elements",
126 Self::CraPhase1 => "EU CRA Phase 1 (2027)",
127 Self::CraPhase2 => "EU CRA Phase 2 (2029)",
128 Self::FdaMedicalDevice => "FDA Medical Device",
129 Self::NistSsdf => "NIST SSDF (SP 800-218)",
130 Self::Eo14028 => "EO 14028 Section 4",
131 Self::Cnsa2 => "CNSA 2.0",
132 Self::NistPqc => "NIST PQC Readiness",
133 Self::BsiTr03183_2 => "BSI TR-03183-2",
134 Self::CraOssSteward => "CRA OSS Steward (Art. 24)",
135 Self::EuccSubstantial => "EUCC Substantial (Reg. 2024/482)",
136 Self::EuAiAct => "EU AI Act Annex IV Readiness",
137 Self::BsiSbomForAi => "BSI/G7 SBOM-for-AI Minimum Elements Readiness",
138 Self::Comprehensive => "Comprehensive",
139 }
140 }
141
142 #[must_use]
144 pub const fn short_name(&self) -> &'static str {
145 match self {
146 Self::Minimum => "Min",
147 Self::Standard => "Std",
148 Self::NtiaMinimum => "NTIA",
149 Self::CraPhase1 => "CRA-1",
150 Self::CraPhase2 => "CRA-2",
151 Self::FdaMedicalDevice => "FDA",
152 Self::NistSsdf => "SSDF",
153 Self::Eo14028 => "EO14028",
154 Self::Cnsa2 => "CNSA2",
155 Self::NistPqc => "PQC",
156 Self::BsiTr03183_2 => "BSI",
157 Self::CraOssSteward => "OSS",
158 Self::EuccSubstantial => "EUCC",
159 Self::EuAiAct => "AI-Act",
160 Self::BsiSbomForAi => "BSI-AI",
161 Self::Comprehensive => "Full",
162 }
163 }
164
165 #[must_use]
167 pub const fn description(&self) -> &'static str {
168 match self {
169 Self::Minimum => "Basic component identification only",
170 Self::Standard => "Recommended fields for general use",
171 Self::NtiaMinimum => "NTIA minimum elements for software transparency",
172 Self::CraPhase1 => {
173 "CRA reporting obligations — product ID, SBOM format, manufacturer (deadline: 11 Dec 2027)"
174 }
175 Self::CraPhase2 => {
176 "Full CRA compliance — adds vulnerability metadata, lifecycle, disclosure (deadline: 11 Dec 2029)"
177 }
178 Self::FdaMedicalDevice => "FDA premarket submission requirements for medical devices",
179 Self::NistSsdf => {
180 "Secure Software Development Framework — provenance, build integrity, VCS references"
181 }
182 Self::Eo14028 => {
183 "Executive Order 14028 — machine-readable SBOM, auto-generation, supply chain security"
184 }
185 Self::Cnsa2 => {
186 "CNSA 2.0 — AES-256, SHA-384+, ML-KEM-1024, ML-DSA-87, quantum security level 5"
187 }
188 Self::NistPqc => {
189 "NIST PQC — quantum-vulnerable algorithm detection, FIPS 203/204/205, SP 800-131A"
190 }
191 Self::BsiTr03183_2 => {
192 "BSI TR-03183-2 — German national SBOM guideline (free, ENISA-cited): mandatory hashes, identifiers, ISO-8601 timestamps"
193 }
194 Self::CraOssSteward => {
195 "CRA Article 24 — Open-source software steward (lighter than full manufacturer obligations): SBOM + CVD policy + vuln-handling required, no DoC/module/manufacturer-email enforcement"
196 }
197 Self::EuccSubstantial => {
198 "EUCC Substantial (Reg. (EU) 2024/482) — reference-only check for Common-Criteria Protection Profile, Target of Evaluation, ITSEF, and certificate valid-until date"
199 }
200 Self::EuAiAct => {
201 "EU AI Act (Reg. (EU) 2024/1689) Annex IV technical-documentation READINESS — model description, training-data characteristics, validation/testing metrics, limitations (readiness only, not a legal-conformity guarantee; N/A for non-AI SBOMs)"
202 }
203 Self::BsiSbomForAi => {
204 "BSI/G7 SBOM-for-AI Minimum Elements (Feb 2026) READINESS — scores an AI-BOM element-by-element across the Metadata, System-Level, Models, Datasets, Infrastructure, and Security clusters (readiness only, not a legal-conformity guarantee; N/A for non-AI SBOMs)"
205 }
206 Self::Comprehensive => "All recommended fields and best practices",
207 }
208 }
209
210 #[must_use]
212 pub const fn all() -> &'static [Self] {
213 &[
214 Self::Minimum,
215 Self::Standard,
216 Self::NtiaMinimum,
217 Self::CraPhase1,
218 Self::CraPhase2,
219 Self::FdaMedicalDevice,
220 Self::NistSsdf,
221 Self::Eo14028,
222 Self::Cnsa2,
223 Self::NistPqc,
224 Self::BsiTr03183_2,
225 Self::CraOssSteward,
226 Self::EuccSubstantial,
227 Self::EuAiAct,
228 Self::BsiSbomForAi,
229 Self::Comprehensive,
230 ]
231 }
232
233 #[must_use]
237 pub const fn is_cra(&self) -> bool {
238 matches!(
239 self,
240 Self::CraPhase1 | Self::CraPhase2 | Self::CraOssSteward
241 )
242 }
243
244 #[must_use]
246 pub const fn cra_phase(&self) -> Option<CraPhase> {
247 match self {
248 Self::CraPhase1 => Some(CraPhase::Phase1),
249 Self::CraPhase2 => Some(CraPhase::Phase2),
250 _ => None,
251 }
252 }
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
263#[non_exhaustive]
264pub enum StandardKind {
265 CraArticle,
267 CraAnnex,
269 Pren40000_1_3,
271 BsiTr03183_2,
273 NistSsdf,
275 Eo14028,
277 FdaPremarket,
279 NtiaMinimum,
281 Csaf2,
283 Cnsa2,
285 NistPqc,
287 EuAiAct,
289 BsiSbomForAi,
291 Other,
293}
294
295impl StandardKind {
296 #[must_use]
298 pub const fn label(self) -> &'static str {
299 match self {
300 Self::CraArticle => "CRA Article",
301 Self::CraAnnex => "CRA Annex",
302 Self::Pren40000_1_3 => "prEN 40000-1-3",
303 Self::BsiTr03183_2 => "BSI TR-03183-2",
304 Self::NistSsdf => "NIST SSDF",
305 Self::Eo14028 => "EO 14028",
306 Self::FdaPremarket => "FDA",
307 Self::NtiaMinimum => "NTIA",
308 Self::Csaf2 => "CSAF v2.0",
309 Self::Cnsa2 => "CNSA 2.0",
310 Self::NistPqc => "NIST PQC",
311 Self::EuAiAct => "EU AI Act",
312 Self::BsiSbomForAi => "BSI/G7 AI-SBOM",
313 Self::Other => "Other",
314 }
315 }
316}
317
318#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
325pub struct StandardRef {
326 pub standard: StandardKind,
328 pub id: String,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub help_uri: Option<String>,
333}
334
335impl StandardRef {
336 #[must_use]
340 pub fn new(standard: StandardKind, id: impl Into<String>) -> Self {
341 let id = id.into();
342 let help_uri = standard.canonical_help_uri(&id);
343 Self {
344 standard,
345 id,
346 help_uri,
347 }
348 }
349
350 #[must_use]
351 pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
352 self.help_uri = Some(uri.into());
353 self
354 }
355}
356
357impl StandardKind {
358 #[must_use]
368 pub fn canonical_help_uri(self, _id: &str) -> Option<String> {
369 let url = match self {
370 Self::CraArticle | Self::CraAnnex => {
372 "https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng"
373 }
374 Self::Pren40000_1_3 => return None,
376 Self::BsiTr03183_2 => {
378 "https://www.bsi.bund.de/EN/Themen/Unternehmen-und-Organisationen/Standards-und-Zertifizierung/Technische-Richtlinien/TR-nach-Thema-sortiert/tr03183/TR-03183_node.html"
379 }
380 Self::NistSsdf => "https://doi.org/10.6028/NIST.SP.800-218",
382 Self::Eo14028 => "https://www.federalregister.gov/d/2021-10460",
384 Self::FdaPremarket => {
386 "https://www.fda.gov/regulatory-information/search-fda-guidance-documents/cybersecurity-medical-devices-quality-system-considerations-and-content-premarket-submissions"
387 }
388 Self::NtiaMinimum => {
390 "https://www.ntia.doc.gov/files/ntia/publications/sbom_minimum_elements_report.pdf"
391 }
392 Self::Csaf2 => "https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html",
394 Self::Cnsa2 => {
396 "https://media.defense.gov/2022/Sep/07/2003071834/-1/-1/0/CSA_CNSA_2.0_ALGORITHMS_.PDF"
397 }
398 Self::NistPqc => "https://csrc.nist.gov/projects/post-quantum-cryptography",
400 Self::EuAiAct => "https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng",
402 Self::BsiSbomForAi => "https://www.bsi.bund.de",
404 Self::Other => return None,
405 };
406 Some(url.to_string())
407 }
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct Violation {
413 pub severity: ViolationSeverity,
415 pub category: ViolationCategory,
417 pub message: String,
419 pub element: Option<String>,
421 pub requirement: String,
423 #[serde(skip, default = "default_rule_id")]
433 pub rule_id: &'static str,
434 #[serde(default, skip_serializing_if = "Vec::is_empty")]
439 pub standard_refs: Vec<StandardRef>,
440}
441
442fn default_rule_id() -> &'static str {
445 "SBOM-CRA-GENERAL"
446}
447
448impl Violation {
449 #[must_use]
462 pub fn registry_standard_refs(&self) -> Vec<StandardRef> {
463 rule_meta(self.rule_id)
464 .map(|m| {
465 m.refs
466 .iter()
467 .map(|(kind, id)| StandardRef::new(*kind, *id))
468 .collect()
469 })
470 .unwrap_or_default()
471 }
472
473 #[must_use]
476 pub fn remediation_guidance(&self) -> &'static str {
477 rule_meta(self.rule_id).map_or(REMEDIATION_GENERIC, |m| m.remediation)
478 }
479}
480
481#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
483pub enum ViolationSeverity {
484 Error,
486 Warning,
488 Info,
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
494pub enum ViolationCategory {
495 DocumentMetadata,
497 ComponentIdentification,
499 DependencyInfo,
501 LicenseInfo,
503 SupplierInfo,
505 IntegrityInfo,
507 SecurityInfo,
509 FormatSpecific,
511 CryptographyInfo,
513}
514
515impl ViolationCategory {
516 #[must_use]
517 pub const fn name(&self) -> &'static str {
518 match self {
519 Self::DocumentMetadata => "Document Metadata",
520 Self::ComponentIdentification => "Component Identification",
521 Self::DependencyInfo => "Dependency Information",
522 Self::LicenseInfo => "License Information",
523 Self::SupplierInfo => "Supplier Information",
524 Self::IntegrityInfo => "Integrity Information",
525 Self::SecurityInfo => "Security Information",
526 Self::FormatSpecific => "Format-Specific",
527 Self::CryptographyInfo => "Cryptography",
528 }
529 }
530
531 #[must_use]
533 pub const fn short_name(&self) -> &'static str {
534 match self {
535 Self::DocumentMetadata => "Doc Meta",
536 Self::ComponentIdentification => "Comp IDs",
537 Self::DependencyInfo => "Deps",
538 Self::LicenseInfo => "License",
539 Self::SupplierInfo => "Supplier",
540 Self::IntegrityInfo => "Integrity",
541 Self::SecurityInfo => "Security",
542 Self::FormatSpecific => "Format",
543 Self::CryptographyInfo => "Crypto",
544 }
545 }
546
547 #[must_use]
549 pub const fn all() -> &'static [Self] {
550 &[
551 Self::SupplierInfo,
552 Self::ComponentIdentification,
553 Self::DocumentMetadata,
554 Self::IntegrityInfo,
555 Self::LicenseInfo,
556 Self::DependencyInfo,
557 Self::SecurityInfo,
558 Self::FormatSpecific,
559 Self::CryptographyInfo,
560 ]
561 }
562}
563
564#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct ComplianceResult {
567 pub is_compliant: bool,
569 pub level: ComplianceLevel,
571 pub violations: Vec<Violation>,
573 pub error_count: usize,
575 pub warning_count: usize,
577 pub info_count: usize,
579 #[serde(default, skip_serializing_if = "Option::is_none")]
583 pub conformity_summary: Option<ConformityAssessmentSummary>,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct ConformityAssessmentSummary {
592 pub product_class: crate::model::CraProductClass,
594 pub route: crate::model::ConformityRoute,
596 pub evidence: Vec<ConformityEvidence>,
598}
599
600#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct ConformityEvidence {
605 pub label: String,
607 pub detail: String,
609 pub satisfied: bool,
611}
612
613impl ComplianceResult {
614 #[must_use]
616 pub fn new(level: ComplianceLevel, violations: Vec<Violation>) -> Self {
617 let error_count = violations
618 .iter()
619 .filter(|v| v.severity == ViolationSeverity::Error)
620 .count();
621 let warning_count = violations
622 .iter()
623 .filter(|v| v.severity == ViolationSeverity::Warning)
624 .count();
625 let info_count = violations
626 .iter()
627 .filter(|v| v.severity == ViolationSeverity::Info)
628 .count();
629
630 Self {
631 is_compliant: error_count == 0,
632 level,
633 violations,
634 conformity_summary: None,
635 error_count,
636 warning_count,
637 info_count,
638 }
639 }
640
641 #[must_use]
643 pub fn violations_by_severity(&self, severity: ViolationSeverity) -> Vec<&Violation> {
644 self.violations
645 .iter()
646 .filter(|v| v.severity == severity)
647 .collect()
648 }
649
650 #[must_use]
652 pub fn violations_by_category(&self, category: ViolationCategory) -> Vec<&Violation> {
653 self.violations
654 .iter()
655 .filter(|v| v.category == category)
656 .collect()
657 }
658}
659
660#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
669pub enum ClassCheck {
670 VendorHashCoverage,
672 EolComponents,
674 Cycles,
676 DocReference,
678 EuccReference,
680 Psirt,
682 ModuleAttestation,
685}
686
687#[derive(Debug, Clone)]
689pub struct ComplianceChecker {
690 level: ComplianceLevel,
692 sidecar: Option<crate::model::CraSidecarMetadata>,
697 product_class: Option<crate::model::CraProductClass>,
701}
702
703impl ComplianceChecker {
704 #[must_use]
706 pub const fn new(level: ComplianceLevel) -> Self {
707 Self {
708 level,
709 sidecar: None,
710 product_class: None,
711 }
712 }
713
714 #[must_use]
721 pub fn with_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
722 self.sidecar = Some(sidecar);
723 self
724 }
725
726 #[must_use]
731 pub const fn with_product_class(mut self, class: crate::model::CraProductClass) -> Self {
732 self.product_class = Some(class);
733 self
734 }
735
736 #[must_use]
741 pub fn effective_product_class(&self) -> crate::model::CraProductClass {
742 self.sidecar
743 .as_ref()
744 .and_then(|s| s.product_class)
745 .or(self.product_class)
746 .unwrap_or(crate::model::CraProductClass::Default)
747 }
748
749 #[must_use]
752 pub fn effective_route(&self) -> crate::model::ConformityRoute {
753 self.sidecar
754 .as_ref()
755 .and_then(|s| s.conformity_assessment_route)
756 .unwrap_or_else(|| self.effective_product_class().default_route())
757 }
758
759 #[must_use]
763 pub fn class_severity(&self, check: ClassCheck) -> Option<ViolationSeverity> {
764 use crate::model::CraProductClass as C;
765 let class = self.effective_product_class();
766 match (check, class) {
767 (ClassCheck::VendorHashCoverage, C::Default | C::ImportantClass1) => {
771 Some(ViolationSeverity::Warning)
772 }
773 (ClassCheck::VendorHashCoverage, C::ImportantClass2 | C::Critical) => {
774 Some(ViolationSeverity::Error)
775 }
776
777 (ClassCheck::EolComponents, C::Default | C::ImportantClass1) => {
778 Some(ViolationSeverity::Warning)
779 }
780 (ClassCheck::EolComponents, C::ImportantClass2 | C::Critical) => {
781 Some(ViolationSeverity::Error)
782 }
783
784 (ClassCheck::Cycles, C::Default | C::ImportantClass1) => {
785 Some(ViolationSeverity::Warning)
786 }
787 (ClassCheck::Cycles, C::ImportantClass2 | C::Critical) => {
788 Some(ViolationSeverity::Error)
789 }
790
791 (ClassCheck::DocReference, C::Default) => Some(ViolationSeverity::Info),
792 (ClassCheck::DocReference, C::ImportantClass1) => Some(ViolationSeverity::Warning),
793 (ClassCheck::DocReference, C::ImportantClass2 | C::Critical) => {
794 Some(ViolationSeverity::Error)
795 }
796
797 (ClassCheck::EuccReference, C::Default | C::ImportantClass1) => None,
798 (ClassCheck::EuccReference, C::ImportantClass2) => Some(ViolationSeverity::Info),
799 (ClassCheck::EuccReference, C::Critical) => Some(ViolationSeverity::Error),
800
801 (ClassCheck::Psirt, C::Default | C::ImportantClass1) => {
802 Some(ViolationSeverity::Warning)
803 }
804 (ClassCheck::Psirt, C::ImportantClass2 | C::Critical) => Some(ViolationSeverity::Error),
805
806 (ClassCheck::ModuleAttestation, C::Default) => None,
807 (ClassCheck::ModuleAttestation, C::ImportantClass1) => Some(ViolationSeverity::Warning),
808 (ClassCheck::ModuleAttestation, C::ImportantClass2 | C::Critical) => {
809 Some(ViolationSeverity::Error)
810 }
811 }
812 }
813
814 #[must_use]
818 pub fn vendor_hash_threshold(&self) -> f64 {
819 use crate::model::CraProductClass as C;
820 match self.effective_product_class() {
821 C::Default => 0.50,
822 C::ImportantClass1 | C::ImportantClass2 => 0.80,
823 C::Critical => 1.00,
824 }
825 }
826
827 #[must_use]
833 pub fn has_explicit_product_class(&self) -> bool {
834 self.product_class.is_some()
835 || self
836 .sidecar
837 .as_ref()
838 .and_then(|s| s.product_class)
839 .is_some()
840 }
841
842 #[must_use]
850 pub fn check(&self, sbom: &NormalizedSbom) -> ComplianceResult {
851 let ctx = ComplianceContext::new(self, sbom);
852 let checker = checker_for(self.level);
853 debug_assert_eq!(
854 checker.level(),
855 self.level,
856 "dispatched checker must match the configured level"
857 );
858 let mut violations = checker.check(&ctx);
859
860 for v in &mut violations {
862 if v.standard_refs.is_empty() {
863 v.standard_refs = v.registry_standard_refs();
864 }
865 }
866
867 let mut result = ComplianceResult::new(self.level, violations);
868 if self.level.is_cra() && self.has_explicit_product_class() {
871 result.conformity_summary = Some(self.build_conformity_summary(sbom));
872 }
873 result
874 }
875}
876
877impl Default for ComplianceChecker {
878 fn default() -> Self {
879 Self::new(ComplianceLevel::Standard)
880 }
881}
882
883#[cfg(test)]
884mod tests {
885 use super::*;
886
887 #[test]
888 fn test_compliance_level_names() {
889 assert_eq!(ComplianceLevel::Minimum.name(), "Minimum");
890 assert_eq!(ComplianceLevel::NtiaMinimum.name(), "NTIA Minimum Elements");
891 assert_eq!(ComplianceLevel::CraPhase1.name(), "EU CRA Phase 1 (2027)");
892 assert_eq!(ComplianceLevel::CraPhase2.name(), "EU CRA Phase 2 (2029)");
893 assert_eq!(ComplianceLevel::NistSsdf.name(), "NIST SSDF (SP 800-218)");
894 assert_eq!(ComplianceLevel::Eo14028.name(), "EO 14028 Section 4");
895 }
896
897 #[test]
898 fn test_nist_ssdf_empty_sbom() {
899 let sbom = NormalizedSbom::default();
900 let checker = ComplianceChecker::new(ComplianceLevel::NistSsdf);
901 let result = checker.check(&sbom);
902 assert!(
904 result
905 .violations
906 .iter()
907 .any(|v| v.requirement.contains("PS.1"))
908 );
909 }
910
911 #[test]
912 fn test_eo14028_empty_sbom() {
913 let sbom = NormalizedSbom::default();
914 let checker = ComplianceChecker::new(ComplianceLevel::Eo14028);
915 let result = checker.check(&sbom);
916 assert!(
917 result
918 .violations
919 .iter()
920 .any(|v| v.requirement.contains("EO 14028"))
921 );
922 }
923
924 #[test]
925 fn test_compliance_result_counts() {
926 let violations = vec![
927 Violation {
928 severity: ViolationSeverity::Error,
929 category: ViolationCategory::ComponentIdentification,
930 message: "Error 1".to_string(),
931 element: None,
932 requirement: "Test".to_string(),
933 rule_id: "SBOM-CRA-GENERAL",
934 standard_refs: Vec::new(),
935 },
936 Violation {
937 severity: ViolationSeverity::Warning,
938 category: ViolationCategory::LicenseInfo,
939 message: "Warning 1".to_string(),
940 element: None,
941 requirement: "Test".to_string(),
942 rule_id: "SBOM-CRA-GENERAL",
943 standard_refs: Vec::new(),
944 },
945 Violation {
946 severity: ViolationSeverity::Info,
947 category: ViolationCategory::FormatSpecific,
948 message: "Info 1".to_string(),
949 element: None,
950 requirement: "Test".to_string(),
951 rule_id: "SBOM-CRA-GENERAL",
952 standard_refs: Vec::new(),
953 },
954 ];
955
956 let result = ComplianceResult::new(ComplianceLevel::Standard, violations);
957 assert!(!result.is_compliant);
958 assert_eq!(result.error_count, 1);
959 assert_eq!(result.warning_count, 1);
960 assert_eq!(result.info_count, 1);
961 }
962
963 fn make_crypto_sbom(algos: &[(&str, &str, Option<&str>, Option<u8>)]) -> NormalizedSbom {
964 use crate::model::{
965 AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
966 };
967 let mut sbom = NormalizedSbom::default();
968 for (name, family, param, ql) in algos {
969 let mut c = crate::model::Component::new(name.to_string(), format!("{name}@1.0"));
970 c.component_type = ComponentType::Cryptographic;
971 let mut algo = AlgorithmProperties::new(CryptoPrimitive::Ae)
972 .with_algorithm_family(family.to_string());
973 if let Some(p) = param {
974 algo = algo.with_parameter_set_identifier(p.to_string());
975 }
976 if let Some(level) = ql {
977 algo = algo.with_nist_quantum_security_level(*level);
978 }
979 c.crypto_properties = Some(
980 CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(algo),
981 );
982 sbom.add_component(c);
983 }
984 sbom
985 }
986
987 #[test]
988 fn test_cnsa2_aes128_violation() {
989 let sbom = make_crypto_sbom(&[("AES-128-GCM", "AES", Some("128"), Some(1))]);
990 let checker = ComplianceChecker::new(ComplianceLevel::Cnsa2);
991 let result = checker.check(&sbom);
992 assert!(
993 result
994 .violations
995 .iter()
996 .any(|v| v.severity == ViolationSeverity::Error && v.message.contains("AES-128")),
997 "CNSA 2.0 should flag AES-128"
998 );
999 }
1000
1001 #[test]
1002 fn test_cnsa2_mlkem1024_passes() {
1003 let sbom = make_crypto_sbom(&[("ML-KEM-1024", "ML-KEM", Some("1024"), Some(5))]);
1004 let checker = ComplianceChecker::new(ComplianceLevel::Cnsa2);
1005 let result = checker.check(&sbom);
1006 let algo_errors: Vec<_> = result
1007 .violations
1008 .iter()
1009 .filter(|v| {
1010 v.severity == ViolationSeverity::Error
1011 && v.element.as_deref() == Some("ML-KEM-1024")
1012 })
1013 .collect();
1014 assert!(algo_errors.is_empty(), "ML-KEM-1024 should pass CNSA 2.0");
1015 }
1016
1017 #[test]
1018 fn test_pqc_quantum_vulnerable() {
1019 let sbom = make_crypto_sbom(&[("RSA-2048", "RSA", None, Some(0))]);
1020 let checker = ComplianceChecker::new(ComplianceLevel::NistPqc);
1021 let result = checker.check(&sbom);
1022 assert!(
1023 result
1024 .violations
1025 .iter()
1026 .any(|v| v.severity == ViolationSeverity::Error
1027 && v.message.contains("quantum-vulnerable")),
1028 "PQC should flag RSA-2048 as quantum-vulnerable"
1029 );
1030 }
1031
1032 #[test]
1033 fn test_pqc_approved_algorithm_info() {
1034 let sbom = make_crypto_sbom(&[("ML-DSA-65", "ML-DSA", Some("65"), Some(3))]);
1035 let checker = ComplianceChecker::new(ComplianceLevel::NistPqc);
1036 let result = checker.check(&sbom);
1037 assert!(
1038 result
1039 .violations
1040 .iter()
1041 .any(|v| v.severity == ViolationSeverity::Info && v.message.contains("approved")),
1042 "PQC should report ML-DSA-65 as approved"
1043 );
1044 }
1045
1046 fn refs_for(rule_id: &'static str) -> Vec<StandardRef> {
1047 let v = Violation {
1048 severity: ViolationSeverity::Warning,
1049 category: ViolationCategory::DocumentMetadata,
1050 message: String::new(),
1051 element: None,
1052 requirement: String::new(),
1053 rule_id,
1054 standard_refs: Vec::new(),
1055 };
1056 v.registry_standard_refs()
1057 }
1058
1059 #[test]
1060 fn registry_refs_for_art_13_4_include_article_and_pren() {
1061 let refs = refs_for("SBOM-CRA-ART-13-4");
1062 assert!(
1063 refs.iter()
1064 .any(|r| r.standard == StandardKind::CraArticle && r.id == "Art. 13(4)"),
1065 "expected CRA Art. 13(4); got {refs:?}"
1066 );
1067 assert!(
1068 refs.iter()
1069 .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-04"),
1070 "expected prEN PRE-7-RQ-04; got {refs:?}"
1071 );
1072 }
1073
1074 #[test]
1075 fn registry_refs_for_annex_i_identifier_include_pren_07() {
1076 let refs = refs_for("SBOM-CRA-ANNEX-I-IDENTIFIER");
1077 assert!(
1078 refs.iter()
1079 .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-07"),
1080 "expected PRE-7-RQ-07; got {refs:?}"
1081 );
1082 let pren_count = refs
1083 .iter()
1084 .filter(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-07")
1085 .count();
1086 assert_eq!(pren_count, 1, "PRE-7-RQ-07 should appear exactly once");
1087 }
1088
1089 #[test]
1090 fn registry_refs_for_supply_chain_include_annex_and_pren() {
1091 let refs = refs_for("SBOM-CRA-ANNEX-I-SUPPLY-CHAIN");
1092 assert!(
1093 refs.iter()
1094 .any(|r| r.standard == StandardKind::CraAnnex && r.id == "Annex I Part III"),
1095 "expected Annex I Part III; got {refs:?}"
1096 );
1097 assert!(
1098 refs.iter()
1099 .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-01"),
1100 "expected PRE-7-RQ-01; got {refs:?}"
1101 );
1102 assert!(
1103 refs.iter()
1104 .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-03"),
1105 "expected PRE-7-RQ-03; got {refs:?}"
1106 );
1107 }
1108
1109 #[test]
1110 fn registry_refs_for_art_13_7_include_pren_rls() {
1111 let refs = refs_for("SBOM-CRA-ART-13-7");
1112 assert!(
1113 refs.iter()
1114 .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "RLS-2-RQ-03-RE"),
1115 "expected RLS-2-RQ-03-RE; got {refs:?}"
1116 );
1117 }
1118
1119 #[test]
1120 fn registry_refs_for_ssdf_ps2() {
1121 let refs = refs_for("SBOM-SSDF-PS2");
1122 assert!(
1123 refs.iter()
1124 .any(|r| r.standard == StandardKind::NistSsdf && r.id == "PS.2"),
1125 "expected NIST SSDF PS.2; got {refs:?}"
1126 );
1127 }
1128
1129 #[test]
1133 fn every_emitted_violation_has_a_registered_rule_id() {
1134 let sbom = NormalizedSbom::default();
1135 for level in ComplianceLevel::all() {
1136 let result = ComplianceChecker::new(*level).check(&sbom);
1137 for v in &result.violations {
1138 assert!(
1139 rule_meta(v.rule_id).is_some(),
1140 "level {level:?}: violation {:?} has unregistered rule_id {:?}",
1141 v.requirement,
1142 v.rule_id
1143 );
1144 }
1145 }
1146 }
1147
1148 #[test]
1149 fn check_populates_standard_refs_for_cra_violations() {
1150 let sbom = NormalizedSbom::default();
1151 let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
1152 let result = checker.check(&sbom);
1153 let cra_violations: Vec<_> = result
1154 .violations
1155 .iter()
1156 .filter(|v| v.requirement.to_lowercase().contains("cra"))
1157 .collect();
1158 assert!(
1159 !cra_violations.is_empty(),
1160 "empty SBOM should produce some CRA violations"
1161 );
1162 for v in &cra_violations {
1163 assert!(
1164 !v.standard_refs.is_empty(),
1165 "CRA violation {:?} should have standard_refs populated",
1166 v.requirement
1167 );
1168 }
1169 }
1170
1171 #[test]
1172 fn sidecar_supplies_security_contact_downgrades_art_13_6() {
1173 use crate::model::CraSidecarMetadata;
1174 let sbom = NormalizedSbom::default();
1175
1176 let bare = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1178 let art_13_6_warning = bare.violations.iter().find(|v| {
1179 v.requirement.contains("Art. 13(6)") && v.severity == ViolationSeverity::Warning
1180 });
1181 assert!(
1182 art_13_6_warning.is_some(),
1183 "Without sidecar, Art. 13(6) should be a Warning"
1184 );
1185
1186 let sidecar = CraSidecarMetadata {
1188 security_contact: Some("security@example.com".to_string()),
1189 ..Default::default()
1190 };
1191 let withsc = ComplianceChecker::new(ComplianceLevel::CraPhase2)
1192 .with_sidecar(sidecar)
1193 .check(&sbom);
1194 let art_13_6_info = withsc.violations.iter().find(|v| {
1195 v.requirement.contains("Art. 13(6)") && v.severity == ViolationSeverity::Info
1196 });
1197 assert!(
1198 art_13_6_info.is_some(),
1199 "With sidecar, Art. 13(6) should be downgraded to Info"
1200 );
1201 assert!(
1202 !withsc
1203 .violations
1204 .iter()
1205 .any(|v| v.requirement.contains("Art. 13(6)")
1206 && v.severity == ViolationSeverity::Warning),
1207 "With sidecar, no Warning-level Art. 13(6) violation should remain"
1208 );
1209 }
1210
1211 #[test]
1212 fn sidecar_supplies_product_name_downgrades_art_13_12() {
1213 use crate::model::CraSidecarMetadata;
1214 let sbom = NormalizedSbom::default(); let sidecar = CraSidecarMetadata {
1217 product_name: Some("Demo Product".to_string()),
1218 ..Default::default()
1219 };
1220 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
1221 .with_sidecar(sidecar)
1222 .check(&sbom);
1223 let downgraded = result.violations.iter().find(|v| {
1224 v.requirement.contains("Art. 13(12)") && v.severity == ViolationSeverity::Info
1225 });
1226 assert!(
1227 downgraded.is_some(),
1228 "Sidecar product_name should downgrade Art. 13(12) to Info"
1229 );
1230 }
1231
1232 #[test]
1233 fn sidecar_supplies_manufacturer_downgrades_art_13_15() {
1234 use crate::model::CraSidecarMetadata;
1235 let sbom = NormalizedSbom::default();
1236 let sidecar = CraSidecarMetadata {
1237 manufacturer_name: Some("Demo Corp".to_string()),
1238 ..Default::default()
1239 };
1240 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
1241 .with_sidecar(sidecar)
1242 .check(&sbom);
1243 let downgraded = result.violations.iter().find(|v| {
1244 v.requirement.contains("Art. 13(15)") && v.severity == ViolationSeverity::Info
1245 });
1246 assert!(
1247 downgraded.is_some(),
1248 "Sidecar manufacturer_name should downgrade Art. 13(15) to Info"
1249 );
1250 }
1251
1252 #[test]
1253 fn sidecar_supplies_cvd_url_downgrades_art_13_7() {
1254 use crate::model::CraSidecarMetadata;
1255 let sbom = NormalizedSbom::default();
1256 let sidecar = CraSidecarMetadata {
1257 vulnerability_disclosure_url: Some("https://example.com/security".to_string()),
1258 ..Default::default()
1259 };
1260 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
1261 .with_sidecar(sidecar)
1262 .check(&sbom);
1263 let downgraded = result.violations.iter().find(|v| {
1264 v.requirement.contains("Art. 13(7)") && v.severity == ViolationSeverity::Info
1265 });
1266 assert!(
1267 downgraded.is_some(),
1268 "Sidecar CVD URL should downgrade Art. 13(7) to Info"
1269 );
1270 }
1271
1272 fn vendor_component(name: &str, with_hash: bool) -> crate::model::Component {
1273 use crate::model::{Component, Hash, HashAlgorithm, Organization};
1274 let mut c = Component::new(name.to_string(), name.to_string())
1275 .with_purl(format!("pkg:cargo/{name}@1.0.0"));
1276 c.supplier = Some(Organization::new("VendorCorp".to_string()));
1277 if with_hash {
1278 c.hashes.push(Hash::new(
1279 HashAlgorithm::Sha256,
1280 "0000000000000000000000000000000000000000000000000000000000000000".to_string(),
1281 ));
1282 }
1283 c
1284 }
1285
1286 fn hw_component(
1287 name: &str,
1288 kind: crate::model::ComponentType,
1289 with_purl: bool,
1290 with_supplier: bool,
1291 version: Option<&str>,
1292 ) -> crate::model::Component {
1293 use crate::model::{Component, Organization};
1294 let mut c = Component::new(name.to_string(), name.to_string());
1295 c.component_type = kind;
1296 if with_purl {
1297 c = c.with_purl(format!("pkg:generic/{name}"));
1298 }
1299 if with_supplier {
1300 c.supplier = Some(Organization::new("HardwareCorp".to_string()));
1301 }
1302 if let Some(v) = version {
1303 c = c.with_version(v.to_string());
1304 }
1305 c
1306 }
1307
1308 #[test]
1309 fn hardware_check_skipped_for_software_only_sbom() {
1310 let mut sbom = NormalizedSbom::default();
1311 let c = vendor_component("software", true);
1312 sbom.components.insert(c.canonical_id.clone(), c);
1313 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1314 assert!(
1315 !result
1316 .violations
1317 .iter()
1318 .any(|v| v.requirement.contains("PRE-8-RQ-02")),
1319 "Software-only SBOM should produce no PRE-8-RQ-02 violations"
1320 );
1321 }
1322
1323 #[test]
1324 fn hardware_check_passes_for_complete_firmware() {
1325 use crate::model::ComponentType;
1326 let mut sbom = NormalizedSbom::default();
1327 let c = hw_component(
1328 "router-fw",
1329 ComponentType::Firmware,
1330 true,
1331 true,
1332 Some("1.2.3"),
1333 );
1334 sbom.components.insert(c.canonical_id.clone(), c);
1335 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1336 assert!(
1337 !result
1338 .violations
1339 .iter()
1340 .any(|v| v.requirement.contains("PRE-8-RQ-02")),
1341 "Complete firmware component should pass [PRE-8-RQ-02]"
1342 );
1343 }
1344
1345 #[test]
1346 fn hardware_check_flags_firmware_without_version() {
1347 use crate::model::ComponentType;
1348 let mut sbom = NormalizedSbom::default();
1349 let c = hw_component("router-fw", ComponentType::Firmware, true, true, None);
1350 sbom.components.insert(c.canonical_id.clone(), c);
1351 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1352 assert!(
1353 result.violations.iter().any(|v| {
1354 v.requirement.contains("Firmware version") && v.severity == ViolationSeverity::Error
1355 }),
1356 "Firmware without version should produce an Error"
1357 );
1358 }
1359
1360 #[test]
1361 fn hardware_check_flags_missing_producer() {
1362 use crate::model::ComponentType;
1363 let mut sbom = NormalizedSbom::default();
1364 let c = hw_component("router", ComponentType::Device, true, false, Some("1.0"));
1365 sbom.components.insert(c.canonical_id.clone(), c);
1366 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1367 assert!(
1368 result.violations.iter().any(|v| {
1369 v.requirement.contains("Hardware producer")
1370 && v.severity == ViolationSeverity::Error
1371 }),
1372 "Hardware without producer should produce an Error"
1373 );
1374 }
1375
1376 #[test]
1377 fn hardware_check_flags_synthetic_identifier() {
1378 use crate::model::{Component, ComponentType, Organization};
1379 let mut sbom = NormalizedSbom::default();
1380 let mut c = Component::new("router".to_string(), "router".to_string())
1381 .with_version("1.0".to_string());
1382 c.component_type = ComponentType::Device;
1383 c.supplier = Some(Organization::new("HardwareCorp".to_string()));
1384 sbom.components.insert(c.canonical_id.clone(), c);
1386 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1387 assert!(
1388 result.violations.iter().any(|v| {
1389 v.requirement.contains("Hardware identifier")
1390 && v.severity == ViolationSeverity::Error
1391 }),
1392 "Hardware with synthetic ID should produce an Error"
1393 );
1394 }
1395
1396 #[test]
1397 fn hardware_check_device_with_firmware_dep_passes() {
1398 use crate::model::{ComponentType, DependencyEdge, DependencyType};
1399 let mut sbom = NormalizedSbom::default();
1400 let device = hw_component("router", ComponentType::Device, true, true, None);
1401 let firmware = hw_component(
1402 "router-fw",
1403 ComponentType::Firmware,
1404 true,
1405 true,
1406 Some("1.2.3"),
1407 );
1408 let device_id = device.canonical_id.clone();
1409 let firmware_id = firmware.canonical_id.clone();
1410 sbom.components.insert(device_id.clone(), device);
1411 sbom.components.insert(firmware_id.clone(), firmware);
1412 sbom.edges.push(DependencyEdge::new(
1413 device_id,
1414 firmware_id,
1415 DependencyType::DependsOn,
1416 ));
1417 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1418 assert!(
1419 !result
1420 .violations
1421 .iter()
1422 .any(|v| { v.requirement.contains("Device firmware association") }),
1423 "Device with firmware dependency should not trigger version warning"
1424 );
1425 }
1426
1427 #[test]
1428 fn vendor_hash_coverage_full() {
1429 use crate::quality::HashQualityMetrics;
1430 let mut sbom = NormalizedSbom::default();
1431 for n in ["a", "b", "c", "d", "e"] {
1432 let c = vendor_component(n, true);
1433 sbom.components.insert(c.canonical_id.clone(), c);
1434 }
1435 let m = HashQualityMetrics::from_sbom(&sbom);
1436 assert_eq!(m.vendor_components_total, 5);
1437 assert_eq!(m.vendor_components_with_hash, 5);
1438 assert_eq!(m.vendor_hash_coverage(), Some(1.0));
1439 }
1440
1441 #[test]
1442 fn vendor_hash_coverage_partial_triggers_warning() {
1443 let mut sbom = NormalizedSbom::default();
1444 for n in ["a", "b", "c", "d", "e", "f", "g"] {
1446 let c = vendor_component(n, true);
1447 sbom.components.insert(c.canonical_id.clone(), c);
1448 }
1449 for n in ["h", "i", "j"] {
1450 let c = vendor_component(n, false);
1451 sbom.components.insert(c.canonical_id.clone(), c);
1452 }
1453 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1454 let v = result.violations.iter().find(|v| {
1455 v.requirement.contains("PRE-7-RQ-07-RE") && v.severity == ViolationSeverity::Warning
1456 });
1457 assert!(
1458 v.is_some(),
1459 "70% vendor-hash coverage should produce a Warning under CraPhase2"
1460 );
1461 }
1462
1463 #[test]
1464 fn vendor_hash_coverage_below_50_triggers_error() {
1465 let mut sbom = NormalizedSbom::default();
1466 for n in ["a", "b", "c", "d"] {
1468 let c = vendor_component(n, true);
1469 sbom.components.insert(c.canonical_id.clone(), c);
1470 }
1471 for n in ["e", "f", "g", "h", "i", "j"] {
1472 let c = vendor_component(n, false);
1473 sbom.components.insert(c.canonical_id.clone(), c);
1474 }
1475 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1476 let v = result.violations.iter().find(|v| {
1477 v.requirement.contains("PRE-7-RQ-07-RE") && v.severity == ViolationSeverity::Error
1478 });
1479 assert!(
1480 v.is_some(),
1481 "40% vendor-hash coverage should produce an Error under CraPhase2"
1482 );
1483 }
1484
1485 #[test]
1486 fn vendor_hash_coverage_no_vendor_components_no_violation() {
1487 let mut sbom = NormalizedSbom::default();
1489 use crate::model::Component;
1490 for n in ["a", "b", "c"] {
1491 let c = Component::new(n.to_string(), n.to_string());
1492 sbom.components.insert(c.canonical_id.clone(), c);
1493 }
1494 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1495 assert!(
1496 !result
1497 .violations
1498 .iter()
1499 .any(|v| v.requirement.contains("PRE-7-RQ-07-RE")),
1500 "No vendor components → no [PRE-7-RQ-07-RE] violation"
1501 );
1502 }
1503
1504 #[test]
1509 fn art_13_2_warns_when_no_risk_assessment_referenced() {
1510 let sbom = NormalizedSbom::default();
1511 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1512 let v = result.violations.iter().find(|v| {
1513 v.requirement.contains("Art. 13(2)") && v.severity == ViolationSeverity::Warning
1514 });
1515 assert!(v.is_some(), "Empty SBOM should produce Art. 13(2) Warning");
1516 }
1517
1518 #[test]
1519 fn art_13_2_silenced_by_sidecar_risk_assessment_url() {
1520 use crate::model::CraSidecarMetadata;
1521 let sbom = NormalizedSbom::default();
1522 let sidecar = CraSidecarMetadata {
1523 risk_assessment_url: Some("https://example.com/ra.pdf".to_string()),
1524 ..Default::default()
1525 };
1526 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
1527 .with_sidecar(sidecar)
1528 .check(&sbom);
1529 assert!(
1530 !result
1531 .violations
1532 .iter()
1533 .any(|v| v.requirement.contains("Art. 13(2)")),
1534 "Sidecar risk_assessment_url should suppress Art. 13(2) violation"
1535 );
1536 }
1537
1538 #[test]
1539 fn article_14_pre_deadline_emits_info_only() {
1540 let sbom = NormalizedSbom::default();
1545 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1546 let art14_count = result
1547 .violations
1548 .iter()
1549 .filter(|v| v.requirement.contains("Art. 14"))
1550 .count();
1551 assert!(
1552 art14_count >= 4,
1553 "Art. 14 readiness should produce ≥4 violations (PSIRT, 14(1), 14(2), 14(7)); got {art14_count}"
1554 );
1555 }
1556
1557 #[test]
1561 fn article_14_pre_deadline_mocked_clock_emits_4_infos() {
1562 let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
1563 let mut violations = Vec::new();
1564 let now = chrono::DateTime::parse_from_rfc3339("2026-04-26T00:00:00Z")
1565 .unwrap()
1566 .with_timezone(&chrono::Utc);
1567 checker.check_article_14_readiness_at(now, &mut violations);
1568
1569 let infos = violations
1570 .iter()
1571 .filter(|v| v.severity == ViolationSeverity::Info && v.requirement.contains("Art. 14"))
1572 .count();
1573 let warnings = violations
1574 .iter()
1575 .filter(|v| {
1576 v.severity == ViolationSeverity::Warning && v.requirement.contains("Art. 14")
1577 })
1578 .count();
1579 assert_eq!(
1580 infos, 4,
1581 "Pre-deadline expects 4 Info-level Art. 14 findings; got {infos} (full list: {violations:?})"
1582 );
1583 assert_eq!(
1584 warnings, 0,
1585 "Pre-deadline expects 0 Warning-level Art. 14 findings"
1586 );
1587 }
1588
1589 #[test]
1593 fn article_14_post_deadline_mocked_clock_emits_3_warnings_1_info() {
1594 let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
1595 let mut violations = Vec::new();
1596 let now = chrono::DateTime::parse_from_rfc3339("2026-12-01T00:00:00Z")
1597 .unwrap()
1598 .with_timezone(&chrono::Utc);
1599 checker.check_article_14_readiness_at(now, &mut violations);
1600
1601 let infos = violations
1602 .iter()
1603 .filter(|v| v.severity == ViolationSeverity::Info && v.requirement.contains("Art. 14"))
1604 .count();
1605 let warnings = violations
1606 .iter()
1607 .filter(|v| {
1608 v.severity == ViolationSeverity::Warning && v.requirement.contains("Art. 14")
1609 })
1610 .count();
1611 assert_eq!(
1612 warnings, 3,
1613 "Post-deadline expects 3 Warning-level Art. 14 findings (PSIRT/14(1)/14(2)); got {warnings} (full: {violations:?})"
1614 );
1615 assert_eq!(
1616 infos, 1,
1617 "Post-deadline expects 1 Info-level Art. 14 finding (Art. 14(7) ENISA platform stays Info regardless of date)"
1618 );
1619 }
1620
1621 #[test]
1622 fn article_14_sidecar_suppresses_psirt_warning() {
1623 use crate::model::CraSidecarMetadata;
1624 let sbom = NormalizedSbom::default();
1625 let sidecar = CraSidecarMetadata {
1626 psirt_url: Some("https://example.com/psirt".to_string()),
1627 early_warning_contact: Some("psirt@example.com".to_string()),
1628 incident_report_contact: Some("ir@example.com".to_string()),
1629 ..Default::default()
1630 };
1631 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
1632 .with_sidecar(sidecar)
1633 .check(&sbom);
1634 let art_14_psirt = result
1636 .violations
1637 .iter()
1638 .any(|v| v.requirement.contains("Art. 14: PSIRT"));
1639 let art_14_1 = result
1640 .violations
1641 .iter()
1642 .any(|v| v.requirement.contains("Art. 14(1)"));
1643 let art_14_2 = result
1644 .violations
1645 .iter()
1646 .any(|v| v.requirement.contains("Art. 14(2)"));
1647 assert!(
1648 !art_14_psirt,
1649 "Sidecar psirt_url should suppress PSIRT check"
1650 );
1651 assert!(
1652 !art_14_1,
1653 "Sidecar early_warning_contact should suppress 14(1)"
1654 );
1655 assert!(
1656 !art_14_2,
1657 "Sidecar incident_report_contact should suppress 14(2)"
1658 );
1659 }
1660
1661 #[test]
1662 fn direct_dep_missing_supplier_is_error_under_cra_phase2() {
1663 use crate::model::{Component, DependencyEdge, DependencyType};
1664 let mut sbom = NormalizedSbom::default();
1665 let app = Component::new("app".to_string(), "app".to_string())
1667 .with_purl("pkg:cargo/app@1.0".to_string());
1668 let lib = Component::new("lib".to_string(), "lib".to_string())
1669 .with_purl("pkg:cargo/lib@1.0".to_string());
1670 let app_id = app.canonical_id.clone();
1671 let lib_id = lib.canonical_id.clone();
1672 sbom.primary_component_id = Some(app_id.clone());
1673 sbom.components.insert(app_id.clone(), app);
1674 sbom.components.insert(lib_id.clone(), lib);
1675 sbom.edges.push(DependencyEdge::new(
1676 app_id,
1677 lib_id,
1678 DependencyType::DependsOn,
1679 ));
1680 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1681 let v = result.violations.iter().find(|v| {
1682 v.requirement.contains("Direct dependency supplier")
1683 && v.severity == ViolationSeverity::Error
1684 });
1685 assert!(
1686 v.is_some(),
1687 "Direct dep without supplier should produce an Error under CraPhase2"
1688 );
1689 }
1690
1691 #[test]
1692 fn transitive_dep_missing_supplier_is_softer_than_direct() {
1693 use crate::model::{Component, DependencyEdge, DependencyType, Organization};
1694 let mut sbom = NormalizedSbom::default();
1695 let mut app = Component::new("app".to_string(), "app".to_string())
1697 .with_purl("pkg:cargo/app@1.0".to_string());
1698 app.supplier = Some(Organization::new("AppCorp".to_string()));
1699 let mut lib = Component::new("lib".to_string(), "lib".to_string())
1700 .with_purl("pkg:cargo/lib@1.0".to_string());
1701 lib.supplier = Some(Organization::new("LibCorp".to_string()));
1702 let deep = Component::new("deep".to_string(), "deep".to_string())
1703 .with_purl("pkg:cargo/deep@1.0".to_string());
1704 let app_id = app.canonical_id.clone();
1705 let lib_id = lib.canonical_id.clone();
1706 let deep_id = deep.canonical_id.clone();
1707 sbom.primary_component_id = Some(app_id.clone());
1708 sbom.components.insert(app_id.clone(), app);
1709 sbom.components.insert(lib_id.clone(), lib);
1710 sbom.components.insert(deep_id.clone(), deep);
1711 sbom.edges.push(DependencyEdge::new(
1712 app_id,
1713 lib_id.clone(),
1714 DependencyType::DependsOn,
1715 ));
1716 sbom.edges.push(DependencyEdge::new(
1717 lib_id,
1718 deep_id,
1719 DependencyType::DependsOn,
1720 ));
1721 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
1722 let direct_err = result.violations.iter().any(|v| {
1723 v.requirement.contains("Direct dependency supplier")
1724 && v.severity == ViolationSeverity::Error
1725 });
1726 let transitive = result
1727 .violations
1728 .iter()
1729 .find(|v| v.requirement.contains("Transitive dependency supplier"));
1730 assert!(
1731 !direct_err,
1732 "No direct deps lack a supplier; should not error"
1733 );
1734 assert!(transitive.is_some(), "Transitive dep should be reported");
1735 assert_ne!(
1736 transitive.unwrap().severity,
1737 ViolationSeverity::Error,
1738 "Transitive supplier missing should never be Error (it's recommended, not mandatory)"
1739 );
1740 }
1741
1742 #[test]
1743 fn bsi_tr_03183_2_empty_sbom_emits_errors() {
1744 let sbom = NormalizedSbom::default();
1745 let result = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
1746 assert!(
1747 result
1748 .violations
1749 .iter()
1750 .any(|v| v.requirement.contains("BSI TR-03183-2 §5.1")
1751 && v.severity == ViolationSeverity::Error),
1752 "Empty SBOM should fail BSI §5.1"
1753 );
1754 }
1755
1756 #[test]
1757 fn bsi_tr_03183_2_flags_missing_strong_hash() {
1758 use crate::model::{Component, Hash, HashAlgorithm};
1759 let mut sbom = NormalizedSbom::default();
1760 let mut c = Component::new("lib".to_string(), "lib".to_string())
1761 .with_purl("pkg:cargo/lib@1.0".to_string());
1762 c.hashes.push(Hash::new(HashAlgorithm::Md5, "0".repeat(32)));
1764 sbom.add_component(c);
1765 let result = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
1766 assert!(
1767 result.violations.iter().any(|v| {
1768 v.requirement.contains("BSI TR-03183-2 §5.4")
1769 && v.severity == ViolationSeverity::Error
1770 }),
1771 "Component without SHA-256+ hash should fail BSI §5.4"
1772 );
1773 }
1774
1775 #[test]
1776 fn bsi_tr_03183_2_passes_for_complete_component() {
1777 use crate::model::{
1778 Component, Creator, CreatorType, DependencyEdge, DependencyType, Hash, HashAlgorithm,
1779 LicenseExpression, Organization,
1780 };
1781 let mut sbom = NormalizedSbom::default();
1782 sbom.document.creators.push(Creator {
1783 creator_type: CreatorType::Tool,
1784 name: "sbom-tools".to_string(),
1785 email: None,
1786 });
1787 let mut a = Component::new("a".to_string(), "a".to_string())
1788 .with_purl("pkg:cargo/a@1.0".to_string())
1789 .with_version("1.0".to_string());
1790 a.hashes
1791 .push(Hash::new(HashAlgorithm::Sha256, "f".repeat(64)));
1792 a.supplier = Some(Organization::new("SupplierA".to_string()));
1793 a.licenses
1794 .add_declared(LicenseExpression::new("MIT".to_string()));
1795 let mut b = Component::new("b".to_string(), "b".to_string())
1796 .with_purl("pkg:cargo/b@1.0".to_string())
1797 .with_version("1.0".to_string());
1798 b.hashes
1799 .push(Hash::new(HashAlgorithm::Sha256, "0".repeat(64)));
1800 b.supplier = Some(Organization::new("SupplierB".to_string()));
1801 b.licenses
1802 .add_declared(LicenseExpression::new("MIT".to_string()));
1803 let a_id = a.canonical_id.clone();
1804 let b_id = b.canonical_id.clone();
1805 sbom.components.insert(a_id.clone(), a);
1806 sbom.components.insert(b_id.clone(), b);
1807 sbom.edges
1808 .push(DependencyEdge::new(a_id, b_id, DependencyType::DependsOn));
1809
1810 let result = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
1811 let errors: Vec<_> = result
1812 .violations
1813 .iter()
1814 .filter(|v| v.severity == ViolationSeverity::Error)
1815 .collect();
1816 assert!(
1817 errors.is_empty(),
1818 "Complete BSI-compliant SBOM should produce no Errors; got: {errors:?}"
1819 );
1820 }
1821
1822 #[test]
1823 fn bsi_tr_03183_2_in_compliance_level_all() {
1824 assert_eq!(ComplianceLevel::all().len(), 16);
1825 assert!(ComplianceLevel::all().contains(&ComplianceLevel::BsiTr03183_2));
1826 assert!(ComplianceLevel::all().contains(&ComplianceLevel::CraOssSteward));
1827 assert!(ComplianceLevel::all().contains(&ComplianceLevel::EuccSubstantial));
1828 assert!(ComplianceLevel::all().contains(&ComplianceLevel::EuAiAct));
1829 assert!(ComplianceLevel::all().contains(&ComplianceLevel::BsiSbomForAi));
1830 }
1831
1832 #[test]
1833 fn sidecar_does_not_override_present_sbom_field() {
1834 use crate::model::{CraSidecarMetadata, Creator, CreatorType};
1835 let mut sbom = NormalizedSbom::default();
1836 sbom.document.creators.push(Creator {
1837 creator_type: CreatorType::Organization,
1838 name: "SbomDeclaredCorp".to_string(),
1839 email: None,
1840 });
1841 let sidecar = CraSidecarMetadata {
1842 manufacturer_name: Some("SidecarCorp".to_string()),
1843 ..Default::default()
1844 };
1845 let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
1846 .with_sidecar(sidecar)
1847 .check(&sbom);
1848 assert!(
1850 !result.violations.iter().any(|v| v
1851 .requirement
1852 .contains("Art. 13(15): Manufacturer identification")),
1853 "When SBOM provides manufacturer, no Art. 13(15) violation should be emitted"
1854 );
1855 }
1856}