Skip to main content

sbom_tools/quality/compliance/
mod.rs

1//! SBOM Compliance checking module.
2//!
3//! Validates SBOMs against format requirements and industry standards.
4//!
5//! The public surface ([`ComplianceChecker`], [`ComplianceLevel`],
6//! [`ComplianceResult`], [`Violation`], the rule registry, …) lives here; the
7//! per-standard check logic is split across sibling submodules and dispatched
8//! through the [`StandardChecker`] trait in [`context`].
9
10use 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/// CRA enforcement phase
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33pub enum CraPhase {
34    /// Phase 1: Reporting obligations — deadline 11 December 2027
35    /// Basic SBOM requirements: product/component identification, manufacturer, version, format
36    Phase1,
37    /// Phase 2: Full compliance — deadline 11 December 2029
38    /// Adds: vulnerability metadata, lifecycle/end-of-support, disclosure policy, EU `DoC`
39    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/// Compliance level/profile
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
60#[non_exhaustive]
61pub enum ComplianceLevel {
62    /// Minimum viable SBOM (basic identification)
63    Minimum,
64    /// Standard compliance (recommended fields)
65    Standard,
66    /// NTIA Minimum Elements compliance
67    NtiaMinimum,
68    /// EU CRA Phase 1 — Reporting obligations (deadline: 11 Dec 2027)
69    CraPhase1,
70    /// EU CRA Phase 2 — Full compliance (deadline: 11 Dec 2029)
71    CraPhase2,
72    /// FDA Medical Device SBOM requirements
73    FdaMedicalDevice,
74    /// NIST SP 800-218 Secure Software Development Framework
75    NistSsdf,
76    /// Executive Order 14028 Section 4 — Enhancing Software Supply Chain Security
77    Eo14028,
78    /// NSA CNSA 2.0 — Commercial National Security Algorithm Suite 2.0
79    Cnsa2,
80    /// NIST PQC Readiness — Post-Quantum Cryptography migration (IR 8547 + FIPS 203/204/205)
81    NistPqc,
82    /// BSI TR-03183-2 (German national CRA-aligned SBOM technical guideline).
83    /// Free, ENISA-cited; stricter than NTIA on hashes and identifiers.
84    BsiTr03183_2,
85    /// CRA Article 24 — Open-source software steward profile (lighter
86    /// obligations than CraPhase1/2). SBOM, vulnerability handling process,
87    /// and CVD policy are still required; manufacturer email, EU DoC, and
88    /// conformity-assessment-module gating are NOT.
89    CraOssSteward,
90    /// EUCC Substantial assurance level (Reg. (EU) 2024/482) — reference-only
91    /// profile for Annex IV products. Verifies that the SBOM/sidecar carries
92    /// a Common-Criteria Protection-Profile reference, Target-of-Evaluation
93    /// reference, ITSEF identifier, and a valid-until date. Does not perform
94    /// a Common-Criteria evaluation itself.
95    EuccSubstantial,
96    /// EU AI Act (Regulation (EU) 2024/1689) Annex IV technical-documentation
97    /// READINESS. Maps the Annex IV documentation obligations for high-risk AI
98    /// systems onto the AI-BOM metadata sbom-tools already parses (model card,
99    /// training-data characteristics, validation/testing metrics, limitations,
100    /// energy disclosure). This is a documentation-readiness assessment, not a
101    /// legal-conformity guarantee, and does not classify a system as high-risk.
102    /// Returns N/A for SBOMs with no ML-model or dataset metadata.
103    EuAiAct,
104    /// BSI/G7 "SBOM for AI — Minimum Elements" (Feb 2026) READINESS. Scores an
105    /// AI-BOM element-by-element against the seven clusters (Metadata,
106    /// System-Level, Models, Datasets, Infrastructure, Security, plus the
107    /// document-author elements) of the BSI/G7 minimum-elements guidance, using
108    /// the AI-BOM metadata sbom-tools already parses (model card, training-data
109    /// characteristics, weight hashes with NIST-approved algorithms, dataset
110    /// provenance). This is a minimum-elements *readiness* assessment, not a
111    /// legal-conformity guarantee. Returns N/A for SBOMs with no ML-model or
112    /// dataset metadata.
113    BsiSbomForAi,
114    /// Comprehensive compliance (all recommended fields)
115    Comprehensive,
116}
117
118impl ComplianceLevel {
119    /// Get human-readable name
120    #[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    /// Get compact tab label (max ~8 chars) for terminal display.
143    #[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    /// Get description of what this level checks
166    #[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    /// Get all compliance levels
211    #[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    /// Whether this level is a CRA check. Includes the lighter Article 24
234    /// open-source steward profile, since stewards still operate under the
235    /// regulation (just with reduced obligations).
236    #[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    /// Get CRA phase, if applicable
245    #[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/// Identifies the source standard a `StandardRef` points at.
256///
257/// The CRA harmonised-standard ecosystem references multiple parallel
258/// hierarchies (the regulation itself, the prEN 40000-1-3 horizontal
259/// standard, BSI TR-03183 national guidance) and a violation typically
260/// maps to several at once. Notified bodies will read prEN IDs; auditors
261/// quote regulation articles; engineers prefer BSI sections.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
263#[non_exhaustive]
264pub enum StandardKind {
265    /// EU CRA regulation article (e.g., "Art. 13(4)")
266    CraArticle,
267    /// EU CRA regulation annex (e.g., "Annex I Part II 1")
268    CraAnnex,
269    /// prEN 40000-1-3 normative requirement ID (e.g., "PRE-7-RQ-07")
270    Pren40000_1_3,
271    /// BSI TR-03183-2 section reference
272    BsiTr03183_2,
273    /// NIST SP 800-218 SSDF practice
274    NistSsdf,
275    /// US Executive Order 14028 Section 4
276    Eo14028,
277    /// FDA premarket cybersecurity guidance
278    FdaPremarket,
279    /// NTIA Minimum Elements for an SBOM
280    NtiaMinimum,
281    /// CSAF v2.0 / ISO/IEC 20153:2025 advisory format
282    Csaf2,
283    /// CNSA 2.0 (NSA Commercial National Security Algorithm Suite)
284    Cnsa2,
285    /// NIST Post-Quantum Cryptography (FIPS 203/204/205, SP 800-131A)
286    NistPqc,
287    /// EU AI Act (Regulation (EU) 2024/1689) — Annex IV technical documentation
288    EuAiAct,
289    /// BSI/G7 "SBOM for AI — Minimum Elements" (Feb 2026)
290    BsiSbomForAi,
291    /// Other / unrecognised standard
292    Other,
293}
294
295impl StandardKind {
296    /// Short label for compact display (≤16 chars).
297    #[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/// A reference to a specific clause/requirement in a published standard.
319///
320/// Surfaced in JSON, SARIF, Markdown, and HTML output so that downstream
321/// tooling (notified-body checklists, GRC platforms, internal dashboards)
322/// can map a violation directly to the standards landscape without parsing
323/// the human-readable `requirement` string.
324#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
325pub struct StandardRef {
326    /// Which standard this reference points at
327    pub standard: StandardKind,
328    /// The clause/requirement ID within that standard (e.g., "PRE-7-RQ-07")
329    pub id: String,
330    /// Optional canonical URL anchor for the clause
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub help_uri: Option<String>,
333}
334
335impl StandardRef {
336    /// Construct a `StandardRef` and auto-populate `help_uri` with a stable
337    /// canonical URL for the standard, when one is known. Pass through
338    /// `with_uri()` to override.
339    #[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    /// Stable canonical URL for the standard / regulation that hosts the
359    /// referenced clause. Returns `None` for `Other` (no canonical home) and
360    /// for `Pren40000_1_3` because the draft EN is paywalled and CEN's URLs
361    /// are not stable; CRA-P5.1 will revisit once the standard is finalised.
362    ///
363    /// The returned URL is the *standard's* root, not a per-clause anchor —
364    /// EUR-Lex and most national standards bodies do not publish stable
365    /// per-article fragments. Per-article precision lives in the
366    /// `StandardRef::id` (e.g., "Art. 13(4)") rather than the URL.
367    #[must_use]
368    pub fn canonical_help_uri(self, _id: &str) -> Option<String> {
369        let url = match self {
370            // CRA Regulation (EU) 2024/2847 — EUR-Lex ELI is the canonical home.
371            Self::CraArticle | Self::CraAnnex => {
372                "https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng"
373            }
374            // prEN 40000-1-3 is in development; no stable public URL yet.
375            Self::Pren40000_1_3 => return None,
376            // BSI TR-03183-2 (English landing page).
377            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            // NIST SP 800-218 SSDF — DOI is the most stable handle.
381            Self::NistSsdf => "https://doi.org/10.6028/NIST.SP.800-218",
382            // EO 14028 — Federal Register short-form.
383            Self::Eo14028 => "https://www.federalregister.gov/d/2021-10460",
384            // FDA premarket cybersecurity guidance.
385            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            // NTIA SBOM Minimum Elements report.
389            Self::NtiaMinimum => {
390                "https://www.ntia.doc.gov/files/ntia/publications/sbom_minimum_elements_report.pdf"
391            }
392            // CSAF v2.0 OASIS standard.
393            Self::Csaf2 => "https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html",
394            // CNSA 2.0 fact sheet.
395            Self::Cnsa2 => {
396                "https://media.defense.gov/2022/Sep/07/2003071834/-1/-1/0/CSA_CNSA_2.0_ALGORITHMS_.PDF"
397            }
398            // NIST PQC project landing page.
399            Self::NistPqc => "https://csrc.nist.gov/projects/post-quantum-cryptography",
400            // EU AI Act Regulation (EU) 2024/1689 — EUR-Lex ELI is the canonical home.
401            Self::EuAiAct => "https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng",
402            // BSI/G7 "SBOM for AI — Minimum Elements" — BSI is the publishing body.
403            Self::BsiSbomForAi => "https://www.bsi.bund.de",
404            Self::Other => return None,
405        };
406        Some(url.to_string())
407    }
408}
409
410/// A compliance violation
411#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct Violation {
413    /// Severity: error, warning, info
414    pub severity: ViolationSeverity,
415    /// Category of the violation
416    pub category: ViolationCategory,
417    /// Human-readable message
418    pub message: String,
419    /// Component or element that violated (if applicable)
420    pub element: Option<String>,
421    /// Standard/requirement being violated
422    pub requirement: String,
423    /// Stable internal rule key, set at the check site, indexing into
424    /// [`rule_meta`]. This — not the human-readable message — drives the
425    /// externally-visible SARIF rule ID, the harmonised-standard references,
426    /// and the remediation text. Defaults to `"SBOM-CRA-GENERAL"` for
427    /// violations built outside the checker (e.g., from external config).
428    ///
429    /// Skipped during (de)serialization: it is a `&'static str` runtime index,
430    /// not part of the JSON contract. Round-tripped payloads resolve back to
431    /// the default; `standard_refs` already carries the serialized references.
432    #[serde(skip, default = "default_rule_id")]
433    pub rule_id: &'static str,
434    /// Structured references to harmonised-standard / regulation clauses.
435    ///
436    /// Populated by `ComplianceChecker::check()` from [`Violation::rule_id`]
437    /// via [`rule_meta`]. Empty when a violation's rule maps to no references.
438    #[serde(default, skip_serializing_if = "Vec::is_empty")]
439    pub standard_refs: Vec<StandardRef>,
440}
441
442/// Serde default for [`Violation::rule_id`] when deserializing payloads that
443/// predate the field.
444fn default_rule_id() -> &'static str {
445    "SBOM-CRA-GENERAL"
446}
447
448impl Violation {
449    /// Structured standard references for this violation, looked up from the
450    /// rule registry by [`Violation::rule_id`].
451    ///
452    /// References are returned in registry order — typically the most specific
453    /// harmonised-standard ID first, then the regulation reference. The
454    /// registry, not the human-readable `requirement` string, is the single
455    /// source of truth, so rewording a message can never silently drop a
456    /// prEN/BSI cross-reference.
457    ///
458    /// `ComplianceChecker::check()` calls this once and stores the result in
459    /// `Violation::standard_refs`, so most consumers should read the field
460    /// directly rather than re-deriving.
461    #[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    /// Remediation guidance for this violation, looked up from the rule
474    /// registry by [`Violation::rule_id`].
475    #[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/// Severity of a compliance violation
482#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
483pub enum ViolationSeverity {
484    /// Must be fixed for compliance
485    Error,
486    /// Should be fixed, but not strictly required
487    Warning,
488    /// Informational recommendation
489    Info,
490}
491
492/// Category of compliance violation
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
494pub enum ViolationCategory {
495    /// Document metadata issue
496    DocumentMetadata,
497    /// Component identification issue
498    ComponentIdentification,
499    /// Dependency information issue
500    DependencyInfo,
501    /// License information issue
502    LicenseInfo,
503    /// Supplier information issue
504    SupplierInfo,
505    /// Hash/integrity issue
506    IntegrityInfo,
507    /// Security/vulnerability disclosure info
508    SecurityInfo,
509    /// Format-specific requirement
510    FormatSpecific,
511    /// Cryptographic algorithm/key/protocol issue
512    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    /// Short name suitable for compact table display (max 10 chars).
532    #[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    /// All category variants in display order.
548    #[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/// Result of compliance checking
565#[derive(Debug, Clone, Serialize, Deserialize)]
566pub struct ComplianceResult {
567    /// Overall compliance status
568    pub is_compliant: bool,
569    /// Compliance level checked against
570    pub level: ComplianceLevel,
571    /// All violations found
572    pub violations: Vec<Violation>,
573    /// Error count
574    pub error_count: usize,
575    /// Warning count
576    pub warning_count: usize,
577    /// Info count
578    pub info_count: usize,
579    /// CRA Annex VIII conformity-assessment summary (CRA-P4.3). Populated
580    /// only when the level is a CRA profile *and* a product class has been
581    /// pinned (explicitly or via sidecar). `None` otherwise.
582    #[serde(default, skip_serializing_if = "Option::is_none")]
583    pub conformity_summary: Option<ConformityAssessmentSummary>,
584}
585
586/// Per-route checklist of evidence the CRA Annex VIII conformity-assessment
587/// procedure expects. Surfaced in markdown / HTML / SARIF / TUI reports so
588/// notified bodies and auditors see the route + the missing evidence in
589/// one glance.
590#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct ConformityAssessmentSummary {
592    /// CRA Annex III/IV product class
593    pub product_class: crate::model::CraProductClass,
594    /// Resolved Annex VIII conformity route
595    pub route: crate::model::ConformityRoute,
596    /// Per-evidence checklist entries (≥1 element)
597    pub evidence: Vec<ConformityEvidence>,
598}
599
600/// One row of the conformity-evidence checklist. `satisfied = true` means
601/// the SBOM (or sidecar) carries the expected reference; `false` means it
602/// is missing and the manufacturer should attach it before submitting.
603#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct ConformityEvidence {
605    /// Short label (e.g., "EU Declaration of Conformity")
606    pub label: String,
607    /// Longer description of the evidence
608    pub detail: String,
609    /// Whether the SBOM/sidecar already provides this evidence
610    pub satisfied: bool,
611}
612
613impl ComplianceResult {
614    /// Create a new compliance result
615    #[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    /// Get violations filtered by severity
642    #[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    /// Get violations filtered by category
651    #[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/// Calibration check identifiers for `ComplianceChecker::class_severity()`.
661///
662/// Each variant corresponds to a row in the CRA-P3.2 calibration table —
663/// the severity that a given finding should produce *given* the product
664/// class (Default → Critical) and conformity-assessment route. `None`
665/// from `class_severity()` means "this check is not applicable for the
666/// given class" (typically Default doesn't carry EUCC/attestation
667/// expectations).
668#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
669pub enum ClassCheck {
670    /// Vendor-supplied hash coverage below threshold ([PRE-7-RQ-07-RE]).
671    VendorHashCoverage,
672    /// EOL component present in SBOM.
673    EolComponents,
674    /// Dependency cycles detected.
675    Cycles,
676    /// Annex VII Declaration-of-Conformity reference missing.
677    DocReference,
678    /// EUCC (Common Criteria) reference missing.
679    EuccReference,
680    /// PSIRT URL / 24h / 72h / ENISA channel missing (Art. 14).
681    Psirt,
682    /// Conformity-assessment-module attestation reference missing
683    /// (only meaningful on Module B+C / H / EUCC routes).
684    ModuleAttestation,
685}
686
687/// Compliance checker for SBOMs
688#[derive(Debug, Clone)]
689pub struct ComplianceChecker {
690    /// Compliance level to check
691    level: ComplianceLevel,
692    /// Optional CRA sidecar metadata that supplements the SBOM with
693    /// manufacturer / disclosure / lifecycle fields the SBOM itself doesn't
694    /// carry. When set, document-metadata checks consult the sidecar before
695    /// emitting "missing" violations.
696    sidecar: Option<crate::model::CraSidecarMetadata>,
697    /// Optional CRA Annex III/IV product class. Drives severity calibration
698    /// for `class_severity()` (vendor-hash, EOL, cycles, DoC, EUCC, PSIRT,
699    /// attestation). When `None`, behaves as `CraProductClass::Default`.
700    product_class: Option<crate::model::CraProductClass>,
701}
702
703impl ComplianceChecker {
704    /// Create a new compliance checker
705    #[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    /// Attach CRA sidecar metadata to supplement SBOM-level fields.
715    ///
716    /// Sidecar values are only consulted as fallbacks — fields present in the
717    /// SBOM always take precedence. Used by `validate`, `quality`, and `view`
718    /// CLIs via the `--cra-sidecar` flag (with auto-discovery for adjacent
719    /// `<sbom>.cra.{json,yaml}` files).
720    #[must_use]
721    pub fn with_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
722        self.sidecar = Some(sidecar);
723        self
724    }
725
726    /// Set the CRA Annex III/IV product class explicitly.
727    ///
728    /// Sidecar `productClass` (when set on the attached sidecar) wins over
729    /// this; resolve via [`Self::effective_product_class`].
730    #[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    /// Resolve the effective product class:
737    /// 1. sidecar `productClass` if present,
738    /// 2. otherwise `with_product_class` value,
739    /// 3. otherwise `CraProductClass::Default`.
740    #[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    /// Resolve the effective conformity-assessment route. Falls back to
750    /// `CraProductClass::default_route()` when the sidecar doesn't pin one.
751    #[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    /// CRA-P3.2 calibration table — severity for a given check at the
760    /// effective product class. Returns `None` when the check does not
761    /// apply for that class (e.g., EUCC reference at `Default`).
762    #[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            // Vendor-hash coverage threshold escalation handled by
768            // `vendor_hash_thresholds()`; this row reflects the *severity*
769            // emitted when the threshold is breached.
770            (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    /// Vendor-hash coverage threshold (single-stage) below which a violation
815    /// fires. The severity is `class_severity(VendorHashCoverage)`. Values:
816    /// Default 50%, Important-1 80%, Important-2 80%, Critical 100%.
817    #[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    /// Whether a CRA product class has been explicitly configured (either
828    /// via `with_product_class()` or the attached sidecar). Used by the
829    /// per-check calibration to decide whether to override phase-based
830    /// defaults — when no class is set, existing phase-driven behavior is
831    /// preserved verbatim for backwards compatibility.
832    #[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    /// Check an SBOM for compliance.
843    ///
844    /// Selects the [`StandardChecker`] for the configured level (the seven
845    /// dedicated profiles get their own checker; the rest take the generic
846    /// path), runs it, then back-fills harmonised-standard references from the
847    /// rule registry and attaches the CRA Annex VIII conformity summary when a
848    /// product class has been pinned on a CRA profile.
849    #[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        // Populate harmonised-standard references from the rule registry.
861        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        // Attach the CRA Annex VIII conformity summary when a product class
869        // has been pinned and the level is a CRA profile.
870        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        // Empty SBOM should have at least a creator violation
903        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    /// Exhaustive registry coverage: every rule key emitted by the checker
1130    /// across all compliance levels and a representative fixture set must
1131    /// resolve in [`rule_meta`] — no orphan rules.
1132    #[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        // Without sidecar: Art. 13(6) is a Warning
1177        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        // With sidecar that supplies security_contact: same finding becomes Info
1187        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(); // no document name
1215
1216        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        // Note: no PURL/CPE/SWHID/SWID → falls back to synthetic
1385        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        // 7 with hashes, 3 without → 70% < 80% → Warning under CraPhase2
1445        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        // 4 with hashes, 6 without → 40% < 50% → Error under CraPhase2
1467        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        // SBOM with only synthetic-ID components — no vendor classification, no violation
1488        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    // ──────────────────────────────────────────────────────────────────
1505    // P2 tests
1506    // ──────────────────────────────────────────────────────────────────
1507
1508    #[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        // The check uses the wall clock; today's date in tests will be
1541        // before/after 2026-09-11 depending on when tests run. We assert
1542        // the *existence* of the readiness violations rather than exact
1543        // severity, then verify with-sidecar suppresses.
1544        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    /// Pre-deadline (mocked clock 2026-04-26): all four channels missing.
1558    /// PSIRT/14(1)/14(2) surface as Info; 14(7) (ENISA platform) is always Info.
1559    /// Total: 4 Infos, 0 Warnings, 0 Errors at Art. 14 level.
1560    #[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    /// Post-deadline (mocked clock 2026-12-01): same SBOM-less state, but
1590    /// PSIRT/14(1)/14(2) become Warnings; 14(7) stays Info.
1591    /// Total: 1 Info, 3 Warnings.
1592    #[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        // PSIRT, 14(1), 14(2) suppressed; 14(7) (ENISA platform) remains as Info.
1635        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        // Primary "app" with one direct dep "lib" missing supplier.
1666        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        // app → lib (with supplier) → deep (no supplier)
1696        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        // Add only a weak hash
1763        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        // No Art. 13(15) violation at all because SBOM provides org
1849        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}