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
13// `pub(crate)` so the AI-readiness scorer (quality/scorer.rs) shares the exact
14// ML-component applicability semantics of the AI compliance profiles instead
15// of re-deriving them (and drifting, as the type-only filter once did).
16pub(crate) mod ai_shared;
17mod bsi;
18mod bsi_sbom_for_ai;
19mod cisa2026;
20mod context;
21mod cra;
22mod crypto;
23mod eo14028;
24mod eu_ai_act;
25mod eucc;
26mod fsct;
27mod generic;
28mod pci_dss;
29mod registry;
30mod selector;
31mod shared;
32mod ssdf;
33
34use context::{ComplianceContext, checker_for};
35pub use registry::{
36    CISA2026_SARIF_RULE_IDS, CNSA2_SARIF_RULE_IDS, COMPLIANCE_SARIF_RULE_IDS,
37    EO14028_SARIF_RULE_IDS, FDA_SARIF_RULE_IDS, FSCT_SARIF_RULE_IDS, NTIA_SARIF_RULE_IDS,
38    PCIDSS_SARIF_RULE_IDS, PQC_SARIF_RULE_IDS, RuleMeta, SSDF_SARIF_RULE_IDS, all_rule_ids,
39    rule_meta,
40};
41use registry::{REMEDIATION_GENERIC, lookup_static_rule_id};
42pub use selector::StandardSelector;
43use shared::{
44    has_known_supplier, has_known_value, is_valid_email_format, known_component_name, known_value,
45    manufacturer_scope_components, truncate_list,
46};
47
48/// CRA enforcement phase
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
50pub enum CraPhase {
51    /// Phase 1: Article 14 reporting obligations — apply from 11 September 2026
52    /// (Reg. (EU) 2024/2847 Art. 71(2)). Basic SBOM requirements:
53    /// product/component identification, manufacturer, version, format
54    Phase1,
55    /// Phase 2: full application of the regulation — from 11 December 2027
56    /// (Art. 71(2)). Adds: vulnerability metadata, lifecycle/end-of-support,
57    /// disclosure policy, EU `DoC`
58    Phase2,
59}
60
61impl CraPhase {
62    pub const fn name(self) -> &'static str {
63        match self {
64            Self::Phase1 => "Phase 1 (2026)",
65            Self::Phase2 => "Phase 2 (2027)",
66        }
67    }
68
69    pub const fn deadline(self) -> &'static str {
70        match self {
71            Self::Phase1 => "11 September 2026",
72            Self::Phase2 => "11 December 2027",
73        }
74    }
75}
76
77/// Compliance level/profile
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
79#[non_exhaustive]
80pub enum ComplianceLevel {
81    /// Minimum viable SBOM (basic identification)
82    Minimum,
83    /// Standard compliance (recommended fields)
84    Standard,
85    /// NTIA Minimum Elements compliance
86    NtiaMinimum,
87    /// EU CRA Phase 1 — Art. 14 reporting obligations (apply from 11 Sep 2026)
88    CraPhase1,
89    /// EU CRA Phase 2 — full application of the regulation (from 11 Dec 2027)
90    CraPhase2,
91    /// FDA Medical Device SBOM requirements
92    FdaMedicalDevice,
93    /// NIST SP 800-218 Secure Software Development Framework
94    NistSsdf,
95    /// Executive Order 14028 Section 4 — Enhancing Software Supply Chain Security
96    Eo14028,
97    /// NSA CNSA 2.0 — Commercial National Security Algorithm Suite 2.0
98    Cnsa2,
99    /// NIST PQC Readiness — Post-Quantum Cryptography migration (IR 8547 + FIPS 203/204/205)
100    NistPqc,
101    /// BSI TR-03183-2 v2.1.0 (German national CRA-aligned SBOM technical
102    /// guideline, 2025-08-20). Free, ENISA-cited; stricter than NTIA on
103    /// eligible formats (CycloneDX 1.6+ / SPDX 3.0.1+) and hashes (SHA-512).
104    BsiTr03183_2,
105    /// CRA Article 24 — Open-source software steward profile (lighter
106    /// obligations than CraPhase1/2). SBOM, vulnerability handling process,
107    /// and CVD policy are still required; manufacturer email, EU DoC, and
108    /// conformity-assessment-module gating are NOT.
109    CraOssSteward,
110    /// EUCC Substantial assurance level (Reg. (EU) 2024/482) — reference-only
111    /// profile for Annex IV products. Verifies that the SBOM/sidecar carries
112    /// a Common-Criteria Protection-Profile reference, Target-of-Evaluation
113    /// reference, ITSEF identifier, and a valid-until date. Does not perform
114    /// a Common-Criteria evaluation itself.
115    EuccSubstantial,
116    /// EU AI Act (Regulation (EU) 2024/1689) Annex IV technical-documentation
117    /// READINESS. Maps the Annex IV documentation obligations for high-risk AI
118    /// systems onto the AI-BOM metadata sbom-tools already parses (model card,
119    /// training-data characteristics, validation/testing metrics, limitations,
120    /// energy disclosure). This is a documentation-readiness assessment, not a
121    /// legal-conformity guarantee, and does not classify a system as high-risk.
122    /// Returns N/A for SBOMs with no ML-model or dataset metadata.
123    EuAiAct,
124    /// BSI/G7 "SBOM for AI — Minimum Elements" (final joint G7 guidance, May 2026)
125    /// READINESS. Scores an
126    /// AI-BOM element-by-element against the seven clusters (Metadata,
127    /// System-Level, Models, Datasets, Infrastructure, Security, plus the
128    /// document-author elements) of the BSI/G7 minimum-elements guidance, using
129    /// the AI-BOM metadata sbom-tools already parses (model card, training-data
130    /// characteristics, weight hashes with NIST-approved algorithms, dataset
131    /// provenance). This is a minimum-elements *readiness* assessment, not a
132    /// legal-conformity guarantee. Returns N/A for SBOMs with no ML-model or
133    /// dataset metadata.
134    BsiSbomForAi,
135    /// "2026 Minimum Elements for a Software Bill of Materials (SBOM)" v2.1
136    /// (CISA/NSA/FBI + 15 international partners, July 29, 2026) — the
137    /// finalized successor to the NTIA 2021 Minimum Elements. Adds SBOM
138    /// author signature, generation context, tool name/version, SBOM
139    /// version, component hashes + algorithms, and licenses to the 2021
140    /// data-field floor; renames Supplier Name to Component Producer.
141    /// Non-binding joint guidance (it creates no regulatory requirements).
142    Cisa2026,
143    /// PCI DSS v4.0.1 Requirement 6.3.2 — inventory of bespoke and custom
144    /// software and incorporated third-party components, maintained to
145    /// facilitate vulnerability and patch management (required in
146    /// assessments since 31 March 2025). PCI DSS prescribes no SBOM format;
147    /// a parseable CycloneDX/SPDX document is the industry-consensus
148    /// evidence, so no format gate applies. Companion controls 6.3.1 and
149    /// 11.3.1.1 are covered where the SBOM embeds vulnerability data.
150    PciDss632,
151    /// CISA "Framing Software Component Transparency: Establishing a Common
152    /// Software Bill of Materials (SBOM)", Third Edition (2024-09-03,
153    /// published 2024-10-15). Community-consensus baseline-attribute
154    /// guidance with three maturity tiers mapped onto severities:
155    /// Minimum Expected → Error, Recommended Practice → Warning,
156    /// Aspirational Goal → Info. Non-regulatory; Error here means "fails
157    /// the Minimum Expected tier", not a legal violation.
158    Fsct,
159    /// Comprehensive compliance (all recommended fields)
160    Comprehensive,
161}
162
163impl ComplianceLevel {
164    /// Get human-readable name
165    #[must_use]
166    pub const fn name(&self) -> &'static str {
167        match self {
168            Self::Minimum => "Minimum",
169            Self::Standard => "Standard",
170            Self::NtiaMinimum => "NTIA Minimum Elements",
171            Self::CraPhase1 => "EU CRA Phase 1 (2026)",
172            Self::CraPhase2 => "EU CRA Phase 2 (2027)",
173            Self::FdaMedicalDevice => "FDA Medical Device",
174            Self::NistSsdf => "NIST SSDF (SP 800-218)",
175            Self::Eo14028 => "EO 14028 Section 4",
176            Self::Cnsa2 => "CNSA 2.0",
177            Self::NistPqc => "NIST PQC Readiness",
178            Self::BsiTr03183_2 => "BSI TR-03183-2",
179            Self::CraOssSteward => "CRA OSS Steward (Art. 24)",
180            Self::EuccSubstantial => "EUCC Substantial (Reg. 2024/482)",
181            Self::EuAiAct => "EU AI Act Annex IV Readiness",
182            Self::BsiSbomForAi => "BSI/G7 SBOM-for-AI Minimum Elements Readiness",
183            Self::Cisa2026 => "CISA 2026 Minimum Elements",
184            Self::PciDss632 => "PCI DSS v4.0.1 Req. 6.3.2",
185            Self::Fsct => "CISA Framing Software Component Transparency (3rd ed.)",
186            Self::Comprehensive => "Comprehensive",
187        }
188    }
189
190    /// Get compact tab label (max ~8 chars) for terminal display.
191    #[must_use]
192    pub const fn short_name(&self) -> &'static str {
193        match self {
194            Self::Minimum => "Min",
195            Self::Standard => "Std",
196            Self::NtiaMinimum => "NTIA",
197            Self::CraPhase1 => "CRA-1",
198            Self::CraPhase2 => "CRA-2",
199            Self::FdaMedicalDevice => "FDA",
200            Self::NistSsdf => "SSDF",
201            Self::Eo14028 => "EO14028",
202            Self::Cnsa2 => "CNSA2",
203            Self::NistPqc => "PQC",
204            Self::BsiTr03183_2 => "BSI",
205            Self::CraOssSteward => "OSS",
206            Self::EuccSubstantial => "EUCC",
207            Self::EuAiAct => "AI-Act",
208            Self::BsiSbomForAi => "BSI-AI",
209            Self::Cisa2026 => "CISA26",
210            Self::PciDss632 => "PCI",
211            Self::Fsct => "FSCT",
212            Self::Comprehensive => "Full",
213        }
214    }
215
216    /// Get description of what this level checks
217    #[must_use]
218    pub const fn description(&self) -> &'static str {
219        match self {
220            Self::Minimum => "Basic component identification only",
221            Self::Standard => "Recommended fields for general use",
222            Self::NtiaMinimum => "NTIA minimum elements for software transparency",
223            Self::CraPhase1 => {
224                "CRA reporting obligations — product ID, SBOM format, manufacturer (Art. 14 applies from 11 Sep 2026)"
225            }
226            Self::CraPhase2 => {
227                "Full CRA compliance — adds vulnerability metadata, lifecycle, disclosure (regulation fully applies from 11 Dec 2027)"
228            }
229            Self::FdaMedicalDevice => "FDA premarket submission requirements for medical devices",
230            Self::NistSsdf => {
231                "Secure Software Development Framework — provenance, build integrity, VCS references"
232            }
233            Self::Eo14028 => {
234                "Executive Order 14028 — machine-readable SBOM, auto-generation, supply chain security"
235            }
236            Self::Cnsa2 => {
237                "CNSA 2.0 — AES-256, SHA-384+, ML-KEM-1024, ML-DSA-87, quantum security level 5"
238            }
239            Self::NistPqc => {
240                "NIST PQC — quantum-vulnerable algorithm detection, FIPS 203/204/205, SP 800-131A"
241            }
242            Self::BsiTr03183_2 => {
243                "BSI TR-03183-2 v2.1.0 — German national SBOM guideline (free, ENISA-cited): CycloneDX 1.6+/SPDX 3.0.1+ formats, required creator/timestamp, per-component version/licences/SHA-512 hash"
244            }
245            Self::CraOssSteward => {
246                "CRA Article 24 — Open-source software steward (lighter than full manufacturer obligations): SBOM + CVD policy + vuln-handling required, no DoC/module/manufacturer-email enforcement"
247            }
248            Self::EuccSubstantial => {
249                "EUCC Substantial (Reg. (EU) 2024/482) — reference-only check for Common-Criteria Protection Profile, Target of Evaluation, ITSEF, and certificate valid-until date"
250            }
251            Self::EuAiAct => {
252                "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)"
253            }
254            Self::BsiSbomForAi => {
255                "BSI/G7 SBOM-for-AI Minimum Elements (joint G7 final, May 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)"
256            }
257            Self::Cisa2026 => {
258                "2026 Minimum Elements for an SBOM (CISA et al., July 2026) — successor to NTIA 2021: author (person/org), signature, format name+version, generation context, timestamp, tool name+version, SBOM version, and per-component producer/name/version/identifiers/hash/license/dependencies. Frequency, distribution, and update accommodation are organizational practices with no in-document evidence and carry no rules."
259            }
260            Self::PciDss632 => {
261                "PCI DSS v4.0.1 Req. 6.3.2 software-inventory profile (required in assessments since 31 Mar 2025) — inventory completeness, per-component name/version/supplier/identifier, freshness, and vulnerability-management usability; 6.3.1/11.3.1.1 risk-ranking checks where vulnerability data is embedded. Assessor-side testing procedures (interviews, software comparison) are out of document reach."
262            }
263            Self::Fsct => {
264                "CISA Framing Software Component Transparency, 3rd ed. (2024) — baseline attributes (author, timestamp, primary component, name, version, supplier, identifiers, hashes, relationships, licenses, copyright) across the Minimum Expected (Error) / Recommended Practice (Warning) / Aspirational Goal (Info) maturity tiers"
265            }
266            Self::Comprehensive => "All recommended fields and best practices",
267        }
268    }
269
270    /// Get all compliance levels
271    #[must_use]
272    pub const fn all() -> &'static [Self] {
273        &[
274            Self::Minimum,
275            Self::Standard,
276            Self::NtiaMinimum,
277            Self::CraPhase1,
278            Self::CraPhase2,
279            Self::FdaMedicalDevice,
280            Self::NistSsdf,
281            Self::Eo14028,
282            Self::Cnsa2,
283            Self::NistPqc,
284            Self::BsiTr03183_2,
285            Self::CraOssSteward,
286            Self::EuccSubstantial,
287            Self::EuAiAct,
288            Self::BsiSbomForAi,
289            Self::Cisa2026,
290            Self::PciDss632,
291            Self::Fsct,
292            Self::Comprehensive,
293        ]
294    }
295
296    /// Whether this level is a CRA check. Includes the lighter Article 24
297    /// open-source steward profile, since stewards still operate under the
298    /// regulation (just with reduced obligations).
299    #[must_use]
300    pub const fn is_cra(&self) -> bool {
301        matches!(
302            self,
303            Self::CraPhase1 | Self::CraPhase2 | Self::CraOssSteward
304        )
305    }
306
307    /// Get CRA phase, if applicable
308    #[must_use]
309    pub const fn cra_phase(&self) -> Option<CraPhase> {
310        match self {
311            Self::CraPhase1 => Some(CraPhase::Phase1),
312            Self::CraPhase2 => Some(CraPhase::Phase2),
313            _ => None,
314        }
315    }
316}
317
318/// Identifies the source standard a `StandardRef` points at.
319///
320/// The CRA harmonised-standard ecosystem references multiple parallel
321/// hierarchies (the regulation itself, the prEN 40000-1-3 horizontal
322/// standard, BSI TR-03183 national guidance) and a violation typically
323/// maps to several at once. Notified bodies will read prEN IDs; auditors
324/// quote regulation articles; engineers prefer BSI sections.
325#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
326#[non_exhaustive]
327pub enum StandardKind {
328    /// EU CRA regulation article (e.g., "Art. 13(8)")
329    CraArticle,
330    /// EU CRA regulation annex (e.g., "Annex I Part II 1")
331    CraAnnex,
332    /// prEN 40000-1-3 normative requirement ID (e.g., "PRE-7-RQ-07")
333    Pren40000_1_3,
334    /// BSI TR-03183-2 section reference
335    BsiTr03183_2,
336    /// NIST SP 800-218 SSDF practice
337    NistSsdf,
338    /// US Executive Order 14028 Section 4
339    Eo14028,
340    /// FDA premarket cybersecurity guidance
341    FdaPremarket,
342    /// NTIA Minimum Elements for an SBOM
343    NtiaMinimum,
344    /// CSAF v2.0 / ISO/IEC 20153:2025 advisory format
345    Csaf2,
346    /// CNSA 2.0 (NSA Commercial National Security Algorithm Suite)
347    Cnsa2,
348    /// NIST Post-Quantum Cryptography (FIPS 203/204/205, SP 800-131A)
349    NistPqc,
350    /// EU AI Act (Regulation (EU) 2024/1689) — Annex IV technical documentation
351    EuAiAct,
352    /// BSI/G7 "SBOM for AI — Minimum Elements" (joint G7 final, May 2026)
353    BsiSbomForAi,
354    /// EUCC — European cybersecurity certification scheme on Common Criteria,
355    /// Implementing Regulation (EU) 2024/482
356    Eucc,
357    /// "2026 Minimum Elements for a Software Bill of Materials (SBOM)" v2.1
358    /// (CISA/NSA/FBI et al., July 29, 2026)
359    CisaMinimum2026,
360    /// PCI DSS v4.0.1 (PCI Security Standards Council) — Requirement 6.3.2
361    /// and companion controls
362    PciDss4,
363    /// CISA "Framing Software Component Transparency", Third Edition (2024)
364    CisaFsct,
365    /// Other / unrecognised standard
366    Other,
367}
368
369impl StandardKind {
370    /// Short label for compact display (≤16 chars).
371    #[must_use]
372    pub const fn label(self) -> &'static str {
373        match self {
374            Self::CraArticle => "CRA Article",
375            Self::CraAnnex => "CRA Annex",
376            Self::Pren40000_1_3 => "prEN 40000-1-3",
377            Self::BsiTr03183_2 => "BSI TR-03183-2",
378            Self::NistSsdf => "NIST SSDF",
379            Self::Eo14028 => "EO 14028",
380            Self::FdaPremarket => "FDA",
381            Self::NtiaMinimum => "NTIA",
382            Self::Csaf2 => "CSAF v2.0",
383            Self::Cnsa2 => "CNSA 2.0",
384            Self::NistPqc => "NIST PQC",
385            Self::EuAiAct => "EU AI Act",
386            Self::BsiSbomForAi => "BSI/G7 AI-SBOM",
387            Self::Eucc => "EUCC",
388            Self::CisaMinimum2026 => "CISA 2026",
389            Self::PciDss4 => "PCI DSS v4",
390            Self::CisaFsct => "CISA FSCT 3e",
391            Self::Other => "Other",
392        }
393    }
394}
395
396/// A reference to a specific clause/requirement in a published standard.
397///
398/// Surfaced in JSON, SARIF, Markdown, and HTML output so that downstream
399/// tooling (notified-body checklists, GRC platforms, internal dashboards)
400/// can map a violation directly to the standards landscape without parsing
401/// the human-readable `requirement` string.
402#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
403pub struct StandardRef {
404    /// Which standard this reference points at
405    pub standard: StandardKind,
406    /// The clause/requirement ID within that standard (e.g., "PRE-7-RQ-07")
407    pub id: String,
408    /// Optional canonical URL anchor for the clause
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub help_uri: Option<String>,
411}
412
413impl StandardRef {
414    /// Construct a `StandardRef` and auto-populate `help_uri` with a stable
415    /// canonical URL for the standard, when one is known. Pass through
416    /// `with_uri()` to override.
417    #[must_use]
418    pub fn new(standard: StandardKind, id: impl Into<String>) -> Self {
419        let id = id.into();
420        let help_uri = standard.canonical_help_uri(&id);
421        Self {
422            standard,
423            id,
424            help_uri,
425        }
426    }
427
428    #[must_use]
429    pub fn with_uri(mut self, uri: impl Into<String>) -> Self {
430        self.help_uri = Some(uri.into());
431        self
432    }
433}
434
435impl StandardKind {
436    /// Stable canonical URL for the standard / regulation that hosts the
437    /// referenced clause. Returns `None` for `Other` (no canonical home) and
438    /// for `Pren40000_1_3` because the draft EN is paywalled and CEN's URLs
439    /// are not stable; CRA-P5.1 will revisit once the standard is finalised.
440    ///
441    /// The returned URL is the *standard's* root, not a per-clause anchor —
442    /// EUR-Lex and most national standards bodies do not publish stable
443    /// per-article fragments. Per-article precision lives in the
444    /// `StandardRef::id` (e.g., "Art. 13(8)") rather than the URL.
445    #[must_use]
446    pub fn canonical_help_uri(self, _id: &str) -> Option<String> {
447        let url = match self {
448            // CRA Regulation (EU) 2024/2847 — EUR-Lex ELI is the canonical home.
449            Self::CraArticle | Self::CraAnnex => {
450                "https://eur-lex.europa.eu/eli/reg/2024/2847/oj/eng"
451            }
452            // prEN 40000-1-3 is in development; no stable public URL yet.
453            Self::Pren40000_1_3 => return None,
454            // BSI TR-03183 (BSI's stable English shortlink, printed in the
455            // v2.1.0 document imprint).
456            Self::BsiTr03183_2 => "https://bsi.bund.de/dok/TR-03183-en",
457            // NIST SP 800-218 SSDF — DOI is the most stable handle.
458            Self::NistSsdf => "https://doi.org/10.6028/NIST.SP.800-218",
459            // EO 14028 — Federal Register short-form.
460            Self::Eo14028 => "https://www.federalregister.gov/d/2021-10460",
461            // FDA premarket cybersecurity guidance — current edition is
462            // "Cybersecurity in Medical Devices: Quality Management System
463            // Considerations and Content of Premarket Submissions" (final,
464            // 2026-02-03); FDA's media id is the stable handle.
465            Self::FdaPremarket => "https://www.fda.gov/media/119933/download",
466            // NTIA SBOM Minimum Elements report (canonical host is ntia.gov;
467            // the old ntia.doc.gov URLs only survive via redirect).
468            Self::NtiaMinimum => {
469                "https://www.ntia.gov/report/2021/minimum-elements-software-bill-materials-sbom"
470            }
471            // CSAF v2.0 OASIS standard.
472            Self::Csaf2 => "https://docs.oasis-open.org/csaf/csaf/v2.0/csaf-v2.0.html",
473            // CNSA 2.0 fact sheet.
474            Self::Cnsa2 => {
475                "https://media.defense.gov/2022/Sep/07/2003071834/-1/-1/0/CSA_CNSA_2.0_ALGORITHMS_.PDF"
476            }
477            // NIST PQC project landing page.
478            Self::NistPqc => "https://csrc.nist.gov/projects/post-quantum-cryptography",
479            // EU AI Act Regulation (EU) 2024/1689 — EUR-Lex ELI is the canonical home.
480            Self::EuAiAct => "https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng",
481            // BSI/G7 "SBOM for AI — Minimum Elements" — final joint G7
482            // guidance (2026-05-12); CISA hosts the stable resource page.
483            Self::BsiSbomForAi => {
484                "https://www.cisa.gov/resources-tools/resources/software-bill-materials-ai-minimum-elements"
485            }
486            // EUCC Implementing Regulation (EU) 2024/482 — EUR-Lex ELI is the
487            // canonical home.
488            Self::Eucc => "https://eur-lex.europa.eu/eli/reg_impl/2024/482/oj/eng",
489            // 2026 Minimum Elements for an SBOM — CISA's resource page is the
490            // stable handle (the PDF path under /sites/default/files churns).
491            Self::CisaMinimum2026 => {
492                "https://www.cisa.gov/resources-tools/resources/2026-minimum-elements-software-bill-materials-sbom"
493            }
494            // PCI DSS v4.0.1 — the standard PDF is license-gated behind a
495            // click-through; the document library is the stable public home.
496            Self::PciDss4 => "https://www.pcisecuritystandards.org/document_library/",
497            // CISA Framing Software Component Transparency (3rd ed., 2024) —
498            // resource page rather than the version-pinned PDF path.
499            Self::CisaFsct => {
500                "https://www.cisa.gov/resources-tools/resources/framing-software-component-transparency-2024"
501            }
502            Self::Other => return None,
503        };
504        Some(url.to_string())
505    }
506}
507
508/// A compliance violation
509#[derive(Debug, Clone)]
510pub struct Violation {
511    /// Severity: error, warning, info
512    pub severity: ViolationSeverity,
513    /// Category of the violation
514    pub category: ViolationCategory,
515    /// Human-readable message
516    pub message: String,
517    /// Component or element that violated (if applicable).
518    ///
519    /// This is a human-readable *label* (usually the component name, sometimes
520    /// a format id) and is not unique across versions or duplicate names — use
521    /// [`Violation::component_id`] as the machine-readable join key.
522    pub element: Option<String>,
523    /// Canonical id of the offending component, when component-scoped.
524    ///
525    /// Unlike [`Violation::element`], this is a stable join key back to the
526    /// SBOM component (the component's `CanonicalId`), so machine consumers
527    /// can reliably correlate findings with components. `None` for
528    /// document-level and aggregate findings.
529    ///
530    /// Serialized as `component_id`, omitted when `None`; old payloads
531    /// without the field deserialize to `None`.
532    pub component_id: Option<String>,
533    /// Affected/total component counts for aggregate findings.
534    ///
535    /// Mirrors the numbers embedded in the human-readable `message`
536    /// (e.g. "7/10 components (70%)…") in structured form. `None` for
537    /// non-aggregate findings.
538    ///
539    /// Serialized as `counts`, omitted when `None`; old payloads without the
540    /// field deserialize to `None`.
541    pub counts: Option<ViolationCounts>,
542    /// Standard/requirement being violated
543    pub requirement: String,
544    /// Stable internal rule key, set at the check site, indexing into
545    /// [`rule_meta`]. This — not the human-readable message — drives the
546    /// externally-visible SARIF rule ID, the harmonised-standard references,
547    /// and the remediation text. Defaults to `"SBOM-CRA-GENERAL"` for
548    /// violations built outside the checker (e.g., from external config).
549    ///
550    /// Serialized as `rule_id` (alongside a derived `sarif_rule_id`; see the
551    /// manual [`Serialize`] impl) so machine consumers get a stable rule key
552    /// instead of regexing the message. Deserialization maps a known id back
553    /// to its registry `&'static str` key and falls back to the generic
554    /// default for unknown ids or old payloads without the field.
555    pub rule_id: &'static str,
556    /// Structured references to harmonised-standard / regulation clauses.
557    ///
558    /// Populated by `ComplianceChecker::check()` from [`Violation::rule_id`]
559    /// via [`rule_meta`]. Empty when a violation's rule maps to no references.
560    pub standard_refs: Vec<StandardRef>,
561}
562
563/// Affected/total component counts carried by aggregate findings.
564///
565/// Structured mirror of the coverage numbers that aggregate checks embed in
566/// the human-readable message (e.g. "7/10 components (70%)…"), so machine
567/// consumers can extract them without regexing the message.
568#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
569pub struct ViolationCounts {
570    /// Numerator as printed in the message — usually the number of offending
571    /// components; for coverage-style findings ("only X/Y carry …") it is the
572    /// covered count, exactly as the message states it.
573    pub affected: usize,
574    /// Total number of components considered (the message's denominator).
575    pub total: usize,
576}
577
578/// Serde default for [`Violation::rule_id`] when deserializing payloads that
579/// predate the field.
580fn default_rule_id() -> &'static str {
581    "SBOM-CRA-GENERAL"
582}
583
584/// Owned mirror of [`Violation`] used for deserialization: `rule_id` arrives
585/// as an owned string (or is absent in old payloads) and is mapped back to
586/// its registry `&'static str` key. A derived `Deserialize` directly on
587/// [`Violation`] would implicitly borrow the `&'static str` field from the
588/// input (`'de: 'static`), which does not hold for streamed payloads.
589#[derive(Deserialize)]
590struct ViolationPayload {
591    severity: ViolationSeverity,
592    category: ViolationCategory,
593    message: String,
594    element: Option<String>,
595    #[serde(default)]
596    component_id: Option<String>,
597    #[serde(default)]
598    counts: Option<ViolationCounts>,
599    requirement: String,
600    #[serde(default)]
601    rule_id: Option<String>,
602    #[serde(default)]
603    standard_refs: Vec<StandardRef>,
604}
605
606impl<'de> Deserialize<'de> for Violation {
607    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
608    where
609        D: serde::Deserializer<'de>,
610    {
611        let payload = ViolationPayload::deserialize(deserializer)?;
612        Ok(Self {
613            severity: payload.severity,
614            category: payload.category,
615            message: payload.message,
616            element: payload.element,
617            component_id: payload.component_id,
618            counts: payload.counts,
619            requirement: payload.requirement,
620            // Unknown ids (from newer/older registries) collapse to the
621            // generic default rather than failing deserialization; the
622            // serialized `sarif_rule_id` companion field is derived, so it
623            // is ignored here.
624            rule_id: payload
625                .rule_id
626                .as_deref()
627                .and_then(lookup_static_rule_id)
628                .unwrap_or_else(default_rule_id),
629            standard_refs: payload.standard_refs,
630        })
631    }
632}
633
634impl Serialize for Violation {
635    /// Manual impl so the JSON carries both the stable internal `rule_id` and
636    /// the externally-visible `sarif_rule_id` derived from the rule registry
637    /// (never stored, so it cannot drift). Field order mirrors declaration
638    /// order; `standard_refs` is skipped when empty, and `component_id` /
639    /// `counts` are skipped when `None`, as their serde-derive
640    /// `skip_serializing_if = "Option::is_none"` equivalents would be.
641    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
642    where
643        S: serde::Serializer,
644    {
645        use serde::ser::SerializeStruct;
646        let has_refs = !self.standard_refs.is_empty();
647        let has_component_id = self.component_id.is_some();
648        let has_counts = self.counts.is_some();
649        let mut state = serializer.serialize_struct(
650            "Violation",
651            7 + usize::from(has_refs) + usize::from(has_component_id) + usize::from(has_counts),
652        )?;
653        state.serialize_field("severity", &self.severity)?;
654        state.serialize_field("category", &self.category)?;
655        state.serialize_field("message", &self.message)?;
656        state.serialize_field("element", &self.element)?;
657        if has_component_id {
658            state.serialize_field("component_id", &self.component_id)?;
659        } else {
660            state.skip_field("component_id")?;
661        }
662        if has_counts {
663            state.serialize_field("counts", &self.counts)?;
664        } else {
665            state.skip_field("counts")?;
666        }
667        state.serialize_field("requirement", &self.requirement)?;
668        state.serialize_field("rule_id", self.rule_id)?;
669        // Same registry lookup + fallback as the SARIF renderer, so the same
670        // violation carries the same external rule id on every surface.
671        state.serialize_field("sarif_rule_id", self.sarif_rule_id())?;
672        if has_refs {
673            state.serialize_field("standard_refs", &self.standard_refs)?;
674        } else {
675            state.skip_field("standard_refs")?;
676        }
677        state.end()
678    }
679}
680
681impl Violation {
682    /// Structured standard references for this violation, looked up from the
683    /// rule registry by [`Violation::rule_id`].
684    ///
685    /// References are returned in registry order — typically the most specific
686    /// harmonised-standard ID first, then the regulation reference. The
687    /// registry, not the human-readable `requirement` string, is the single
688    /// source of truth, so rewording a message can never silently drop a
689    /// prEN/BSI cross-reference.
690    ///
691    /// `ComplianceChecker::check()` calls this once and stores the result in
692    /// `Violation::standard_refs`, so most consumers should read the field
693    /// directly rather than re-deriving.
694    #[must_use]
695    pub fn registry_standard_refs(&self) -> Vec<StandardRef> {
696        rule_meta(self.rule_id)
697            .map(|m| {
698                m.refs
699                    .iter()
700                    .map(|(kind, id)| StandardRef::new(*kind, *id))
701                    .collect()
702            })
703            .unwrap_or_default()
704    }
705
706    /// Remediation guidance for this violation, looked up from the rule
707    /// registry by [`Violation::rule_id`].
708    #[must_use]
709    pub fn remediation_guidance(&self) -> &'static str {
710        rule_meta(self.rule_id).map_or(REMEDIATION_GENERIC, |m| m.remediation)
711    }
712
713    /// Externally-visible SARIF rule id for this violation, looked up from
714    /// the rule registry by [`Violation::rule_id`]. Check sites stamp a
715    /// family-correct generic id (via [`generic_rule_id_for_level`]) when no
716    /// specific rule applies, so registered keys — i.e. every violation the
717    /// checkers produce — serialize identically in JSON and SARIF. Truly
718    /// unregistered keys (violations built outside the checkers, e.g. from
719    /// external config) fall back to the generic CRA rule here; the SARIF
720    /// renderer additionally re-buckets those by standard family, which is
721    /// the one residual place the two outputs can differ.
722    #[must_use]
723    pub fn sarif_rule_id(&self) -> &'static str {
724        rule_meta(self.rule_id).map_or("SBOM-CRA-GENERAL", |m| m.sarif_id)
725    }
726}
727
728/// Family-generic rule for a compliance standard: the id a check site (or
729/// the SARIF renderer) falls back to when a finding has no specific registry
730/// mapping. Every returned id is a registered self-descriptor
731/// (`rule_meta(id).sarif_id == id`), so rule catalogues always declare it
732/// with registry metadata.
733#[must_use]
734pub const fn generic_rule_id_for_level(level: ComplianceLevel) -> &'static str {
735    match level {
736        ComplianceLevel::Minimum | ComplianceLevel::Standard | ComplianceLevel::Comprehensive => {
737            "SBOM-QUALITY-GENERAL"
738        }
739        ComplianceLevel::NtiaMinimum => "SBOM-NTIA-GENERAL",
740        ComplianceLevel::CraPhase1
741        | ComplianceLevel::CraPhase2
742        | ComplianceLevel::CraOssSteward => "SBOM-CRA-GENERAL",
743        ComplianceLevel::FdaMedicalDevice => "SBOM-FDA-GENERAL",
744        ComplianceLevel::NistSsdf => "SBOM-SSDF-GENERAL",
745        ComplianceLevel::Eo14028 => "SBOM-EO14028-GENERAL",
746        ComplianceLevel::Cnsa2 => "SBOM-CNSA2-GENERAL",
747        ComplianceLevel::NistPqc => "SBOM-PQC-GENERAL",
748        ComplianceLevel::BsiTr03183_2 => "SBOM-BSI-TR-03183-2-GENERAL",
749        ComplianceLevel::EuccSubstantial => "SBOM-EUCC-GENERAL",
750        ComplianceLevel::EuAiAct => "SBOM-AIACT-GENERAL",
751        ComplianceLevel::BsiSbomForAi => "SBOM-BSIAI-GENERAL",
752        ComplianceLevel::Cisa2026 => "SBOM-CISA2026-GENERAL",
753        ComplianceLevel::PciDss632 => "SBOM-PCI-GENERAL",
754        ComplianceLevel::Fsct => "SBOM-FSCT-GENERAL",
755    }
756}
757
758/// Severity of a compliance violation
759#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
760pub enum ViolationSeverity {
761    /// Must be fixed for compliance
762    Error,
763    /// Should be fixed, but not strictly required
764    Warning,
765    /// Informational recommendation
766    Info,
767}
768
769impl ViolationSeverity {
770    /// Canonical display label, matching the strings the reports layer
771    /// (markdown/HTML violation tables) emits. Renderers should use this
772    /// rather than the `Debug` form so the label can't silently drift.
773    #[must_use]
774    pub const fn name(self) -> &'static str {
775        match self {
776            Self::Error => "Error",
777            Self::Warning => "Warning",
778            Self::Info => "Info",
779        }
780    }
781}
782
783/// Category of compliance violation
784#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
785pub enum ViolationCategory {
786    /// Document metadata issue
787    DocumentMetadata,
788    /// Component identification issue
789    ComponentIdentification,
790    /// Dependency information issue
791    DependencyInfo,
792    /// License information issue
793    LicenseInfo,
794    /// Supplier information issue
795    SupplierInfo,
796    /// Hash/integrity issue
797    IntegrityInfo,
798    /// Security/vulnerability disclosure info
799    SecurityInfo,
800    /// Format-specific requirement
801    FormatSpecific,
802    /// Cryptographic algorithm/key/protocol issue
803    CryptographyInfo,
804}
805
806impl ViolationCategory {
807    #[must_use]
808    pub const fn name(&self) -> &'static str {
809        match self {
810            Self::DocumentMetadata => "Document Metadata",
811            Self::ComponentIdentification => "Component Identification",
812            Self::DependencyInfo => "Dependency Information",
813            Self::LicenseInfo => "License Information",
814            Self::SupplierInfo => "Supplier Information",
815            Self::IntegrityInfo => "Integrity Information",
816            Self::SecurityInfo => "Security Information",
817            Self::FormatSpecific => "Format-Specific",
818            Self::CryptographyInfo => "Cryptography",
819        }
820    }
821
822    /// Short name suitable for compact table display (max 10 chars).
823    #[must_use]
824    pub const fn short_name(&self) -> &'static str {
825        match self {
826            Self::DocumentMetadata => "Doc Meta",
827            Self::ComponentIdentification => "Comp IDs",
828            Self::DependencyInfo => "Deps",
829            Self::LicenseInfo => "License",
830            Self::SupplierInfo => "Supplier",
831            Self::IntegrityInfo => "Integrity",
832            Self::SecurityInfo => "Security",
833            Self::FormatSpecific => "Format",
834            Self::CryptographyInfo => "Crypto",
835        }
836    }
837
838    /// All category variants in display order.
839    #[must_use]
840    pub const fn all() -> &'static [Self] {
841        &[
842            Self::SupplierInfo,
843            Self::ComponentIdentification,
844            Self::DocumentMetadata,
845            Self::IntegrityInfo,
846            Self::LicenseInfo,
847            Self::DependencyInfo,
848            Self::SecurityInfo,
849            Self::FormatSpecific,
850            Self::CryptographyInfo,
851        ]
852    }
853}
854
855/// Whether the checked standard actually evaluated this SBOM.
856///
857/// Readiness profiles (EU AI Act, BSI/G7 SBOM-for-AI) return a single Info
858/// violation and `is_compliant = true` for SBOMs outside their scope; that
859/// contract is kept for compatibility, but consumers must render such runs
860/// as N/A — never as a pass. Old payloads without the field deserialize as
861/// `Applicable`.
862#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
863#[serde(rename_all = "snake_case", tag = "status", content = "reason")]
864pub enum Applicability {
865    /// The standard evaluated the SBOM; `is_compliant` is meaningful.
866    #[default]
867    Applicable,
868    /// The standard did not apply (the string is the human-readable reason).
869    NotApplicable(String),
870}
871
872/// Rule ids whose presence marks a result as not applicable (the readiness
873/// profiles emit exactly one of these, as an Info, for out-of-scope SBOMs).
874const NOT_APPLICABLE_RULES: &[&str] = &["SBOM-AIACT-NA", "SBOM-BSIAI-NA"];
875
876/// Result of compliance checking
877#[derive(Debug, Clone, Serialize, Deserialize)]
878pub struct ComplianceResult {
879    /// Overall compliance status
880    pub is_compliant: bool,
881    /// Compliance level checked against
882    pub level: ComplianceLevel,
883    /// All violations found
884    pub violations: Vec<Violation>,
885    /// Error count
886    pub error_count: usize,
887    /// Warning count
888    pub warning_count: usize,
889    /// Info count
890    pub info_count: usize,
891    /// CRA Annex VIII conformity-assessment summary (CRA-P4.3). Populated
892    /// only when the level is a CRA profile *and* a product class has been
893    /// pinned (explicitly or via sidecar). `None` otherwise.
894    #[serde(default, skip_serializing_if = "Option::is_none")]
895    pub conformity_summary: Option<ConformityAssessmentSummary>,
896    /// Whether the standard actually evaluated this SBOM (see
897    /// [`Applicability`]). `is_compliant` stays `true` for not-applicable
898    /// runs by contract; renderers must show N/A instead of a pass.
899    #[serde(default)]
900    pub applicability: Applicability,
901}
902
903/// Per-route checklist of evidence the CRA Annex VIII conformity-assessment
904/// procedure expects. Surfaced in markdown / HTML / SARIF / TUI reports so
905/// notified bodies and auditors see the route + the missing evidence in
906/// one glance.
907#[derive(Debug, Clone, Serialize, Deserialize)]
908pub struct ConformityAssessmentSummary {
909    /// CRA Annex III/IV product class
910    pub product_class: crate::model::CraProductClass,
911    /// Resolved Annex VIII conformity route
912    pub route: crate::model::ConformityRoute,
913    /// Per-evidence checklist entries (≥1 element)
914    pub evidence: Vec<ConformityEvidence>,
915}
916
917/// One row of the conformity-evidence checklist. `satisfied = true` means
918/// the SBOM (or sidecar) carries the expected reference; `false` means it
919/// is missing and the manufacturer should attach it before submitting.
920#[derive(Debug, Clone, Serialize, Deserialize)]
921pub struct ConformityEvidence {
922    /// Short label (e.g., "EU Declaration of Conformity")
923    pub label: String,
924    /// Longer description of the evidence
925    pub detail: String,
926    /// Whether the SBOM/sidecar already provides this evidence
927    pub satisfied: bool,
928}
929
930impl ComplianceResult {
931    /// Create a new compliance result
932    #[must_use]
933    pub fn new(level: ComplianceLevel, violations: Vec<Violation>) -> Self {
934        let error_count = violations
935            .iter()
936            .filter(|v| v.severity == ViolationSeverity::Error)
937            .count();
938        let warning_count = violations
939            .iter()
940            .filter(|v| v.severity == ViolationSeverity::Warning)
941            .count();
942        let info_count = violations
943            .iter()
944            .filter(|v| v.severity == ViolationSeverity::Info)
945            .count();
946
947        let applicability = violations
948            .iter()
949            .find(|v| NOT_APPLICABLE_RULES.contains(&v.rule_id))
950            .map_or(Applicability::Applicable, |v| {
951                Applicability::NotApplicable(v.message.clone())
952            });
953
954        Self {
955            is_compliant: error_count == 0,
956            level,
957            violations,
958            conformity_summary: None,
959            applicability,
960            error_count,
961            warning_count,
962            info_count,
963        }
964    }
965
966    /// Whether the standard actually evaluated this SBOM.
967    #[must_use]
968    pub fn is_applicable(&self) -> bool {
969        self.applicability == Applicability::Applicable
970    }
971
972    /// Badge/summary compliance score (0–100).
973    ///
974    /// Errors and warnings count against the score; Info findings are
975    /// neutral. (The formula this replaces used `violations.len()` as the
976    /// denominator, so adding Info findings *raised* the score — 5 errors
977    /// alone scored 16 while 5 errors + 20 infos scored 80.) `None` when
978    /// the standard was not applicable — an unevaluated SBOM has no score.
979    #[must_use]
980    pub fn score(&self) -> Option<u8> {
981        if !self.is_applicable() {
982            return None;
983        }
984        let actionable = self.error_count + self.warning_count;
985        #[allow(clippy::cast_possible_truncation)]
986        Some((100 / (actionable + 1)) as u8)
987    }
988
989    /// Get violations filtered by severity
990    #[must_use]
991    pub fn violations_by_severity(&self, severity: ViolationSeverity) -> Vec<&Violation> {
992        self.violations
993            .iter()
994            .filter(|v| v.severity == severity)
995            .collect()
996    }
997
998    /// Get violations filtered by category
999    #[must_use]
1000    pub fn violations_by_category(&self, category: ViolationCategory) -> Vec<&Violation> {
1001        self.violations
1002            .iter()
1003            .filter(|v| v.category == category)
1004            .collect()
1005    }
1006}
1007
1008/// Calibration check identifiers for `ComplianceChecker::class_severity()`.
1009///
1010/// Each variant corresponds to a row in the CRA-P3.2 calibration table —
1011/// the severity that a given finding should produce *given* the product
1012/// class (Default → Critical) and conformity-assessment route. `None`
1013/// from `class_severity()` means "this check is not applicable for the
1014/// given class" (typically Default doesn't carry EUCC/attestation
1015/// expectations).
1016#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
1017pub enum ClassCheck {
1018    /// Vendor-supplied hash coverage below threshold ([PRE-7-RQ-07-RE]).
1019    VendorHashCoverage,
1020    /// EOL component present in SBOM.
1021    EolComponents,
1022    /// Dependency cycles detected.
1023    Cycles,
1024    /// Annex V Declaration-of-Conformity reference missing.
1025    DocReference,
1026    /// EUCC (Common Criteria) reference missing.
1027    EuccReference,
1028    /// PSIRT URL / 24h / 72h / ENISA channel missing (Art. 14).
1029    Psirt,
1030    /// Conformity-assessment-module attestation reference missing
1031    /// (only meaningful on Module B+C / H / EUCC routes).
1032    ModuleAttestation,
1033}
1034
1035/// Compliance checker for SBOMs
1036#[derive(Debug, Clone)]
1037pub struct ComplianceChecker {
1038    /// Compliance level to check
1039    level: ComplianceLevel,
1040    /// Optional CRA sidecar metadata that supplements the SBOM with
1041    /// manufacturer / disclosure / lifecycle fields the SBOM itself doesn't
1042    /// carry. When set, document-metadata checks consult the sidecar before
1043    /// emitting "missing" violations.
1044    sidecar: Option<crate::model::CraSidecarMetadata>,
1045    /// Optional CRA Annex III/IV product class. Drives severity calibration
1046    /// for `class_severity()` (vendor-hash, EOL, cycles, DoC, EUCC, PSIRT,
1047    /// attestation). When `None`, behaves as `CraProductClass::Default`.
1048    product_class: Option<crate::model::CraProductClass>,
1049    /// Evaluation clock. `None` means wall clock; pin it (CLI `--as-of`,
1050    /// tests) so deadline-sensitive checks (Art. 14 readiness, SBOM age,
1051    /// EUCC certificate expiry) are reproducible across runs.
1052    as_of: Option<chrono::DateTime<chrono::Utc>>,
1053}
1054
1055impl ComplianceChecker {
1056    /// Create a new compliance checker
1057    #[must_use]
1058    pub const fn new(level: ComplianceLevel) -> Self {
1059        Self {
1060            level,
1061            sidecar: None,
1062            product_class: None,
1063            as_of: None,
1064        }
1065    }
1066
1067    /// Pin the evaluation clock. Deadline-sensitive checks (Art. 14
1068    /// readiness, SBOM age, EUCC certificate expiry) evaluate against this
1069    /// instant instead of the wall clock — reproducible CI runs, testable
1070    /// boundary dates.
1071    #[must_use]
1072    pub const fn with_as_of(mut self, as_of: chrono::DateTime<chrono::Utc>) -> Self {
1073        self.as_of = Some(as_of);
1074        self
1075    }
1076
1077    /// The evaluation instant: the pinned `as_of` clock, or the wall clock.
1078    pub(crate) fn now(&self) -> chrono::DateTime<chrono::Utc> {
1079        self.as_of.unwrap_or_else(chrono::Utc::now)
1080    }
1081
1082    /// Attach CRA sidecar metadata to supplement SBOM-level fields.
1083    ///
1084    /// Sidecar values are only consulted as fallbacks — fields present in the
1085    /// SBOM always take precedence. Used by `validate`, `quality`, and `view`
1086    /// CLIs via the `--cra-sidecar` flag (with auto-discovery for adjacent
1087    /// `<sbom>.cra.{json,yaml}` files).
1088    #[must_use]
1089    pub fn with_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
1090        self.sidecar = Some(sidecar);
1091        self
1092    }
1093
1094    /// Set the CRA Annex III/IV product class explicitly.
1095    ///
1096    /// Sidecar `productClass` (when set on the attached sidecar) wins over
1097    /// this; resolve via [`Self::effective_product_class`].
1098    #[must_use]
1099    pub const fn with_product_class(mut self, class: crate::model::CraProductClass) -> Self {
1100        self.product_class = Some(class);
1101        self
1102    }
1103
1104    /// Resolve the effective product class:
1105    /// 1. sidecar `productClass` if present,
1106    /// 2. otherwise `with_product_class` value,
1107    /// 3. otherwise `CraProductClass::Default`.
1108    #[must_use]
1109    pub fn effective_product_class(&self) -> crate::model::CraProductClass {
1110        self.sidecar
1111            .as_ref()
1112            .and_then(|s| s.product_class)
1113            .or(self.product_class)
1114            .unwrap_or(crate::model::CraProductClass::Default)
1115    }
1116
1117    /// Resolve the effective conformity-assessment route. Falls back to
1118    /// `CraProductClass::default_route()` when the sidecar doesn't pin one.
1119    #[must_use]
1120    pub fn effective_route(&self) -> crate::model::ConformityRoute {
1121        self.sidecar
1122            .as_ref()
1123            .and_then(|s| s.conformity_assessment_route)
1124            .unwrap_or_else(|| self.effective_product_class().default_route())
1125    }
1126
1127    /// CRA-P3.2 calibration table — severity for a given check at the
1128    /// effective product class. Returns `None` when the check does not
1129    /// apply for that class (e.g., EUCC reference at `Default`).
1130    #[must_use]
1131    pub fn class_severity(&self, check: ClassCheck) -> Option<ViolationSeverity> {
1132        use crate::model::CraProductClass as C;
1133        let class = self.effective_product_class();
1134        match (check, class) {
1135            // Vendor-hash coverage threshold escalation handled by
1136            // `vendor_hash_thresholds()`; this row reflects the *severity*
1137            // emitted when the threshold is breached.
1138            (ClassCheck::VendorHashCoverage, C::Default | C::ImportantClass1) => {
1139                Some(ViolationSeverity::Warning)
1140            }
1141            (ClassCheck::VendorHashCoverage, C::ImportantClass2 | C::Critical) => {
1142                Some(ViolationSeverity::Error)
1143            }
1144
1145            (ClassCheck::EolComponents, C::Default | C::ImportantClass1) => {
1146                Some(ViolationSeverity::Warning)
1147            }
1148            (ClassCheck::EolComponents, C::ImportantClass2 | C::Critical) => {
1149                Some(ViolationSeverity::Error)
1150            }
1151
1152            (ClassCheck::Cycles, C::Default | C::ImportantClass1) => {
1153                Some(ViolationSeverity::Warning)
1154            }
1155            (ClassCheck::Cycles, C::ImportantClass2 | C::Critical) => {
1156                Some(ViolationSeverity::Error)
1157            }
1158
1159            (ClassCheck::DocReference, C::Default) => Some(ViolationSeverity::Info),
1160            (ClassCheck::DocReference, C::ImportantClass1) => Some(ViolationSeverity::Warning),
1161            (ClassCheck::DocReference, C::ImportantClass2 | C::Critical) => {
1162                Some(ViolationSeverity::Error)
1163            }
1164
1165            (ClassCheck::EuccReference, C::Default | C::ImportantClass1) => None,
1166            (ClassCheck::EuccReference, C::ImportantClass2) => Some(ViolationSeverity::Info),
1167            (ClassCheck::EuccReference, C::Critical) => Some(ViolationSeverity::Error),
1168
1169            (ClassCheck::Psirt, C::Default | C::ImportantClass1) => {
1170                Some(ViolationSeverity::Warning)
1171            }
1172            (ClassCheck::Psirt, C::ImportantClass2 | C::Critical) => Some(ViolationSeverity::Error),
1173
1174            (ClassCheck::ModuleAttestation, C::Default) => None,
1175            (ClassCheck::ModuleAttestation, C::ImportantClass1) => Some(ViolationSeverity::Warning),
1176            (ClassCheck::ModuleAttestation, C::ImportantClass2 | C::Critical) => {
1177                Some(ViolationSeverity::Error)
1178            }
1179        }
1180    }
1181
1182    /// Vendor-hash coverage threshold (single-stage) below which a violation
1183    /// fires. The severity is `class_severity(VendorHashCoverage)`. Values:
1184    /// Default 50%, Important-1 80%, Important-2 80%, Critical 100%.
1185    #[must_use]
1186    pub fn vendor_hash_threshold(&self) -> f64 {
1187        use crate::model::CraProductClass as C;
1188        match self.effective_product_class() {
1189            C::Default => 0.50,
1190            C::ImportantClass1 | C::ImportantClass2 => 0.80,
1191            C::Critical => 1.00,
1192        }
1193    }
1194
1195    /// Whether a CRA product class has been explicitly configured (either
1196    /// via `with_product_class()` or the attached sidecar). Used by the
1197    /// per-check calibration to decide whether to override phase-based
1198    /// defaults — when no class is set, existing phase-driven behavior is
1199    /// preserved verbatim for backwards compatibility.
1200    #[must_use]
1201    pub fn has_explicit_product_class(&self) -> bool {
1202        self.product_class.is_some()
1203            || self
1204                .sidecar
1205                .as_ref()
1206                .and_then(|s| s.product_class)
1207                .is_some()
1208    }
1209
1210    /// Check an SBOM for compliance.
1211    ///
1212    /// Selects the [`StandardChecker`] for the configured level (the
1213    /// dedicated profiles get their own checker; the rest take the generic
1214    /// path), runs it, then back-fills harmonised-standard references from the
1215    /// rule registry and attaches the CRA Annex VIII conformity summary when a
1216    /// product class has been pinned on a CRA profile.
1217    #[must_use]
1218    pub fn check(&self, sbom: &NormalizedSbom) -> ComplianceResult {
1219        let ctx = ComplianceContext::new(self, sbom);
1220        let checker = checker_for(self.level);
1221        debug_assert_eq!(
1222            checker.level(),
1223            self.level,
1224            "dispatched checker must match the configured level"
1225        );
1226        let mut violations = checker.check(&ctx);
1227
1228        // Populate harmonised-standard references from the rule registry.
1229        for v in &mut violations {
1230            if v.standard_refs.is_empty() {
1231                v.standard_refs = v.registry_standard_refs();
1232            }
1233        }
1234
1235        let mut result = ComplianceResult::new(self.level, violations);
1236        // Attach the CRA Annex VIII conformity summary when a product class
1237        // has been pinned and the level is a CRA profile.
1238        if self.level.is_cra() && self.has_explicit_product_class() {
1239            result.conformity_summary = Some(self.build_conformity_summary(sbom));
1240        }
1241        result
1242    }
1243}
1244
1245impl Default for ComplianceChecker {
1246    fn default() -> Self {
1247        Self::new(ComplianceLevel::Standard)
1248    }
1249}
1250
1251#[cfg(test)]
1252mod tests {
1253    use super::*;
1254
1255    /// NTIA lists Timestamp as a required data field; it must gate. A
1256    /// timestamp-less SBOM (epoch sentinel) now fails NtiaMinimum.
1257    #[test]
1258    fn ntia_gates_on_missing_timestamp() {
1259        use crate::model::{Component, DocumentMetadata, NormalizedSbom};
1260        let comp = |sbom: &mut NormalizedSbom| {
1261            let c = Component::new("lib".to_string(), "lib@1".to_string())
1262                .with_version("1.0".to_string())
1263                .with_purl("pkg:cargo/lib@1.0".to_string());
1264            sbom.add_component(c);
1265        };
1266
1267        // No real timestamp (epoch sentinel) → NTIA timestamp error.
1268        let mut no_ts = NormalizedSbom::new(DocumentMetadata::default());
1269        no_ts.document.created = chrono::DateTime::UNIX_EPOCH;
1270        comp(&mut no_ts);
1271        let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&no_ts);
1272        assert!(
1273            r.violations
1274                .iter()
1275                .any(|v| v.rule_id == "SBOM-NTIA-TIMESTAMP"
1276                    && v.severity == ViolationSeverity::Error),
1277            "missing timestamp must fail NTIA"
1278        );
1279
1280        // A real timestamp → no timestamp error.
1281        let mut with_ts = NormalizedSbom::new(DocumentMetadata::default()); // now()
1282        comp(&mut with_ts);
1283        let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&with_ts);
1284        assert!(
1285            !r.violations
1286                .iter()
1287                .any(|v| v.rule_id == "SBOM-NTIA-TIMESTAMP")
1288        );
1289    }
1290
1291    /// EO 14028 §4(e) requires Supplier Name (an NTIA element); a missing
1292    /// supplier must be a gating Error, not a sub-threshold Warning.
1293    #[test]
1294    fn eo14028_gates_on_missing_supplier() {
1295        use crate::model::{Component, DocumentMetadata, NormalizedSbom};
1296        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1297        // Component with version + id but NO supplier.
1298        sbom.add_component(
1299            Component::new("lib".to_string(), "lib@1".to_string())
1300                .with_version("1.0".to_string())
1301                .with_purl("pkg:cargo/lib@1.0".to_string()),
1302        );
1303        let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
1304        assert!(
1305            r.violations
1306                .iter()
1307                .any(|v| v.rule_id == "SBOM-EO14028-SUPPLIER"
1308                    && v.severity == ViolationSeverity::Error),
1309            "missing supplier must be a gating Error under EO 14028"
1310        );
1311    }
1312
1313    /// BSI TR-03183-2 §5.2.2 makes component name mandatory; the dedicated
1314    /// BSI checker must enforce it (it does not run the generic component
1315    /// check).
1316    #[test]
1317    fn bsi_gates_on_nameless_component() {
1318        use crate::model::{Component, DocumentMetadata, NormalizedSbom};
1319        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1320        let mut c =
1321            Component::new(String::new(), "ref-1".to_string()).with_version("1.0".to_string());
1322        c.identifiers.purl = Some("pkg:cargo/x@1.0".to_string());
1323        sbom.add_component(c);
1324        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
1325        assert!(
1326            r.violations
1327                .iter()
1328                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-3"),
1329            "a nameless component must fail BSI §5.2.2"
1330        );
1331    }
1332
1333    /// CRA Art. 24 steward vuln-handling is satisfied by a DOCUMENT-level
1334    /// disclosure URL — the check previously ignored doc fields (false-fail).
1335    #[test]
1336    fn cra_art24_honors_document_level_disclosure() {
1337        use crate::model::{Component, DocumentMetadata, NormalizedSbom};
1338        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1339        sbom.document.vulnerability_disclosure_url =
1340            Some("https://example.org/security".to_string());
1341        sbom.add_component(
1342            Component::new("lib".to_string(), "lib@1".to_string())
1343                .with_version("1.0".to_string())
1344                .with_purl("pkg:cargo/lib@1.0".to_string()),
1345        );
1346        let r = ComplianceChecker::new(ComplianceLevel::CraOssSteward).check(&sbom);
1347        assert!(
1348            !r.violations.iter().any(|v| v.rule_id == "SBOM-CRA-ART-24"),
1349            "a document-level disclosure URL must satisfy the Art.24 vuln-handling gate"
1350        );
1351    }
1352
1353    /// "Other Unique Identifiers" is one of the seven NTIA minimum data
1354    /// fields; a component without PURL/CPE/SWHID/SWID must fail NtiaMinimum.
1355    #[test]
1356    fn ntia_gates_on_missing_identifier() {
1357        use crate::model::{Component, DocumentMetadata, NormalizedSbom, Organization};
1358        let mut without_id = NormalizedSbom::new(DocumentMetadata::default());
1359        let mut c =
1360            Component::new("lib".to_string(), "lib@1".to_string()).with_version("1.0".to_string());
1361        c.supplier = Some(Organization::new("LibCorp".to_string()));
1362        without_id.add_component(c);
1363        let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&without_id);
1364        assert!(
1365            r.violations
1366                .iter()
1367                .any(|v| v.rule_id == "SBOM-NTIA-IDENTIFIER"
1368                    && v.severity == ViolationSeverity::Error),
1369            "missing unique identifier must fail NTIA"
1370        );
1371
1372        let mut with_id = NormalizedSbom::new(DocumentMetadata::default());
1373        let mut c = Component::new("lib".to_string(), "lib@1".to_string())
1374            .with_version("1.0".to_string())
1375            .with_purl("pkg:cargo/lib@1.0".to_string());
1376        c.supplier = Some(Organization::new("LibCorp".to_string()));
1377        with_id.add_component(c);
1378        let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&with_id);
1379        assert!(
1380            !r.violations
1381                .iter()
1382                .any(|v| v.rule_id == "SBOM-NTIA-IDENTIFIER"),
1383            "PURL satisfies the NTIA identifier element"
1384        );
1385    }
1386
1387    /// Asserted-as-unknown sentinels (NOASSERTION) must not satisfy the NTIA
1388    /// version and supplier gates — SPDX copies versionInfo verbatim.
1389    #[test]
1390    fn placeholder_values_do_not_satisfy_required_elements() {
1391        use crate::model::{Component, DocumentMetadata, NormalizedSbom, Organization};
1392        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1393        let mut c = Component::new("lib".to_string(), "lib@1".to_string())
1394            .with_version("NOASSERTION".to_string())
1395            .with_purl("pkg:cargo/lib@1.0".to_string());
1396        c.supplier = Some(Organization::new("NOASSERTION".to_string()));
1397        sbom.add_component(c);
1398        let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1399        assert!(
1400            r.violations
1401                .iter()
1402                .any(|v| v.rule_id == "SBOM-NTIA-VERSION"),
1403            "NOASSERTION version must not satisfy the NTIA version element"
1404        );
1405        assert!(
1406            r.violations
1407                .iter()
1408                .any(|v| v.rule_id == "SBOM-NTIA-SUPPLIER"),
1409            "NOASSERTION supplier must not satisfy the NTIA supplier element"
1410        );
1411    }
1412
1413    /// FDA premarket guidance incorporates the NTIA baseline, so a
1414    /// timestamp-less SBOM must fail FdaMedicalDevice like it fails NTIA.
1415    #[test]
1416    fn fda_gates_on_missing_timestamp() {
1417        use crate::model::{Component, DocumentMetadata, NormalizedSbom};
1418        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1419        sbom.document.created = chrono::DateTime::UNIX_EPOCH;
1420        sbom.add_component(
1421            Component::new("lib".to_string(), "lib@1".to_string())
1422                .with_version("1.0".to_string())
1423                .with_purl("pkg:cargo/lib@1.0".to_string()),
1424        );
1425        let r = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom);
1426        assert!(
1427            r.violations
1428                .iter()
1429                .any(|v| v.rule_id == "SBOM-NTIA-TIMESTAMP"
1430                    && v.severity == ViolationSeverity::Error),
1431            "missing timestamp must fail FDA (NTIA baseline)"
1432        );
1433    }
1434
1435    /// FDA §524B requires level-of-support / end-of-support information;
1436    /// its absence must at least warn, and lifecycle properties satisfy it.
1437    #[test]
1438    fn fda_warns_without_support_lifecycle() {
1439        use crate::model::{Component, DocumentMetadata, NormalizedSbom, Property};
1440        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1441        sbom.add_component(
1442            Component::new("lib".to_string(), "lib@1".to_string())
1443                .with_version("1.0".to_string())
1444                .with_purl("pkg:cargo/lib@1.0".to_string()),
1445        );
1446        let r = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom);
1447        assert!(
1448            r.violations
1449                .iter()
1450                .any(|v| v.requirement.contains("Level of support")),
1451            "missing support-lifecycle info must warn under FDA"
1452        );
1453
1454        let mut with_eol = NormalizedSbom::new(DocumentMetadata::default());
1455        let mut c = Component::new("lib".to_string(), "lib@1".to_string())
1456            .with_version("1.0".to_string())
1457            .with_purl("pkg:cargo/lib@1.0".to_string());
1458        c.extensions.properties.push(Property {
1459            name: "end-of-support".to_string(),
1460            value: "2030-01-01".to_string(),
1461        });
1462        with_eol.add_component(c);
1463        let r = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&with_eol);
1464        assert!(
1465            !r.violations
1466                .iter()
1467                .any(|v| v.requirement.contains("Level of support")),
1468            "component end-of-support property satisfies the FDA support check"
1469        );
1470    }
1471
1472    /// EO 14028 §4(e) mandates the NTIA minimum elements, which include the
1473    /// creation timestamp.
1474    #[test]
1475    fn eo14028_gates_on_missing_timestamp() {
1476        use crate::model::{Component, DocumentMetadata, NormalizedSbom};
1477        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1478        sbom.document.created = chrono::DateTime::UNIX_EPOCH;
1479        sbom.add_component(
1480            Component::new("lib".to_string(), "lib@1".to_string())
1481                .with_version("1.0".to_string())
1482                .with_purl("pkg:cargo/lib@1.0".to_string()),
1483        );
1484        let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
1485        assert!(
1486            r.violations
1487                .iter()
1488                .any(|v| v.rule_id == "SBOM-EO14028-TIMESTAMP"
1489                    && v.severity == ViolationSeverity::Error),
1490            "missing timestamp must fail EO 14028"
1491        );
1492
1493        let mut with_ts = NormalizedSbom::new(DocumentMetadata::default());
1494        with_ts.add_component(
1495            Component::new("lib".to_string(), "lib@1".to_string())
1496                .with_version("1.0".to_string())
1497                .with_purl("pkg:cargo/lib@1.0".to_string()),
1498        );
1499        let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&with_ts);
1500        assert!(
1501            !r.violations
1502                .iter()
1503                .any(|v| v.rule_id == "SBOM-EO14028-TIMESTAMP")
1504        );
1505    }
1506
1507    /// SPDX 2.2 is an NTIA-accepted format; the EO 14028 machine-readable
1508    /// gate must not error on it (SPDX 2.1 and older still fail).
1509    #[test]
1510    fn eo14028_accepts_spdx_2_2() {
1511        use crate::model::{DocumentMetadata, NormalizedSbom, SbomFormat};
1512        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1513        sbom.document.format = SbomFormat::Spdx;
1514        sbom.document.spec_version = "2.2".to_string();
1515        let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
1516        assert!(
1517            !r.violations
1518                .iter()
1519                .any(|v| v.rule_id == "SBOM-EO14028-FORMAT"),
1520            "SPDX 2.2 is machine-readable under EO 14028"
1521        );
1522
1523        sbom.document.spec_version = "2.1".to_string();
1524        let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
1525        assert!(
1526            r.violations
1527                .iter()
1528                .any(|v| v.rule_id == "SBOM-EO14028-FORMAT"),
1529            "SPDX 2.1 still fails the machine-readable gate"
1530        );
1531    }
1532
1533    /// SWHID-only components carry a valid unique identifier; EO 14028 and
1534    /// SSDF identifier checks must not flag them.
1535    #[test]
1536    fn eo14028_and_ssdf_accept_swhid_identifiers() {
1537        use crate::model::{Component, DocumentMetadata, NormalizedSbom, SwhidObject};
1538        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1539        let mut c =
1540            Component::new("lib".to_string(), "lib@1".to_string()).with_version("1.0".to_string());
1541        c.identifiers.swhid.push(
1542            SwhidObject::parse("swh:1:cnt:94a9ed024d3859793618152ea559a168bbcbb5e2").unwrap(),
1543        );
1544        sbom.add_component(c);
1545        let r = ComplianceChecker::new(ComplianceLevel::Eo14028).check(&sbom);
1546        assert!(
1547            !r.violations
1548                .iter()
1549                .any(|v| v.rule_id == "SBOM-EO14028-IDENTIFIER"),
1550            "SWHID satisfies the EO 14028 identifier element"
1551        );
1552        let r = ComplianceChecker::new(ComplianceLevel::NistSsdf).check(&sbom);
1553        assert!(
1554            !r.violations.iter().any(|v| v.rule_id == "SBOM-SSDF-RV1"),
1555            "SWHID satisfies the SSDF RV.1 identifier check"
1556        );
1557    }
1558
1559    /// A single token edge among many components must not satisfy the NTIA
1560    /// dependency-relationship element; declared-incomplete SBOMs are exempt.
1561    #[test]
1562    fn dependency_graph_orphans_warn() {
1563        use crate::model::{
1564            CompletenessDeclaration, Component, DependencyEdge, DependencyType, DocumentMetadata,
1565            NormalizedSbom,
1566        };
1567        let build = || {
1568            let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1569            let mut ids = Vec::new();
1570            for i in 0..5 {
1571                let c = Component::new(format!("lib{i}"), format!("lib{i}@1"))
1572                    .with_version("1.0".to_string())
1573                    .with_purl(format!("pkg:cargo/lib{i}@1.0"));
1574                ids.push(c.canonical_id.clone());
1575                sbom.add_component(c);
1576            }
1577            // One edge between two leaves; the other three stay orphaned.
1578            sbom.edges.push(DependencyEdge::new(
1579                ids[0].clone(),
1580                ids[1].clone(),
1581                DependencyType::DependsOn,
1582            ));
1583            sbom
1584        };
1585
1586        let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&build());
1587        assert!(
1588            r.violations.iter().any(|v| v
1589                .message
1590                .contains("participate in no dependency relationship")),
1591            "3/5 orphaned components must produce a dependency-coverage warning"
1592        );
1593
1594        let mut declared = build();
1595        declared.document.completeness_declaration = CompletenessDeclaration::Incomplete;
1596        let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&declared);
1597        assert!(
1598            !r.violations.iter().any(|v| v
1599                .message
1600                .contains("participate in no dependency relationship")),
1601            "declared-incomplete SBOMs are exempt from the orphan warning"
1602        );
1603    }
1604
1605    /// The primary product component must participate in the dependency
1606    /// graph — a root with zero relationships does not describe the product.
1607    #[test]
1608    fn primary_component_must_participate_in_graph() {
1609        use crate::model::{
1610            Component, DependencyEdge, DependencyType, DocumentMetadata, NormalizedSbom,
1611        };
1612        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1613        let app = Component::new("app".to_string(), "app".to_string())
1614            .with_version("1.0".to_string())
1615            .with_purl("pkg:cargo/app@1.0".to_string());
1616        let lib_a = Component::new("liba".to_string(), "liba".to_string())
1617            .with_version("1.0".to_string())
1618            .with_purl("pkg:cargo/liba@1.0".to_string());
1619        let lib_b = Component::new("libb".to_string(), "libb".to_string())
1620            .with_version("1.0".to_string())
1621            .with_purl("pkg:cargo/libb@1.0".to_string());
1622        let app_id = app.canonical_id.clone();
1623        let a_id = lib_a.canonical_id.clone();
1624        let b_id = lib_b.canonical_id.clone();
1625        sbom.primary_component_id = Some(app_id.clone());
1626        sbom.add_component(app);
1627        sbom.add_component(lib_a);
1628        sbom.add_component(lib_b);
1629        // Edge between the two libs only; the primary is disconnected.
1630        sbom.edges.push(DependencyEdge::new(
1631            a_id.clone(),
1632            b_id,
1633            DependencyType::DependsOn,
1634        ));
1635        let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1636        assert!(
1637            r.violations
1638                .iter()
1639                .any(|v| v.message.contains("Primary component")
1640                    && v.message.contains("no dependency relationship")),
1641            "disconnected primary component must warn"
1642        );
1643
1644        // Connect the primary → warning disappears.
1645        sbom.edges
1646            .push(DependencyEdge::new(app_id, a_id, DependencyType::DependsOn));
1647        let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1648        assert!(
1649            !r.violations
1650                .iter()
1651                .any(|v| v.message.contains("Primary component")
1652                    && v.message.contains("no dependency relationship"))
1653        );
1654    }
1655
1656    /// CRA Art. 24 steward SBOMs still need versioned, identified components
1657    /// and dependency relationships — the per-component gates used to skip
1658    /// the CraOssSteward level entirely (vacuous "SBOM completeness").
1659    #[test]
1660    fn oss_steward_enforces_component_completeness() {
1661        use crate::model::{Component, DocumentMetadata, ExternalRefType, NormalizedSbom};
1662        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1663        // Steward vuln-handling satisfied so only completeness is under test.
1664        sbom.document.vulnerability_disclosure_url =
1665            Some("https://example.org/security".to_string());
1666        for n in ["a", "b", "c"] {
1667            let mut c = Component::new(n.to_string(), n.to_string());
1668            c.external_refs.push(crate::model::ExternalReference {
1669                ref_type: ExternalRefType::Advisories,
1670                url: "https://example.org/advisories".to_string(),
1671                comment: None,
1672                hashes: Vec::new(),
1673            });
1674            sbom.add_component(c);
1675        }
1676        let r = ComplianceChecker::new(ComplianceLevel::CraOssSteward).check(&sbom);
1677        assert!(
1678            r.violations
1679                .iter()
1680                .any(|v| v.rule_id == "SBOM-CRA-COMPONENT-VERSION"
1681                    && v.severity == ViolationSeverity::Error),
1682            "steward components without versions must error"
1683        );
1684        assert!(
1685            r.violations
1686                .iter()
1687                .any(|v| v.rule_id == "SBOM-CRA-ANNEX-I-IDENTIFIER"
1688                    && v.severity == ViolationSeverity::Error),
1689            "steward components without identifiers must error"
1690        );
1691        assert!(
1692            r.violations
1693                .iter()
1694                .any(|v| v.rule_id == "SBOM-CRA-ANNEX-I-DEPENDENCY"
1695                    && v.severity == ViolationSeverity::Error),
1696            "steward SBOM without dependency edges must error"
1697        );
1698        assert!(!r.is_compliant, "incomplete steward SBOM must not pass");
1699    }
1700
1701    /// A third-party dependency's upstream advisories/support refs must not
1702    /// satisfy the manufacturer's Art. 13(17) contact / Annex I Part II (5)
1703    /// CVD-policy obligations.
1704    #[test]
1705    fn third_party_refs_do_not_satisfy_manufacturer_obligations() {
1706        use crate::model::{
1707            Component, DependencyEdge, DependencyType, DocumentMetadata, ExternalRefType,
1708            ExternalReference, NormalizedSbom,
1709        };
1710        let build = |primary_has_contact: bool| {
1711            let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1712            let mut app = Component::new("app".to_string(), "app".to_string())
1713                .with_version("1.0".to_string())
1714                .with_purl("pkg:cargo/app@1.0".to_string());
1715            if primary_has_contact {
1716                app.external_refs.push(ExternalReference {
1717                    ref_type: ExternalRefType::SecurityContact,
1718                    url: "https://acme.example/security".to_string(),
1719                    comment: None,
1720                    hashes: Vec::new(),
1721                });
1722                app.external_refs.push(ExternalReference {
1723                    ref_type: ExternalRefType::Advisories,
1724                    url: "https://acme.example/advisories".to_string(),
1725                    comment: None,
1726                    hashes: Vec::new(),
1727                });
1728            }
1729            // Third-party dep carrying its own upstream refs.
1730            let mut lodash = Component::new("lodash".to_string(), "lodash".to_string())
1731                .with_version("4.17.21".to_string())
1732                .with_purl("pkg:npm/lodash@4.17.21".to_string());
1733            lodash.external_refs.push(ExternalReference {
1734                ref_type: ExternalRefType::Advisories,
1735                url: "https://github.com/lodash/lodash/security/advisories".to_string(),
1736                comment: None,
1737                hashes: Vec::new(),
1738            });
1739            lodash.external_refs.push(ExternalReference {
1740                ref_type: ExternalRefType::Support,
1741                url: "https://lodash.com/docs".to_string(),
1742                comment: None,
1743                hashes: Vec::new(),
1744            });
1745            let app_id = app.canonical_id.clone();
1746            let lodash_id = lodash.canonical_id.clone();
1747            sbom.primary_component_id = Some(app_id.clone());
1748            sbom.add_component(app);
1749            sbom.add_component(lodash);
1750            sbom.edges.push(DependencyEdge::new(
1751                app_id,
1752                lodash_id,
1753                DependencyType::DependsOn,
1754            ));
1755            sbom
1756        };
1757
1758        let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&build(false));
1759        assert!(
1760            r.violations
1761                .iter()
1762                .any(|v| v.rule_id == "SBOM-CRA-ART-13-17-CONTACT"),
1763            "dep-level advisories/support refs must not satisfy Art. 13(17)"
1764        );
1765        assert!(
1766            r.violations
1767                .iter()
1768                .any(|v| v.rule_id == "SBOM-CRA-CVD-POLICY"),
1769            "dep-level advisories ref must not satisfy Annex I Part II (5)"
1770        );
1771
1772        let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&build(true));
1773        assert!(
1774            !r.violations
1775                .iter()
1776                .any(|v| v.rule_id == "SBOM-CRA-ART-13-17-CONTACT"),
1777            "primary-component security contact satisfies Art. 13(17)"
1778        );
1779        assert!(
1780            !r.violations
1781                .iter()
1782                .any(|v| v.rule_id == "SBOM-CRA-CVD-POLICY"),
1783            "primary-component advisories ref satisfies Annex I Part II (5)"
1784        );
1785    }
1786
1787    /// Pinning an explicit product class must never weaken the phase-based
1788    /// vendor-hash gate: 40% coverage is an Error under CraPhase2 regardless
1789    /// of a pinned Important-1 class (whose own threshold severity is softer).
1790    #[test]
1791    fn explicit_product_class_never_weakens_vendor_hash_gate() {
1792        use crate::model::CraProductClass;
1793        let mut sbom = NormalizedSbom::default();
1794        for n in ["a", "b", "c", "d"] {
1795            let c = vendor_component(n, true);
1796            sbom.components.insert(c.canonical_id.clone(), c);
1797        }
1798        for n in ["e", "f", "g", "h", "i", "j"] {
1799            let c = vendor_component(n, false);
1800            sbom.components.insert(c.canonical_id.clone(), c);
1801        }
1802        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
1803            .with_product_class(CraProductClass::ImportantClass1)
1804            .check(&sbom);
1805        let v = result.violations.iter().find(|v| {
1806            v.requirement.contains("PRE-7-RQ-07-RE") && v.severity == ViolationSeverity::Error
1807        });
1808        assert!(
1809            v.is_some(),
1810            "40% coverage must stay an Error under CraPhase2 even with an explicit class"
1811        );
1812    }
1813
1814    /// The sidecar's dedicated EUCC evidence fields must satisfy the
1815    /// Critical-class EUCC reference check (not just URL substrings).
1816    #[test]
1817    fn eucc_sidecar_fields_satisfy_critical_class_check() {
1818        use crate::model::{Component, CraProductClass, CraSidecarMetadata, DocumentMetadata};
1819        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1820        sbom.add_component(
1821            Component::new("fw".to_string(), "fw".to_string())
1822                .with_version("1.0".to_string())
1823                .with_purl("pkg:generic/fw@1.0".to_string()),
1824        );
1825
1826        let bare = ComplianceChecker::new(ComplianceLevel::CraPhase2)
1827            .with_product_class(CraProductClass::Critical)
1828            .check(&sbom);
1829        assert!(
1830            bare.violations
1831                .iter()
1832                .any(|v| v.rule_id == "SBOM-CRA-ANNEX-IV"),
1833            "Critical class without EUCC evidence must flag Annex IV"
1834        );
1835
1836        let sidecar = CraSidecarMetadata {
1837            eucc_protection_profile_id: Some("PP-CC-MFR-2024-01".to_string()),
1838            eucc_target_of_evaluation: Some("TOE-fw-1.0".to_string()),
1839            ..Default::default()
1840        };
1841        let with_sidecar = ComplianceChecker::new(ComplianceLevel::CraPhase2)
1842            .with_product_class(CraProductClass::Critical)
1843            .with_sidecar(sidecar)
1844            .check(&sbom);
1845        assert!(
1846            !with_sidecar
1847                .violations
1848                .iter()
1849                .any(|v| v.rule_id == "SBOM-CRA-ANNEX-IV"),
1850            "sidecar EUCC evidence fields must satisfy the Critical-class check"
1851        );
1852    }
1853
1854    /// Genuine packages named "none"/"unknown" (corroborated by PURL) must
1855    /// not fail the name gate; NOASSERTION always fails it.
1856    #[test]
1857    fn genuine_none_named_package_passes_name_gate() {
1858        use crate::model::{Component, DocumentMetadata, NormalizedSbom, Organization};
1859        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1860        let mut c = Component::new("none".to_string(), "none@1".to_string())
1861            .with_version("1.0.0".to_string())
1862            .with_purl("pkg:npm/none@1.0.0".to_string());
1863        c.supplier = Some(Organization::new("Acme".to_string()));
1864        sbom.add_component(c);
1865        let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1866        assert!(
1867            !r.violations.iter().any(|v| v.rule_id == "SBOM-NTIA-NAME"),
1868            "npm package genuinely named 'none' must not fail the name gate"
1869        );
1870
1871        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1872        sbom.add_component(
1873            Component::new("NOASSERTION".to_string(), "x@1".to_string())
1874                .with_version("1.0".to_string())
1875                .with_purl("pkg:npm/realname@1.0".to_string()),
1876        );
1877        let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1878        assert!(
1879            r.violations.iter().any(|v| v.rule_id == "SBOM-NTIA-NAME"),
1880            "NOASSERTION never satisfies the name gate"
1881        );
1882    }
1883
1884    /// Lifecycle-evidence property matching is token-based with a real
1885    /// value — "geolocation" must not satisfy the FDA support element.
1886    #[test]
1887    fn fda_support_not_satisfied_by_incidental_eol_substring() {
1888        use crate::model::{Component, DocumentMetadata, NormalizedSbom, Property};
1889        let build = |name: &str, value: &str| {
1890            let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1891            let mut c = Component::new("lib".to_string(), "lib@1".to_string())
1892                .with_version("1.0".to_string())
1893                .with_purl("pkg:cargo/lib@1.0".to_string());
1894            c.extensions.properties.push(Property {
1895                name: name.to_string(),
1896                value: value.to_string(),
1897            });
1898            sbom.add_component(c);
1899            ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom)
1900        };
1901        assert!(
1902            build("geolocation", "enabled")
1903                .violations
1904                .iter()
1905                .any(|v| v.requirement.contains("Level of support")),
1906            "'geolocation' must not satisfy the support-lifecycle element"
1907        );
1908        assert!(
1909            build("acme:eol", "2030-01-01")
1910                .violations
1911                .iter()
1912                .all(|v| !v.requirement.contains("Level of support")),
1913            "a real eol property with a value satisfies the element"
1914        );
1915        assert!(
1916            build("end-of-support", "NOASSERTION")
1917                .violations
1918                .iter()
1919                .any(|v| v.requirement.contains("Level of support")),
1920            "a placeholder value must not satisfy the element"
1921        );
1922    }
1923
1924    /// A primary component that positively declares "no dependencies"
1925    /// (CycloneDX empty dependsOn, preserved as a synthetic property) is
1926    /// documented — the participation warning must not fire.
1927    #[test]
1928    fn primary_with_declared_empty_deps_does_not_warn() {
1929        use crate::model::{
1930            Component, DependencyEdge, DependencyType, DocumentMetadata, NormalizedSbom, Property,
1931        };
1932        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1933        let mut app = Component::new("app".to_string(), "app".to_string())
1934            .with_version("1.0".to_string())
1935            .with_purl("pkg:cargo/app@1.0".to_string());
1936        app.extensions.properties.push(Property {
1937            name: crate::parsers::DECLARED_NO_DEPENDENCIES_PROPERTY.to_string(),
1938            value: "true".to_string(),
1939        });
1940        let lib_a = Component::new("liba".to_string(), "liba".to_string())
1941            .with_version("1.0".to_string())
1942            .with_purl("pkg:cargo/liba@1.0".to_string());
1943        let lib_b = Component::new("libb".to_string(), "libb".to_string())
1944            .with_version("1.0".to_string())
1945            .with_purl("pkg:cargo/libb@1.0".to_string());
1946        let app_id = app.canonical_id.clone();
1947        let a_id = lib_a.canonical_id.clone();
1948        let b_id = lib_b.canonical_id.clone();
1949        sbom.primary_component_id = Some(app_id);
1950        sbom.add_component(app);
1951        sbom.add_component(lib_a);
1952        sbom.add_component(lib_b);
1953        sbom.edges
1954            .push(DependencyEdge::new(a_id, b_id, DependencyType::DependsOn));
1955        let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1956        assert!(
1957            !r.violations
1958                .iter()
1959                .any(|v| v.message.contains("Primary component")),
1960            "declared-no-dependencies primary must not warn"
1961        );
1962    }
1963
1964    /// FDA keeps the retired fast-path's any-orphan sensitivity; other
1965    /// standards only warn on a majority of orphans.
1966    #[test]
1967    fn fda_warns_on_minority_orphans() {
1968        use crate::model::{
1969            Component, DependencyEdge, DependencyType, DocumentMetadata, NormalizedSbom,
1970        };
1971        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
1972        let mut ids = Vec::new();
1973        for i in 0..4 {
1974            let c = Component::new(format!("lib{i}"), format!("lib{i}@1"))
1975                .with_version("1.0".to_string())
1976                .with_purl(format!("pkg:cargo/lib{i}@1.0"));
1977            ids.push(c.canonical_id.clone());
1978            sbom.add_component(c);
1979        }
1980        // 3 of 4 connected, 1 orphan (minority).
1981        sbom.edges.push(DependencyEdge::new(
1982            ids[0].clone(),
1983            ids[1].clone(),
1984            DependencyType::DependsOn,
1985        ));
1986        sbom.edges.push(DependencyEdge::new(
1987            ids[1].clone(),
1988            ids[2].clone(),
1989            DependencyType::DependsOn,
1990        ));
1991        let fda = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom);
1992        assert!(
1993            fda.violations.iter().any(|v| v
1994                .message
1995                .contains("participate in no dependency relationship")),
1996            "FDA warns on any orphaned component"
1997        );
1998        let ntia = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
1999        assert!(
2000            !ntia.violations.iter().any(|v| v
2001                .message
2002                .contains("participate in no dependency relationship")),
2003            "NTIA only warns when orphans form a majority"
2004        );
2005    }
2006
2007    /// FDA rule identity: dependency, creator, and serial findings must
2008    /// carry FDA rule ids, not NTIA/CRA ones.
2009    #[test]
2010    fn fda_findings_carry_fda_rule_ids() {
2011        use crate::model::{Component, DocumentMetadata, NormalizedSbom};
2012        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2013        sbom.document.creators.clear();
2014        for i in 0..2 {
2015            sbom.add_component(
2016                Component::new(format!("lib{i}"), format!("lib{i}@1"))
2017                    .with_version("1.0".to_string())
2018                    .with_purl(format!("pkg:cargo/lib{i}@1.0")),
2019            );
2020        }
2021        let r = ComplianceChecker::new(ComplianceLevel::FdaMedicalDevice).check(&sbom);
2022        assert!(
2023            r.violations.iter().any(|v| v.rule_id == "SBOM-FDA-CREATOR"),
2024            "creators-empty must carry SBOM-FDA-CREATOR under FDA"
2025        );
2026        assert!(
2027            r.violations
2028                .iter()
2029                .any(|v| v.rule_id == "SBOM-FDA-DEPENDENCY"),
2030            "dependency findings must carry SBOM-FDA-DEPENDENCY under FDA"
2031        );
2032        assert!(
2033            r.violations
2034                .iter()
2035                .any(|v| v.rule_id == "SBOM-FDA-NAMESPACE"),
2036            "serial-number finding must carry SBOM-FDA-NAMESPACE under FDA"
2037        );
2038        assert!(
2039            !r.violations
2040                .iter()
2041                .any(|v| v.rule_id == "SBOM-NTIA-DEPENDENCY"),
2042            "FDA runs must not emit NTIA dependency rule identity"
2043        );
2044    }
2045
2046    /// Sidecar EUCC evidence must be live: empty strings and expired
2047    /// validity dates must not satisfy the Critical-class Annex IV gate.
2048    #[test]
2049    fn eucc_sidecar_empty_or_expired_evidence_does_not_satisfy() {
2050        use crate::model::{Component, CraProductClass, CraSidecarMetadata, DocumentMetadata};
2051        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2052        sbom.add_component(
2053            Component::new("fw".to_string(), "fw".to_string())
2054                .with_version("1.0".to_string())
2055                .with_purl("pkg:generic/fw@1.0".to_string()),
2056        );
2057        let check = |sidecar: CraSidecarMetadata| {
2058            ComplianceChecker::new(ComplianceLevel::CraPhase2)
2059                .with_product_class(CraProductClass::Critical)
2060                .with_sidecar(sidecar)
2061                .check(&sbom)
2062                .violations
2063                .iter()
2064                .any(|v| v.rule_id == "SBOM-CRA-ANNEX-IV")
2065        };
2066        assert!(
2067            check(CraSidecarMetadata {
2068                eucc_protection_profile_id: Some("  ".to_string()),
2069                ..Default::default()
2070            }),
2071            "an empty-string EUCC field must not satisfy the Annex IV gate"
2072        );
2073        assert!(
2074            check(CraSidecarMetadata {
2075                eucc_valid_until: Some(chrono::Utc::now() - chrono::Duration::days(365)),
2076                ..Default::default()
2077            }),
2078            "an expired EUCC validity date must not satisfy the Annex IV gate"
2079        );
2080        assert!(
2081            !check(CraSidecarMetadata {
2082                eucc_valid_until: Some(chrono::Utc::now() + chrono::Duration::days(365)),
2083                ..Default::default()
2084            }),
2085            "a live EUCC validity date satisfies the Annex IV gate"
2086        );
2087    }
2088
2089    /// Steward supplier findings cite Art. 24 in their structured refs, not
2090    /// the exempted Art. 13(16) manufacturer-identification obligation.
2091    #[test]
2092    fn steward_supplier_refs_cite_art_24() {
2093        use crate::model::{Component, DocumentMetadata, NormalizedSbom};
2094        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2095        sbom.document.vulnerability_disclosure_url =
2096            Some("https://example.org/security".to_string());
2097        sbom.add_component(
2098            Component::new("lib".to_string(), "lib@1".to_string())
2099                .with_version("1.0".to_string())
2100                .with_purl("pkg:cargo/lib@1.0".to_string()),
2101        );
2102        let r = ComplianceChecker::new(ComplianceLevel::CraOssSteward).check(&sbom);
2103        let v = r
2104            .violations
2105            .iter()
2106            .find(|v| v.rule_id == "SBOM-CRA-ART-24-SUPPLIER")
2107            .expect("steward supplier warning fires");
2108        assert!(
2109            v.standard_refs
2110                .iter()
2111                .all(|sr| sr.id != "Art. 13(15)" && sr.id != "Art. 13(16)"),
2112            "steward supplier refs must not cite Art. 13(15)/13(16)"
2113        );
2114        assert!(
2115            v.standard_refs.iter().any(|sr| sr.id == "Art. 24"),
2116            "steward supplier refs must cite Art. 24"
2117        );
2118    }
2119
2120    /// Manufacturer scope: cyclic graphs fall back to all components, and a
2121    /// primary does not exclude sibling root products.
2122    #[test]
2123    fn manufacturer_scope_handles_cycles_and_sibling_roots() {
2124        use crate::model::{
2125            Component, DependencyEdge, DependencyType, DocumentMetadata, ExternalRefType,
2126            ExternalReference, NormalizedSbom,
2127        };
2128        // Cyclic: a <-> b, no primary; evidence on a must still count.
2129        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2130        let mut a = Component::new("liba".to_string(), "liba".to_string())
2131            .with_version("1.0".to_string())
2132            .with_purl("pkg:cargo/liba@1.0".to_string());
2133        a.external_refs.push(ExternalReference {
2134            ref_type: ExternalRefType::SecurityContact,
2135            url: "https://acme.example/security".to_string(),
2136            comment: None,
2137            hashes: Vec::new(),
2138        });
2139        let b = Component::new("libb".to_string(), "libb".to_string())
2140            .with_version("1.0".to_string())
2141            .with_purl("pkg:cargo/libb@1.0".to_string());
2142        let a_id = a.canonical_id.clone();
2143        let b_id = b.canonical_id.clone();
2144        sbom.add_component(a);
2145        sbom.add_component(b);
2146        sbom.edges.push(DependencyEdge::new(
2147            a_id.clone(),
2148            b_id.clone(),
2149            DependencyType::DependsOn,
2150        ));
2151        sbom.edges
2152            .push(DependencyEdge::new(b_id, a_id, DependencyType::DependsOn));
2153        let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
2154        assert!(
2155            !r.violations
2156                .iter()
2157                .any(|v| v.rule_id == "SBOM-CRA-ART-13-17-CONTACT"),
2158            "evidence in a fully-cyclic graph must still count (fallback to all components)"
2159        );
2160    }
2161
2162    /// Dependency cycles fire the ClassCheck::Cycles calibration: Warning at
2163    /// Default class, Error at Important-2/Critical; acyclic graphs are silent.
2164    #[test]
2165    fn cra_dependency_cycles_scale_with_product_class() {
2166        use crate::model::{
2167            Component, CraProductClass, DependencyEdge, DependencyType, DocumentMetadata,
2168            NormalizedSbom,
2169        };
2170        let build = |cyclic: bool| {
2171            let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2172            let a = Component::new("liba".to_string(), "liba".to_string())
2173                .with_version("1.0".to_string())
2174                .with_purl("pkg:cargo/liba@1.0".to_string());
2175            let b = Component::new("libb".to_string(), "libb".to_string())
2176                .with_version("1.0".to_string())
2177                .with_purl("pkg:cargo/libb@1.0".to_string());
2178            let a_id = a.canonical_id.clone();
2179            let b_id = b.canonical_id.clone();
2180            sbom.add_component(a);
2181            sbom.add_component(b);
2182            sbom.edges.push(DependencyEdge::new(
2183                a_id.clone(),
2184                b_id.clone(),
2185                DependencyType::DependsOn,
2186            ));
2187            if cyclic {
2188                sbom.edges
2189                    .push(DependencyEdge::new(b_id, a_id, DependencyType::DependsOn));
2190            }
2191            sbom
2192        };
2193
2194        let cycles = |r: &ComplianceResult| {
2195            r.violations
2196                .iter()
2197                .find(|v| v.rule_id == "SBOM-CRA-CYCLES")
2198                .map(|v| v.severity)
2199        };
2200
2201        let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&build(true));
2202        assert_eq!(
2203            cycles(&r),
2204            Some(ViolationSeverity::Warning),
2205            "cycles warn at the default class"
2206        );
2207        let r = ComplianceChecker::new(ComplianceLevel::CraPhase2)
2208            .with_product_class(CraProductClass::Critical)
2209            .check(&build(true));
2210        assert_eq!(
2211            cycles(&r),
2212            Some(ViolationSeverity::Error),
2213            "cycles error at Critical class"
2214        );
2215        let r = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&build(false));
2216        assert_eq!(cycles(&r), None, "acyclic graphs are silent");
2217    }
2218
2219    /// A readiness standard that never evaluated the SBOM must expose
2220    /// NotApplicable and no score — is_compliant stays true by contract.
2221    #[test]
2222    fn not_applicable_result_has_no_score() {
2223        use crate::model::{Component, DocumentMetadata, NormalizedSbom};
2224        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2225        sbom.add_component(
2226            Component::new("lib".to_string(), "lib@1".to_string())
2227                .with_version("1.0".to_string())
2228                .with_purl("pkg:cargo/lib@1.0".to_string()),
2229        );
2230        let r = ComplianceChecker::new(ComplianceLevel::EuAiAct).check(&sbom);
2231        assert!(!r.is_applicable(), "non-AI SBOM must be NotApplicable");
2232        assert!(
2233            matches!(r.applicability, Applicability::NotApplicable(_)),
2234            "applicability must carry the reason"
2235        );
2236        assert_eq!(r.score(), None, "unevaluated SBOMs have no score");
2237        assert!(r.is_compliant, "the N/A is_compliant contract is preserved");
2238
2239        // An applicable run keeps a real score.
2240        let r = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(&sbom);
2241        assert!(r.is_applicable());
2242        assert!(r.score().is_some());
2243    }
2244
2245    /// Info findings must not move the score: 5 errors alone and 5 errors +
2246    /// 20 infos used to score 16 vs 80 because infos inflated the
2247    /// denominator.
2248    #[test]
2249    fn score_is_neutral_to_info_findings() {
2250        let violation = |severity| Violation {
2251            severity,
2252            category: ViolationCategory::DocumentMetadata,
2253            message: "x".to_string(),
2254            element: None,
2255            requirement: "x".to_string(),
2256            rule_id: "SBOM-CRA-GENERAL",
2257            component_id: None,
2258            counts: None,
2259            standard_refs: Vec::new(),
2260        };
2261        let errors_only = ComplianceResult::new(
2262            ComplianceLevel::NtiaMinimum,
2263            (0..5)
2264                .map(|_| violation(ViolationSeverity::Error))
2265                .collect(),
2266        );
2267        let with_infos = ComplianceResult::new(
2268            ComplianceLevel::NtiaMinimum,
2269            (0..5)
2270                .map(|_| violation(ViolationSeverity::Error))
2271                .chain((0..20).map(|_| violation(ViolationSeverity::Info)))
2272                .collect(),
2273        );
2274        assert_eq!(errors_only.score(), with_infos.score());
2275        assert_eq!(errors_only.score(), Some(16));
2276        let clean = ComplianceResult::new(ComplianceLevel::NtiaMinimum, Vec::new());
2277        assert_eq!(clean.score(), Some(100));
2278    }
2279
2280    /// Payloads that predate the applicability field deserialize as
2281    /// Applicable.
2282    #[test]
2283    fn applicability_defaults_on_old_payloads() {
2284        let r = ComplianceResult::new(ComplianceLevel::NtiaMinimum, Vec::new());
2285        let mut json: serde_json::Value = serde_json::to_value(&r).unwrap();
2286        json.as_object_mut().unwrap().remove("applicability");
2287        let back: ComplianceResult = serde_json::from_value(json).unwrap();
2288        assert_eq!(back.applicability, Applicability::Applicable);
2289    }
2290
2291    /// A pinned evaluation clock makes deadline-sensitive checks
2292    /// deterministic: Art. 14 severity flips across 2026-09-11, and EUCC
2293    /// certificate expiry evaluates against the pinned instant.
2294    #[test]
2295    fn as_of_clock_pins_deadline_checks() {
2296        use crate::model::{Component, CraSidecarMetadata, DocumentMetadata, NormalizedSbom};
2297        let mut sbom = NormalizedSbom::new(DocumentMetadata::default());
2298        sbom.add_component(
2299            Component::new("lib".to_string(), "lib@1".to_string())
2300                .with_version("1.0".to_string())
2301                .with_purl("pkg:cargo/lib@1.0".to_string()),
2302        );
2303        let ts = |s: &str| {
2304            chrono::DateTime::parse_from_rfc3339(s)
2305                .unwrap()
2306                .with_timezone(&chrono::Utc)
2307        };
2308
2309        // Art. 14: Info before the 2026-09-11 application date, stronger after.
2310        let art14_severity = |as_of: &str| {
2311            ComplianceChecker::new(ComplianceLevel::CraPhase2)
2312                .with_as_of(ts(as_of))
2313                .check(&sbom)
2314                .violations
2315                .iter()
2316                .find(|v| v.requirement.contains("Art. 14(2)(a)"))
2317                .map(|v| v.severity)
2318        };
2319        assert_eq!(
2320            art14_severity("2026-01-01T00:00:00Z"),
2321            Some(ViolationSeverity::Info),
2322            "pre-deadline Art. 14 findings are informational"
2323        );
2324        assert_eq!(
2325            art14_severity("2027-01-01T00:00:00Z"),
2326            Some(ViolationSeverity::Warning),
2327            "post-deadline Art. 14 findings escalate"
2328        );
2329
2330        // EUCC certificate expiry against the pinned clock.
2331        let sidecar = CraSidecarMetadata {
2332            eucc_protection_profile_id: Some("PP-1".to_string()),
2333            eucc_target_of_evaluation: Some("TOE-1".to_string()),
2334            eucc_itsef_identifier: Some("ITSEF-1".to_string()),
2335            eucc_valid_until: Some(ts("2027-06-01T00:00:00Z")),
2336            ..Default::default()
2337        };
2338        let eucc_expired = |as_of: &str| {
2339            ComplianceChecker::new(ComplianceLevel::EuccSubstantial)
2340                .with_sidecar(sidecar.clone())
2341                .with_as_of(ts(as_of))
2342                .check(&sbom)
2343                .violations
2344                .iter()
2345                .any(|v| {
2346                    v.severity == ViolationSeverity::Error && v.rule_id == "SBOM-EUCC-VALIDITY"
2347                })
2348        };
2349        assert!(
2350            !eucc_expired("2027-01-01T00:00:00Z"),
2351            "certificate valid at the pinned instant"
2352        );
2353        assert!(
2354            eucc_expired("2028-01-01T00:00:00Z"),
2355            "certificate expired at the pinned instant"
2356        );
2357    }
2358
2359    #[test]
2360    fn test_compliance_level_names() {
2361        assert_eq!(ComplianceLevel::Minimum.name(), "Minimum");
2362        assert_eq!(ComplianceLevel::NtiaMinimum.name(), "NTIA Minimum Elements");
2363        assert_eq!(ComplianceLevel::CraPhase1.name(), "EU CRA Phase 1 (2026)");
2364        assert_eq!(ComplianceLevel::CraPhase2.name(), "EU CRA Phase 2 (2027)");
2365        assert_eq!(ComplianceLevel::NistSsdf.name(), "NIST SSDF (SP 800-218)");
2366        assert_eq!(ComplianceLevel::Eo14028.name(), "EO 14028 Section 4");
2367    }
2368
2369    #[test]
2370    fn test_nist_ssdf_empty_sbom() {
2371        let sbom = NormalizedSbom::default();
2372        let checker = ComplianceChecker::new(ComplianceLevel::NistSsdf);
2373        let result = checker.check(&sbom);
2374        // Empty SBOM should have at least a creator violation
2375        assert!(
2376            result
2377                .violations
2378                .iter()
2379                .any(|v| v.requirement.contains("PS.1"))
2380        );
2381    }
2382
2383    #[test]
2384    fn test_eo14028_empty_sbom() {
2385        let sbom = NormalizedSbom::default();
2386        let checker = ComplianceChecker::new(ComplianceLevel::Eo14028);
2387        let result = checker.check(&sbom);
2388        assert!(
2389            result
2390                .violations
2391                .iter()
2392                .any(|v| v.requirement.contains("EO 14028"))
2393        );
2394    }
2395
2396    #[test]
2397    fn test_compliance_result_counts() {
2398        let violations = vec![
2399            Violation {
2400                severity: ViolationSeverity::Error,
2401                category: ViolationCategory::ComponentIdentification,
2402                message: "Error 1".to_string(),
2403                element: None,
2404                requirement: "Test".to_string(),
2405                rule_id: "SBOM-CRA-GENERAL",
2406                component_id: None,
2407                counts: None,
2408                standard_refs: Vec::new(),
2409            },
2410            Violation {
2411                severity: ViolationSeverity::Warning,
2412                category: ViolationCategory::LicenseInfo,
2413                message: "Warning 1".to_string(),
2414                element: None,
2415                requirement: "Test".to_string(),
2416                rule_id: "SBOM-CRA-GENERAL",
2417                component_id: None,
2418                counts: None,
2419                standard_refs: Vec::new(),
2420            },
2421            Violation {
2422                severity: ViolationSeverity::Info,
2423                category: ViolationCategory::FormatSpecific,
2424                message: "Info 1".to_string(),
2425                element: None,
2426                requirement: "Test".to_string(),
2427                rule_id: "SBOM-CRA-GENERAL",
2428                component_id: None,
2429                counts: None,
2430                standard_refs: Vec::new(),
2431            },
2432        ];
2433
2434        let result = ComplianceResult::new(ComplianceLevel::Standard, violations);
2435        assert!(!result.is_compliant);
2436        assert_eq!(result.error_count, 1);
2437        assert_eq!(result.warning_count, 1);
2438        assert_eq!(result.info_count, 1);
2439    }
2440
2441    fn make_crypto_sbom(algos: &[(&str, &str, Option<&str>, Option<u8>)]) -> NormalizedSbom {
2442        use crate::model::{
2443            AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
2444        };
2445        let mut sbom = NormalizedSbom::default();
2446        for (name, family, param, ql) in algos {
2447            let mut c = crate::model::Component::new(name.to_string(), format!("{name}@1.0"));
2448            c.component_type = ComponentType::Cryptographic;
2449            let mut algo = AlgorithmProperties::new(CryptoPrimitive::Ae)
2450                .with_algorithm_family(family.to_string());
2451            if let Some(p) = param {
2452                algo = algo.with_parameter_set_identifier(p.to_string());
2453            }
2454            if let Some(level) = ql {
2455                algo = algo.with_nist_quantum_security_level(*level);
2456            }
2457            c.crypto_properties = Some(
2458                CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(algo),
2459            );
2460            sbom.add_component(c);
2461        }
2462        sbom
2463    }
2464
2465    #[test]
2466    fn test_cnsa2_aes128_violation() {
2467        let sbom = make_crypto_sbom(&[("AES-128-GCM", "AES", Some("128"), Some(1))]);
2468        let checker = ComplianceChecker::new(ComplianceLevel::Cnsa2);
2469        let result = checker.check(&sbom);
2470        assert!(
2471            result
2472                .violations
2473                .iter()
2474                .any(|v| v.severity == ViolationSeverity::Error && v.message.contains("AES-128")),
2475            "CNSA 2.0 should flag AES-128"
2476        );
2477    }
2478
2479    #[test]
2480    fn test_cnsa2_mlkem1024_passes() {
2481        let sbom = make_crypto_sbom(&[("ML-KEM-1024", "ML-KEM", Some("1024"), Some(5))]);
2482        let checker = ComplianceChecker::new(ComplianceLevel::Cnsa2);
2483        let result = checker.check(&sbom);
2484        let algo_errors: Vec<_> = result
2485            .violations
2486            .iter()
2487            .filter(|v| {
2488                v.severity == ViolationSeverity::Error
2489                    && v.element.as_deref() == Some("ML-KEM-1024")
2490            })
2491            .collect();
2492        assert!(algo_errors.is_empty(), "ML-KEM-1024 should pass CNSA 2.0");
2493    }
2494
2495    #[test]
2496    fn test_pqc_quantum_vulnerable() {
2497        let sbom = make_crypto_sbom(&[("RSA-2048", "RSA", None, Some(0))]);
2498        let checker = ComplianceChecker::new(ComplianceLevel::NistPqc);
2499        let result = checker.check(&sbom);
2500        assert!(
2501            result
2502                .violations
2503                .iter()
2504                .any(|v| v.severity == ViolationSeverity::Error
2505                    && v.message.contains("quantum-vulnerable")),
2506            "PQC should flag RSA-2048 as quantum-vulnerable"
2507        );
2508    }
2509
2510    /// A plain SBOM with NO cryptographic inventory must NOT pass PQC/CNSA2 —
2511    /// that was a vacuous false-pass. It now fails with an "inventory absent"
2512    /// Error.
2513    #[test]
2514    fn crypto_standards_fail_on_empty_inventory() {
2515        let mut sbom = NormalizedSbom::default();
2516        sbom.add_component(crate::model::Component::new(
2517            "lodash".to_string(),
2518            "lodash@4.17.21".to_string(),
2519        ));
2520        for level in [ComplianceLevel::NistPqc, ComplianceLevel::Cnsa2] {
2521            let result = ComplianceChecker::new(level).check(&sbom);
2522            assert!(
2523                !result.is_compliant,
2524                "{level:?} must not report compliant with no crypto inventory"
2525            );
2526            assert!(
2527                result
2528                    .violations
2529                    .iter()
2530                    .any(|v| v.severity == ViolationSeverity::Error
2531                        && v.message.contains("No cryptographic inventory")),
2532                "{level:?} must emit an inventory-absent error"
2533            );
2534        }
2535    }
2536
2537    /// A classical quantum-vulnerable algorithm (RSA/ECDSA/DH) must fail NIST
2538    /// PQC even when nistQuantumSecurityLevel is UNSET (real CBOMs rarely set
2539    /// it to an explicit 0).
2540    #[test]
2541    fn pqc_flags_classical_crypto_with_unset_quantum_level() {
2542        for family in ["RSA", "ECDSA", "ECDH", "DH", "DSA"] {
2543            let sbom = make_crypto_sbom(&[("classical", family, None, None)]);
2544            let result = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2545            assert!(
2546                !result.is_compliant,
2547                "{family} with unset quantum level must fail PQC"
2548            );
2549            assert!(
2550                result
2551                    .violations
2552                    .iter()
2553                    .any(|v| v.severity == ViolationSeverity::Error && v.rule_id == "SBOM-PQC-001"),
2554                "{family} must raise the quantum-vulnerable error"
2555            );
2556        }
2557    }
2558
2559    /// SHA-224 and SHA-256 must fail CNSA 2.0 whether the size is in the family
2560    /// string or the parameter (previously only family "SHA-2"/param "256"
2561    /// was caught).
2562    #[test]
2563    fn cnsa2_flags_weak_sha2_in_either_encoding() {
2564        for (family, param) in [
2565            ("SHA-256", None),
2566            ("SHA-224", None),
2567            ("SHA-2", Some("256")),
2568            ("SHA-2", Some("224")),
2569        ] {
2570            let sbom = make_crypto_sbom(&[("hash", family, param, None)]);
2571            let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2572            assert!(
2573                result
2574                    .violations
2575                    .iter()
2576                    .any(|v| v.rule_id == "SBOM-CNSA2-ALG-002"),
2577                "{family}/{param:?} must fail CNSA 2.0 hash gate"
2578            );
2579        }
2580        // SHA-384 passes the hash gate.
2581        let ok = make_crypto_sbom(&[("hash", "SHA-384", None, None)]);
2582        let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&ok);
2583        assert!(
2584            !result
2585                .violations
2586                .iter()
2587                .any(|v| v.rule_id == "SBOM-CNSA2-ALG-002"),
2588            "SHA-384 must not trip the hash gate"
2589        );
2590    }
2591
2592    #[test]
2593    fn test_pqc_approved_algorithm_info() {
2594        let sbom = make_crypto_sbom(&[("ML-DSA-65", "ML-DSA", Some("65"), Some(3))]);
2595        let checker = ComplianceChecker::new(ComplianceLevel::NistPqc);
2596        let result = checker.check(&sbom);
2597        assert!(
2598            result
2599                .violations
2600                .iter()
2601                .any(|v| v.severity == ViolationSeverity::Info && v.message.contains("approved")),
2602            "PQC should report ML-DSA-65 as approved"
2603        );
2604    }
2605
2606    /// CNSA 2.0 is an exclusive allowlist: everything recognized but not on
2607    /// it must Error — broken hashes, classical PK (including the families the
2608    /// old blocklist missed: Ed25519/ECIES), non-CNSA symmetric ciphers,
2609    /// sub-1024 or parameterless ML-KEM, and round-3 PQC names.
2610    #[test]
2611    fn cnsa2_allowlist_rejects_non_cnsa_algorithms() {
2612        for (family, param, expected_rule) in [
2613            ("SHA-1", None, "SBOM-CNSA2-ALG-005"),
2614            ("DES", None, "SBOM-CNSA2-ALG-005"),
2615            ("ChaCha20", None, "SBOM-CNSA2-ALG-008"),
2616            ("Ed25519", None, "SBOM-CNSA2-ALG-006"),
2617            ("ECIES", None, "SBOM-CNSA2-ALG-006"),
2618            ("EC", None, "SBOM-CNSA2-ALG-006"),
2619            ("Kyber", Some("768"), "SBOM-CNSA2-ALG-003"),
2620            ("ML-KEM-768", None, "SBOM-CNSA2-ALG-003"),
2621            ("ML-KEM", None, "SBOM-CNSA2-ALG-003"), // absent parameter set
2622            ("ML-DSA", Some("65"), "SBOM-CNSA2-ALG-004"),
2623            ("SLH-DSA", None, "SBOM-CNSA2-ALG-008"),
2624            ("SHA-3", Some("256"), "SBOM-CNSA2-ALG-008"),
2625            ("AES-128", None, "SBOM-CNSA2-ALG-001"), // size in family string
2626        ] {
2627            let sbom = make_crypto_sbom(&[("asset", family, param, None)]);
2628            let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2629            assert!(
2630                result.violations.iter().any(|v| {
2631                    v.severity == ViolationSeverity::Error && v.rule_id == expected_rule
2632                }),
2633                "{family}/{param:?} must fail CNSA 2.0 with {expected_rule}; got {:?}",
2634                result
2635                    .violations
2636                    .iter()
2637                    .map(|v| (v.rule_id, v.message.clone()))
2638                    .collect::<Vec<_>>()
2639            );
2640            assert!(!result.is_compliant, "{family} must not be CNSA compliant");
2641        }
2642    }
2643
2644    /// The full CNSA 2.0 suite passes the allowlist with zero errors:
2645    /// AES-256, SHA-384, ML-KEM-1024, ML-DSA-87, and SP 800-208 LMS.
2646    #[test]
2647    fn cnsa2_allowlist_accepts_full_cnsa_suite() {
2648        let sbom = make_crypto_sbom(&[
2649            ("AES-256-GCM", "AES", Some("256"), Some(1)),
2650            ("SHA-384", "SHA-2", Some("384"), Some(2)),
2651            ("ML-KEM-1024", "ML-KEM", Some("1024"), Some(5)),
2652            ("ML-DSA-87", "ML-DSA", Some("87"), Some(5)),
2653            ("LMS", "LMS", None, Some(5)),
2654        ]);
2655        let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2656        let errors: Vec<_> = result
2657            .violations
2658            .iter()
2659            .filter(|v| v.severity == ViolationSeverity::Error)
2660            .collect();
2661        assert!(
2662            errors.is_empty(),
2663            "full CNSA 2.0 suite must have zero errors, got {:?}",
2664            errors
2665                .iter()
2666                .map(|v| (v.rule_id, v.message.clone()))
2667                .collect::<Vec<_>>()
2668        );
2669        assert!(result.is_compliant);
2670    }
2671
2672    /// Unrecognizable algorithms must not silently pass CNSA 2.0: they get a
2673    /// "cannot verify" Warning (not an Error, not a pass).
2674    #[test]
2675    fn cnsa2_unknown_algorithm_warns() {
2676        let sbom = make_crypto_sbom(&[("mystery", "proprietary-frobnicator", None, None)]);
2677        let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2678        assert!(
2679            result.violations.iter().any(|v| {
2680                v.severity == ViolationSeverity::Warning && v.rule_id == "SBOM-CNSA2-ALG-UNKNOWN"
2681            }),
2682            "unknown algorithm must produce the cannot-verify warning"
2683        );
2684        assert!(
2685            !result
2686                .violations
2687                .iter()
2688                .any(|v| v.severity == ViolationSeverity::Error
2689                    && v.rule_id.starts_with("SBOM-CNSA2-ALG")),
2690            "unknown algorithm must not produce an algorithm Error"
2691        );
2692    }
2693
2694    /// A CycloneDX 1.6-style asset (no algorithmFamily) must still be
2695    /// classified — via OID, or via the component name when both family and
2696    /// OID are absent.
2697    #[test]
2698    fn cnsa2_classifies_without_algorithm_family() {
2699        use crate::model::{
2700            AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
2701        };
2702        let mut sbom = NormalizedSbom::default();
2703        // RSA via OID only.
2704        let mut rsa = crate::model::Component::new("RSA-2048".into(), "algo-1".into());
2705        rsa.component_type = ComponentType::Cryptographic;
2706        rsa.crypto_properties = Some(
2707            CryptoProperties::new(CryptoAssetType::Algorithm)
2708                .with_oid("1.2.840.113549.1.1.1".into())
2709                .with_algorithm_properties(AlgorithmProperties::new(CryptoPrimitive::Pke)),
2710        );
2711        sbom.add_component(rsa);
2712        // AES-128 via name only.
2713        let mut aes = crate::model::Component::new("AES-128-CBC".into(), "algo-2".into());
2714        aes.component_type = ComponentType::Cryptographic;
2715        aes.crypto_properties = Some(
2716            CryptoProperties::new(CryptoAssetType::Algorithm)
2717                .with_algorithm_properties(AlgorithmProperties::new(CryptoPrimitive::BlockCipher)),
2718        );
2719        sbom.add_component(aes);
2720
2721        let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2722        assert!(
2723            result
2724                .violations
2725                .iter()
2726                .any(|v| v.rule_id == "SBOM-CNSA2-ALG-006" && v.message.contains("RSA")),
2727            "RSA must be flagged via OID without algorithmFamily"
2728        );
2729        assert!(
2730            result
2731                .violations
2732                .iter()
2733                .any(|v| v.rule_id == "SBOM-CNSA2-ALG-001" && v.message.contains("AES-128")),
2734            "AES-128 must be flagged via name without algorithmFamily/OID"
2735        );
2736
2737        let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2738        assert!(
2739            pqc.violations
2740                .iter()
2741                .any(|v| v.rule_id == "SBOM-PQC-001" && v.message.contains("RSA")),
2742            "PQC must flag OID-only RSA as quantum-vulnerable"
2743        );
2744    }
2745
2746    /// Spelling variants of broken algorithms must not escape SP 800-131A
2747    /// detection under PQC.
2748    #[test]
2749    fn pqc_flags_broken_spelling_variants() {
2750        for family in ["SHA1", "TDES", "ARC4"] {
2751            let sbom = make_crypto_sbom(&[("legacy", family, None, None)]);
2752            let result = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2753            assert!(
2754                result
2755                    .violations
2756                    .iter()
2757                    .any(|v| v.severity == ViolationSeverity::Error && v.rule_id == "SBOM-PQC-005"),
2758                "{family} must fail SP 800-131A broken-algorithm detection"
2759            );
2760        }
2761        // Silent case: a healthy modern hash raises no broken-algorithm error.
2762        let ok = make_crypto_sbom(&[("hash", "SHA-384", None, None)]);
2763        let result = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&ok);
2764        assert!(
2765            !result
2766                .violations
2767                .iter()
2768                .any(|v| v.rule_id == "SBOM-PQC-005"),
2769            "SHA-384 must not be reported broken"
2770        );
2771    }
2772
2773    fn make_protocol_sbom(version: &str, suite_name: &str, suite_algos: &[&str]) -> NormalizedSbom {
2774        use crate::model::{
2775            CipherSuite, ComponentType, CryptoAssetType, CryptoProperties, ProtocolProperties,
2776            ProtocolType,
2777        };
2778        let mut sbom = NormalizedSbom::default();
2779        let mut c = crate::model::Component::new("tls-endpoint".into(), "proto-1".into());
2780        c.component_type = ComponentType::Cryptographic;
2781        c.crypto_properties = Some(
2782            CryptoProperties::new(CryptoAssetType::Protocol).with_protocol_properties(
2783                ProtocolProperties::new(ProtocolType::Tls)
2784                    .with_version(version.to_string())
2785                    .with_cipher_suites(vec![CipherSuite {
2786                        name: Some(suite_name.to_string()),
2787                        algorithms: suite_algos.iter().map(ToString::to_string).collect(),
2788                        identifiers: Vec::new(),
2789                    }]),
2790            ),
2791        );
2792        sbom.add_component(c);
2793        sbom
2794    }
2795
2796    /// A TLS 1.0 protocol asset with a legacy cipher suite must fail BOTH
2797    /// standards — previously protocols satisfied the inventory gate but
2798    /// received zero evaluation.
2799    #[test]
2800    fn protocol_tls10_rc4_fails_both_standards() {
2801        let sbom = make_protocol_sbom("1.0", "TLS_RSA_WITH_RC4_128_SHA", &[]);
2802
2803        let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2804        assert!(!cnsa.is_compliant, "TLS 1.0 must fail CNSA 2.0");
2805        assert!(
2806            cnsa.violations
2807                .iter()
2808                .any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
2809            "TLS 1.0 must trip the CNSA TLS-1.3 version gate"
2810        );
2811        assert!(
2812            cnsa.violations.iter().any(|v| {
2813                v.rule_id == "SBOM-CNSA2-PROTO-002"
2814                    && v.message.contains("RC4")
2815                    && v.message.contains("RSA")
2816            }),
2817            "cipher-suite scan must flag RC4 and RSA; got {:?}",
2818            cnsa.violations
2819                .iter()
2820                .map(|v| v.message.clone())
2821                .collect::<Vec<_>>()
2822        );
2823
2824        let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2825        assert!(!pqc.is_compliant, "TLS 1.0 must fail PQC readiness");
2826        assert!(
2827            pqc.violations
2828                .iter()
2829                .any(|v| v.rule_id == "SBOM-PQC-PROTO-001"),
2830            "TLS 1.0 must trip the PQC minimum-version gate"
2831        );
2832        assert!(
2833            pqc.violations
2834                .iter()
2835                .any(|v| v.rule_id == "SBOM-PQC-PROTO-002" && v.message.contains("broken")),
2836            "cipher-suite scan must flag broken algorithms under PQC"
2837        );
2838    }
2839
2840    /// Silent case: TLS 1.3 with a CNSA 2.0 cipher suite passes both
2841    /// standards' protocol checks.
2842    #[test]
2843    fn protocol_tls13_cnsa_suite_passes() {
2844        let sbom = make_protocol_sbom("1.3", "TLS_AES_256_GCM_SHA384", &[]);
2845
2846        let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2847        assert!(
2848            !cnsa
2849                .violations
2850                .iter()
2851                .any(|v| v.rule_id.starts_with("SBOM-CNSA2-PROTO")),
2852            "TLS 1.3 + CNSA suite must raise no CNSA protocol violations; got {:?}",
2853            cnsa.violations
2854                .iter()
2855                .map(|v| v.message.clone())
2856                .collect::<Vec<_>>()
2857        );
2858        assert!(cnsa.is_compliant);
2859
2860        let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2861        assert!(
2862            !pqc.violations
2863                .iter()
2864                .any(|v| v.rule_id.starts_with("SBOM-PQC-PROTO")),
2865            "TLS 1.3 + CNSA suite must raise no PQC protocol violations"
2866        );
2867    }
2868
2869    /// TLS 1.2 fails the CNSA 2.0 TLS-1.3 gate but passes the PQC
2870    /// minimum-version gate (>= 1.2).
2871    #[test]
2872    fn protocol_tls12_fails_cnsa_only() {
2873        let sbom = make_protocol_sbom("1.2", "TLS_AES_256_GCM_SHA384", &[]);
2874        let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2875        assert!(
2876            cnsa.violations
2877                .iter()
2878                .any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
2879            "TLS 1.2 must fail the CNSA 2.0 version gate"
2880        );
2881        let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2882        assert!(
2883            !pqc.violations
2884                .iter()
2885                .any(|v| v.rule_id == "SBOM-PQC-PROTO-001"),
2886            "TLS 1.2 must pass the PQC minimum-version gate"
2887        );
2888    }
2889
2890    /// Cipher-suite algorithm bom-refs are resolved through the SBOM index
2891    /// and classified — even when the ref string itself is opaque.
2892    #[test]
2893    fn protocol_resolves_cipher_suite_algorithm_refs() {
2894        use crate::model::{
2895            AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
2896        };
2897        let mut sbom = make_protocol_sbom("1.3", "OPAQUE_SUITE_1", &["suite-algo-7"]);
2898        let mut rsa = crate::model::Component::new("legacy-kx".into(), "suite-algo-7".into());
2899        rsa.component_type = ComponentType::Cryptographic;
2900        rsa.crypto_properties = Some(
2901            CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
2902                AlgorithmProperties::new(CryptoPrimitive::Pke).with_algorithm_family("RSA".into()),
2903            ),
2904        );
2905        sbom.add_component(rsa);
2906
2907        let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2908        assert!(
2909            cnsa.violations
2910                .iter()
2911                .any(|v| { v.rule_id == "SBOM-CNSA2-PROTO-002" && v.message.contains("RSA") }),
2912            "resolved suite algorithm ref must be classified; got {:?}",
2913            cnsa.violations
2914                .iter()
2915                .map(|v| v.message.clone())
2916                .collect::<Vec<_>>()
2917        );
2918    }
2919
2920    fn make_cert_sbom(sig_ref: &str, algo_family: Option<&str>) -> NormalizedSbom {
2921        use crate::model::{
2922            AlgorithmProperties, CertificateProperties, ComponentType, CryptoAssetType,
2923            CryptoPrimitive, CryptoProperties,
2924        };
2925        let mut sbom = NormalizedSbom::default();
2926        if let Some(family) = algo_family {
2927            let mut algo = crate::model::Component::new("sig-algorithm".into(), sig_ref.into());
2928            algo.component_type = ComponentType::Cryptographic;
2929            algo.crypto_properties = Some(
2930                CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
2931                    AlgorithmProperties::new(CryptoPrimitive::Signature)
2932                        .with_algorithm_family(family.to_string()),
2933                ),
2934            );
2935            sbom.add_component(algo);
2936        }
2937        let mut cert = crate::model::Component::new("server-cert".into(), "cert-1".into());
2938        cert.component_type = ComponentType::Cryptographic;
2939        cert.crypto_properties = Some(
2940            CryptoProperties::new(CryptoAssetType::Certificate).with_certificate_properties(
2941                CertificateProperties::new().with_signature_algorithm_ref(sig_ref.to_string()),
2942            ),
2943        );
2944        sbom.add_component(cert);
2945        sbom
2946    }
2947
2948    /// CERT-001 must resolve an OPAQUE signature-algorithm bom-ref through
2949    /// the index to the referenced algorithm — the old substring heuristic
2950    /// ("rsa" in the ref string) gave opaque refs zero checks.
2951    #[test]
2952    fn cnsa2_cert_resolves_opaque_signature_ref() {
2953        let sbom = make_cert_sbom("sig-algo-42", Some("RSA"));
2954        let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
2955        assert!(
2956            result
2957                .violations
2958                .iter()
2959                .any(|v| v.rule_id == "SBOM-CNSA2-CERT-001"),
2960            "opaque ref resolving to RSA must fire CERT-001; got {:?}",
2961            result
2962                .violations
2963                .iter()
2964                .map(|v| (v.rule_id, v.message.clone()))
2965                .collect::<Vec<_>>()
2966        );
2967
2968        let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
2969        assert!(
2970            pqc.violations
2971                .iter()
2972                .any(|v| v.rule_id == "SBOM-PQC-CERT-001"),
2973            "opaque ref resolving to RSA must fire the PQC certificate rule"
2974        );
2975    }
2976
2977    /// Silent case: a certificate signed with ML-DSA-87 passes CERT-001, and
2978    /// an unresolvable ref still gets the word-boundary fallback on the raw
2979    /// ref string.
2980    #[test]
2981    fn cnsa2_cert_approved_and_fallback_cases() {
2982        let ok = make_cert_sbom("sig-algo-42", Some("ML-DSA-87"));
2983        let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&ok);
2984        assert!(
2985            !result
2986                .violations
2987                .iter()
2988                .any(|v| v.rule_id == "SBOM-CNSA2-CERT-001"),
2989            "ML-DSA-87-signed certificate must pass CERT-001"
2990        );
2991
2992        // Unresolvable ref: fall back to token-matching the ref string.
2993        let dangling = make_cert_sbom("crypto/algorithm/ecdsa-p256", None);
2994        let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&dangling);
2995        assert!(
2996            result
2997                .violations
2998                .iter()
2999                .any(|v| v.rule_id == "SBOM-CNSA2-CERT-001"),
3000            "unresolvable ECDSA-named ref must still fire CERT-001 via token fallback"
3001        );
3002    }
3003
3004    /// Key material alone must not satisfy the crypto-inventory gate — it
3005    /// receives no CNSA 2.0 evaluation.
3006    #[test]
3007    fn cnsa2_key_material_alone_does_not_satisfy_inventory_gate() {
3008        use crate::model::{
3009            ComponentType, CryptoAssetType, CryptoMaterialType, CryptoProperties,
3010            RelatedCryptoMaterialProperties,
3011        };
3012        let mut sbom = NormalizedSbom::default();
3013        let mut key = crate::model::Component::new("some-key".into(), "key-1".into());
3014        key.component_type = ComponentType::Cryptographic;
3015        key.crypto_properties = Some(
3016            CryptoProperties::new(CryptoAssetType::RelatedCryptoMaterial)
3017                .with_related_crypto_material_properties(
3018                    RelatedCryptoMaterialProperties::new(CryptoMaterialType::PublicKey)
3019                        .with_size(2048),
3020                ),
3021        );
3022        sbom.add_component(key);
3023
3024        let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3025        assert!(
3026            result
3027                .violations
3028                .iter()
3029                .any(|v| v.rule_id == "SBOM-CNSA2-000"),
3030            "unevaluable key material must not satisfy the CNSA2-000 inventory gate"
3031        );
3032        assert!(!result.is_compliant);
3033    }
3034
3035    /// Compound algorithmFamily spellings ("DES-CBC", "AES-128-CBC") must
3036    /// fail both standards by their base algorithm — previously they
3037    /// classified Unknown and produced only a Warning (false pass).
3038    #[test]
3039    fn compound_family_spellings_fail_both_standards() {
3040        let sbom = make_crypto_sbom(&[("legacy-cipher", "DES-CBC", None, None)]);
3041        let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3042        assert!(!cnsa.is_compliant, "DES-CBC must not be CNSA 2.0 compliant");
3043        assert!(
3044            cnsa.violations
3045                .iter()
3046                .any(|v| v.rule_id == "SBOM-CNSA2-ALG-005" && v.message.contains("DES")),
3047            "DES-CBC must fire the broken-algorithm rule; got {:?}",
3048            cnsa.violations
3049                .iter()
3050                .map(|v| (v.rule_id, v.message.clone()))
3051                .collect::<Vec<_>>()
3052        );
3053        let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3054        assert!(
3055            pqc.violations.iter().any(|v| v.rule_id == "SBOM-PQC-005"),
3056            "DES-CBC must fire SP 800-131A under PQC"
3057        );
3058
3059        let sbom = make_crypto_sbom(&[("aes-cbc", "AES-128-CBC", None, None)]);
3060        let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3061        assert!(
3062            cnsa.violations
3063                .iter()
3064                .any(|v| v.rule_id == "SBOM-CNSA2-ALG-001" && v.message.contains("AES-128")),
3065            "AES-128-CBC must fire the AES-256-only rule"
3066        );
3067
3068        // Silent case: a compound spelling of an approved algorithm passes.
3069        let ok = make_crypto_sbom(&[("aead", "AES-256-GCM", None, None)]);
3070        let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&ok);
3071        assert!(
3072            !cnsa
3073                .violations
3074                .iter()
3075                .any(|v| v.severity == ViolationSeverity::Error),
3076            "AES-256-GCM must pass the CNSA 2.0 allowlist; got {:?}",
3077            cnsa.violations
3078                .iter()
3079                .map(|v| (v.rule_id, v.message.clone()))
3080                .collect::<Vec<_>>()
3081        );
3082    }
3083
3084    /// Truncated SHA-2 (SHA-512/256, SHA-512/224) must fail the CNSA 2.0
3085    /// hash gate by its truncated output size — previously it classified
3086    /// as full SHA-512 and was falsely Approved.
3087    #[test]
3088    fn cnsa2_flags_truncated_sha2_variants() {
3089        for family in ["SHA-512/256", "SHA-512/224"] {
3090            let sbom = make_crypto_sbom(&[("hash", family, None, None)]);
3091            let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3092            assert!(
3093                result
3094                    .violations
3095                    .iter()
3096                    .any(|v| v.rule_id == "SBOM-CNSA2-ALG-002"),
3097                "{family} must fail the CNSA 2.0 hash gate; got {:?}",
3098                result
3099                    .violations
3100                    .iter()
3101                    .map(|v| (v.rule_id, v.message.clone()))
3102                    .collect::<Vec<_>>()
3103            );
3104        }
3105        // Silent case: full SHA-512 stays approved.
3106        let ok = make_crypto_sbom(&[("hash", "SHA-512", None, None)]);
3107        let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&ok);
3108        assert!(
3109            !result
3110                .violations
3111                .iter()
3112                .any(|v| v.rule_id == "SBOM-CNSA2-ALG-002"),
3113            "SHA-512 must not trip the hash gate"
3114        );
3115    }
3116
3117    /// The name-only fallback must report the most severe token: a
3118    /// hash-first name like "sha384-rsa-signature" is quantum-vulnerable
3119    /// RSA, not CNSA-approved SHA-384 (previously the first token won and
3120    /// both standards passed).
3121    #[test]
3122    fn name_fallback_reports_most_severe_token() {
3123        use crate::model::{
3124            AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
3125        };
3126        for name in ["sha384-rsa-signature", "rsa-sha384-signature"] {
3127            let mut sbom = NormalizedSbom::default();
3128            let mut c = crate::model::Component::new(name.into(), "algo-1".into());
3129            c.component_type = ComponentType::Cryptographic;
3130            c.crypto_properties = Some(
3131                CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
3132                    AlgorithmProperties::new(CryptoPrimitive::Signature),
3133                ),
3134            );
3135            sbom.add_component(c);
3136
3137            let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3138            assert!(
3139                cnsa.violations
3140                    .iter()
3141                    .any(|v| v.rule_id == "SBOM-CNSA2-ALG-006" && v.message.contains("RSA")),
3142                "'{name}' must be flagged quantum-vulnerable under CNSA 2.0; got {:?}",
3143                cnsa.violations
3144                    .iter()
3145                    .map(|v| (v.rule_id, v.message.clone()))
3146                    .collect::<Vec<_>>()
3147            );
3148            let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3149            assert!(
3150                pqc.violations
3151                    .iter()
3152                    .any(|v| v.rule_id == "SBOM-PQC-001" && v.message.contains("RSA")),
3153                "'{name}' must be flagged quantum-vulnerable under PQC"
3154            );
3155        }
3156    }
3157
3158    /// A certificate whose signature-algorithm ref cannot be resolved or
3159    /// classified must produce a "cannot verify" Warning under both
3160    /// standards — previously it counted as evaluated and passed silently
3161    /// (certificate-only CBOMs reported 100% compliant).
3162    #[test]
3163    fn cert_unknown_signature_ref_warns_both_standards() {
3164        let sbom = make_cert_sbom("sig-algo-42", None);
3165
3166        let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3167        assert!(
3168            cnsa.violations.iter().any(|v| {
3169                v.severity == ViolationSeverity::Warning && v.rule_id == "SBOM-CNSA2-CERT-UNKNOWN"
3170            }),
3171            "opaque dangling sig ref must warn under CNSA 2.0; got {:?}",
3172            cnsa.violations
3173                .iter()
3174                .map(|v| (v.rule_id, v.message.clone()))
3175                .collect::<Vec<_>>()
3176        );
3177        assert!(
3178            !cnsa
3179                .violations
3180                .iter()
3181                .any(|v| v.rule_id == "SBOM-CNSA2-CERT-001"),
3182            "an unverifiable ref is a Warning, not a CERT-001 Error"
3183        );
3184
3185        let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3186        assert!(
3187            pqc.violations.iter().any(|v| {
3188                v.severity == ViolationSeverity::Warning && v.rule_id == "SBOM-PQC-CERT-UNKNOWN"
3189            }),
3190            "opaque dangling sig ref must warn under PQC"
3191        );
3192
3193        // Silent case: a resolvable ML-DSA-87 signature produces neither
3194        // the Warning nor an Error.
3195        let ok = make_cert_sbom("sig-algo-42", Some("ML-DSA-87"));
3196        for level in [ComplianceLevel::Cnsa2, ComplianceLevel::NistPqc] {
3197            let result = ComplianceChecker::new(level).check(&ok);
3198            assert!(
3199                !result
3200                    .violations
3201                    .iter()
3202                    .any(|v| v.rule_id.ends_with("CERT-UNKNOWN")),
3203                "{level:?}: resolvable approved sig ref must not warn"
3204            );
3205        }
3206    }
3207
3208    /// A protocol asset with nothing evaluable (SSH, no version, no cipher
3209    /// suites, no refs) must warn instead of silently counting as evaluated
3210    /// and suppressing the no-inventory gate.
3211    #[test]
3212    fn protocol_without_evidence_warns_both_standards() {
3213        use crate::model::{
3214            ComponentType, CryptoAssetType, CryptoProperties, ProtocolProperties, ProtocolType,
3215        };
3216        let mut sbom = NormalizedSbom::default();
3217        let mut c = crate::model::Component::new("ssh-endpoint".into(), "proto-1".into());
3218        c.component_type = ComponentType::Cryptographic;
3219        c.crypto_properties = Some(
3220            CryptoProperties::new(CryptoAssetType::Protocol)
3221                .with_protocol_properties(ProtocolProperties::new(ProtocolType::Ssh)),
3222        );
3223        sbom.add_component(c);
3224
3225        for (level, rule) in [
3226            (ComplianceLevel::Cnsa2, "SBOM-CNSA2-PROTO-UNKNOWN"),
3227            (ComplianceLevel::NistPqc, "SBOM-PQC-PROTO-UNKNOWN"),
3228        ] {
3229            let result = ComplianceChecker::new(level).check(&sbom);
3230            assert!(
3231                result
3232                    .violations
3233                    .iter()
3234                    .any(|v| v.severity == ViolationSeverity::Warning && v.rule_id == rule),
3235                "{level:?}: evidence-free protocol must warn with {rule}; got {:?}",
3236                result
3237                    .violations
3238                    .iter()
3239                    .map(|v| (v.rule_id, v.message.clone()))
3240                    .collect::<Vec<_>>()
3241            );
3242        }
3243
3244        // Silent case: a TLS 1.3 protocol with a classifiable suite raises
3245        // no PROTO-UNKNOWN warning.
3246        let ok = make_protocol_sbom("1.3", "TLS_AES_256_GCM_SHA384", &[]);
3247        for level in [ComplianceLevel::Cnsa2, ComplianceLevel::NistPqc] {
3248            let result = ComplianceChecker::new(level).check(&ok);
3249            assert!(
3250                !result
3251                    .violations
3252                    .iter()
3253                    .any(|v| v.rule_id.ends_with("PROTO-UNKNOWN")),
3254                "{level:?}: evaluable protocol must not warn"
3255            );
3256        }
3257    }
3258
3259    /// An IKEv2 protocol whose transform refs are all opaque and
3260    /// unresolvable must warn under both standards — previously it counted
3261    /// as evaluated while receiving zero effective checks (vacuous 100%
3262    /// compliant CBOMs).
3263    #[test]
3264    fn ikev2_opaque_transform_refs_warn_both_standards() {
3265        use crate::model::{
3266            ComponentType, CryptoAssetType, CryptoProperties, Ikev2TransformTypes,
3267            ProtocolProperties, ProtocolType,
3268        };
3269        let mut sbom = NormalizedSbom::default();
3270        let mut c = crate::model::Component::new("ipsec-tunnel".into(), "proto-1".into());
3271        c.component_type = ComponentType::Cryptographic;
3272        c.crypto_properties = Some(
3273            CryptoProperties::new(CryptoAssetType::Protocol).with_protocol_properties(
3274                ProtocolProperties::new(ProtocolType::Ikev2).with_ikev2_transform_types(
3275                    Ikev2TransformTypes {
3276                        encr: vec!["transform-encr-7".into()],
3277                        prf: vec!["transform-prf-3".into()],
3278                        integ: vec!["transform-integ-2".into()],
3279                        ke: vec!["transform-ke-9".into()],
3280                    },
3281                ),
3282            ),
3283        );
3284        sbom.add_component(c);
3285
3286        for (level, rule) in [
3287            (ComplianceLevel::Cnsa2, "SBOM-CNSA2-PROTO-UNKNOWN"),
3288            (ComplianceLevel::NistPqc, "SBOM-PQC-PROTO-UNKNOWN"),
3289        ] {
3290            let result = ComplianceChecker::new(level).check(&sbom);
3291            assert!(
3292                result.violations.iter().any(|v| {
3293                    v.severity == ViolationSeverity::Warning
3294                        && v.rule_id == rule
3295                        && v.message.contains("transform-encr-7")
3296                }),
3297                "{level:?}: opaque IKEv2 transforms must warn with {rule}; got {:?}",
3298                result
3299                    .violations
3300                    .iter()
3301                    .map(|v| (v.rule_id, v.message.clone()))
3302                    .collect::<Vec<_>>()
3303            );
3304        }
3305    }
3306
3307    /// The CNSA 2.0 TLS gate must accept legitimate spellings of TLS 1.3
3308    /// and share version parsing with the PQC gate, so the two standards
3309    /// agree on the same input (previously "TLSv1.3" was a false Error and
3310    /// "TLSv1.0" silently passed the PQC gate).
3311    #[test]
3312    fn tls_version_spellings_parse_tolerantly() {
3313        for version in ["TLSv1.3", "tls1.3", "1.3.0", " 1.3", "v1.3"] {
3314            let sbom = make_protocol_sbom(version, "TLS_AES_256_GCM_SHA384", &[]);
3315            let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3316            assert!(
3317                !cnsa
3318                    .violations
3319                    .iter()
3320                    .any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
3321                "'{version}' must count as TLS 1.3; got {:?}",
3322                cnsa.violations
3323                    .iter()
3324                    .map(|v| v.message.clone())
3325                    .collect::<Vec<_>>()
3326            );
3327        }
3328        // Spelled-out old versions now fail BOTH gates.
3329        let sbom = make_protocol_sbom("TLSv1.0", "TLS_AES_256_GCM_SHA384", &[]);
3330        let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3331        assert!(
3332            cnsa.violations
3333                .iter()
3334                .any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
3335            "TLSv1.0 must fail the CNSA 2.0 version gate"
3336        );
3337        let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3338        assert!(
3339            pqc.violations
3340                .iter()
3341                .any(|v| v.rule_id == "SBOM-PQC-PROTO-001"),
3342            "TLSv1.0 must fail the PQC minimum-version gate (previously silent)"
3343        );
3344        // Unparseable versions: strict Error under the CNSA 2.0 allowlist,
3345        // cannot-verify Warning under PQC.
3346        let sbom = make_protocol_sbom("quantum-safe", "TLS_AES_256_GCM_SHA384", &[]);
3347        let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3348        assert!(
3349            cnsa.violations
3350                .iter()
3351                .any(|v| v.rule_id == "SBOM-CNSA2-PROTO-001"),
3352            "an unparseable version cannot affirm TLS 1.3"
3353        );
3354        let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3355        assert!(
3356            pqc.violations
3357                .iter()
3358                .any(|v| v.severity == ViolationSeverity::Warning
3359                    && v.rule_id == "SBOM-PQC-PROTO-UNKNOWN"
3360                    && v.message.contains("quantum-safe")),
3361            "an unparseable version must be a cannot-verify Warning under PQC"
3362        );
3363    }
3364
3365    /// Reference resolution must carry the referenced asset's declared
3366    /// classicalSecurityLevel: an AES asset whose key size is declared only
3367    /// via classicalSecurityLevel=256 is Approved when evaluated directly,
3368    /// so it must also be Approved when reached through a protocol's
3369    /// cryptoRefArray (previously a false PROTO-002).
3370    #[test]
3371    fn protocol_ref_resolution_keeps_declared_security_level() {
3372        use crate::model::{
3373            AlgorithmProperties, ComponentType, CryptoAssetType, CryptoPrimitive, CryptoProperties,
3374            ProtocolProperties, ProtocolType,
3375        };
3376        let make = |classical_level: u32| {
3377            let mut sbom = NormalizedSbom::default();
3378            let mut aes = crate::model::Component::new("aes-gcm-cipher".into(), "algo-aes".into());
3379            aes.component_type = ComponentType::Cryptographic;
3380            aes.crypto_properties = Some(
3381                CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
3382                    AlgorithmProperties::new(CryptoPrimitive::Ae)
3383                        .with_algorithm_family("AES".into())
3384                        .with_classical_security_level(classical_level)
3385                        .with_nist_quantum_security_level(5),
3386                ),
3387            );
3388            sbom.add_component(aes);
3389            let mut proto = crate::model::Component::new("tls-endpoint".into(), "proto-1".into());
3390            proto.component_type = ComponentType::Cryptographic;
3391            proto.crypto_properties = Some(
3392                CryptoProperties::new(CryptoAssetType::Protocol).with_protocol_properties(
3393                    ProtocolProperties::new(ProtocolType::Tls)
3394                        .with_version("1.3".into())
3395                        .with_crypto_ref_array(vec!["algo-aes".into()]),
3396                ),
3397            );
3398            sbom.add_component(proto);
3399            sbom
3400        };
3401
3402        // AES-256 via classicalSecurityLevel only: both the direct and the
3403        // referenced evaluation must agree (zero errors).
3404        let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&make(256));
3405        assert!(
3406            !result
3407                .violations
3408                .iter()
3409                .any(|v| v.severity == ViolationSeverity::Error),
3410            "AES with classicalSecurityLevel=256 must pass via ref too; got {:?}",
3411            result
3412                .violations
3413                .iter()
3414                .map(|v| (v.rule_id, v.message.clone()))
3415                .collect::<Vec<_>>()
3416        );
3417
3418        // Firing counterpart: a declared 128-bit level fails through the
3419        // ref path exactly as it does directly.
3420        let result = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&make(128));
3421        assert!(
3422            result
3423                .violations
3424                .iter()
3425                .any(|v| v.rule_id == "SBOM-CNSA2-PROTO-002" && v.message.contains("AES")),
3426            "AES-128 referenced from a protocol must still fire PROTO-002"
3427        );
3428    }
3429
3430    /// National quantum-vulnerable algorithms (SM2 / GOST R 34.10 /
3431    /// brainpool) must fail PQC and CNSA 2.0 as classical crypto instead of
3432    /// producing only an unclassifiable Warning.
3433    #[test]
3434    fn national_algorithms_fail_both_standards() {
3435        for family in ["SM2", "GOST R 34.10", "brainpoolP256r1"] {
3436            let sbom = make_crypto_sbom(&[("national-sig", family, None, None)]);
3437            let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3438            assert!(
3439                pqc.violations
3440                    .iter()
3441                    .any(|v| v.severity == ViolationSeverity::Error && v.rule_id == "SBOM-PQC-001"),
3442                "{family} must fire the quantum-vulnerable rule; got {:?}",
3443                pqc.violations
3444                    .iter()
3445                    .map(|v| (v.rule_id, v.message.clone()))
3446                    .collect::<Vec<_>>()
3447            );
3448            let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3449            assert!(
3450                cnsa.violations
3451                    .iter()
3452                    .any(|v| v.rule_id == "SBOM-CNSA2-ALG-006"),
3453                "{family} must fail the CNSA 2.0 allowlist as quantum-vulnerable"
3454            );
3455        }
3456        // SM4 (symmetric) and GOST hashes are recognized-but-not-approved
3457        // under CNSA 2.0, and not quantum-vulnerable under PQC.
3458        for family in ["SM4", "Streebog"] {
3459            let sbom = make_crypto_sbom(&[("national-prim", family, None, None)]);
3460            let cnsa = ComplianceChecker::new(ComplianceLevel::Cnsa2).check(&sbom);
3461            assert!(
3462                cnsa.violations
3463                    .iter()
3464                    .any(|v| v.rule_id == "SBOM-CNSA2-ALG-008"),
3465                "{family} must be recognized as not CNSA 2.0-approved"
3466            );
3467            let pqc = ComplianceChecker::new(ComplianceLevel::NistPqc).check(&sbom);
3468            assert!(
3469                !pqc.violations
3470                    .iter()
3471                    .any(|v| v.rule_id == "SBOM-PQC-001" || v.rule_id == "SBOM-PQC-005"),
3472                "{family} must not be reported broken or quantum-vulnerable"
3473            );
3474        }
3475    }
3476
3477    fn refs_for(rule_id: &'static str) -> Vec<StandardRef> {
3478        let v = Violation {
3479            severity: ViolationSeverity::Warning,
3480            category: ViolationCategory::DocumentMetadata,
3481            message: String::new(),
3482            element: None,
3483            requirement: String::new(),
3484            rule_id,
3485            component_id: None,
3486            counts: None,
3487            standard_refs: Vec::new(),
3488        };
3489        v.registry_standard_refs()
3490    }
3491
3492    #[test]
3493    fn registry_refs_for_machine_readable_include_annex_and_pren() {
3494        let refs = refs_for("SBOM-CRA-MACHINE-READABLE");
3495        assert!(
3496            refs.iter()
3497                .any(|r| r.standard == StandardKind::CraAnnex && r.id == "Annex I Part II (1)"),
3498            "expected CRA Annex I Part II (1); got {refs:?}"
3499        );
3500        assert!(
3501            refs.iter()
3502                .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-04"),
3503            "expected prEN PRE-7-RQ-04; got {refs:?}"
3504        );
3505    }
3506
3507    #[test]
3508    fn registry_refs_for_annex_i_identifier_include_pren_07() {
3509        let refs = refs_for("SBOM-CRA-ANNEX-I-IDENTIFIER");
3510        assert!(
3511            refs.iter()
3512                .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-07"),
3513            "expected PRE-7-RQ-07; got {refs:?}"
3514        );
3515        let pren_count = refs
3516            .iter()
3517            .filter(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-07")
3518            .count();
3519        assert_eq!(pren_count, 1, "PRE-7-RQ-07 should appear exactly once");
3520    }
3521
3522    #[test]
3523    fn registry_refs_for_supply_chain_include_annex_and_pren() {
3524        let refs = refs_for("SBOM-CRA-ANNEX-I-SUPPLY-CHAIN");
3525        assert!(
3526            refs.iter()
3527                .any(|r| r.standard == StandardKind::CraAnnex && r.id == "Annex I Part II"),
3528            "expected Annex I Part II; got {refs:?}"
3529        );
3530        assert!(
3531            refs.iter()
3532                .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-01"),
3533            "expected PRE-7-RQ-01; got {refs:?}"
3534        );
3535        assert!(
3536            refs.iter()
3537                .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "PRE-7-RQ-03"),
3538            "expected PRE-7-RQ-03; got {refs:?}"
3539        );
3540    }
3541
3542    #[test]
3543    fn registry_refs_for_cvd_policy_include_annex_and_pren_rls() {
3544        let refs = refs_for("SBOM-CRA-CVD-POLICY");
3545        assert!(
3546            refs.iter()
3547                .any(|r| r.standard == StandardKind::CraAnnex && r.id == "Annex I Part II (5)"),
3548            "expected CRA Annex I Part II (5); got {refs:?}"
3549        );
3550        assert!(
3551            refs.iter()
3552                .any(|r| r.standard == StandardKind::Pren40000_1_3 && r.id == "RLS-2-RQ-03-RE"),
3553            "expected RLS-2-RQ-03-RE; got {refs:?}"
3554        );
3555    }
3556
3557    #[test]
3558    fn registry_refs_for_ssdf_ps2() {
3559        let refs = refs_for("SBOM-SSDF-PS2");
3560        assert!(
3561            refs.iter()
3562                .any(|r| r.standard == StandardKind::NistSsdf && r.id == "PS.2"),
3563            "expected NIST SSDF PS.2; got {refs:?}"
3564        );
3565    }
3566
3567    /// Exhaustive registry coverage: every rule key emitted by the checker
3568    /// across all compliance levels and a representative fixture set must
3569    /// resolve in [`rule_meta`] — no orphan rules.
3570    #[test]
3571    fn every_emitted_violation_has_a_registered_rule_id() {
3572        let sbom = NormalizedSbom::default();
3573        for level in ComplianceLevel::all() {
3574            let result = ComplianceChecker::new(*level).check(&sbom);
3575            for v in &result.violations {
3576                assert!(
3577                    rule_meta(v.rule_id).is_some(),
3578                    "level {level:?}: violation {:?} has unregistered rule_id {:?}",
3579                    v.requirement,
3580                    v.rule_id
3581                );
3582            }
3583        }
3584    }
3585
3586    #[test]
3587    fn check_populates_standard_refs_for_cra_violations() {
3588        let sbom = NormalizedSbom::default();
3589        let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
3590        let result = checker.check(&sbom);
3591        let cra_violations: Vec<_> = result
3592            .violations
3593            .iter()
3594            .filter(|v| v.requirement.to_lowercase().contains("cra"))
3595            .collect();
3596        assert!(
3597            !cra_violations.is_empty(),
3598            "empty SBOM should produce some CRA violations"
3599        );
3600        for v in &cra_violations {
3601            assert!(
3602                !v.standard_refs.is_empty(),
3603                "CRA violation {:?} should have standard_refs populated",
3604                v.requirement
3605            );
3606        }
3607    }
3608
3609    #[test]
3610    fn sidecar_supplies_security_contact_downgrades_art_13_17() {
3611        use crate::model::CraSidecarMetadata;
3612        let sbom = NormalizedSbom::default();
3613
3614        // Without sidecar: Art. 13(17) is a Warning
3615        let bare = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3616        let art_13_17_warning = bare.violations.iter().find(|v| {
3617            v.requirement.contains("Art. 13(17)") && v.severity == ViolationSeverity::Warning
3618        });
3619        assert!(
3620            art_13_17_warning.is_some(),
3621            "Without sidecar, Art. 13(17) should be a Warning"
3622        );
3623
3624        // With sidecar that supplies security_contact: same finding becomes Info
3625        let sidecar = CraSidecarMetadata {
3626            security_contact: Some("security@example.com".to_string()),
3627            ..Default::default()
3628        };
3629        let withsc = ComplianceChecker::new(ComplianceLevel::CraPhase2)
3630            .with_sidecar(sidecar)
3631            .check(&sbom);
3632        let art_13_17_info = withsc.violations.iter().find(|v| {
3633            v.requirement.contains("Art. 13(17)") && v.severity == ViolationSeverity::Info
3634        });
3635        assert!(
3636            art_13_17_info.is_some(),
3637            "With sidecar, Art. 13(17) should be downgraded to Info"
3638        );
3639        assert!(
3640            !withsc
3641                .violations
3642                .iter()
3643                .any(|v| v.requirement.contains("Art. 13(17)")
3644                    && v.severity == ViolationSeverity::Warning),
3645            "With sidecar, no Warning-level Art. 13(17) violation should remain"
3646        );
3647    }
3648
3649    #[test]
3650    fn sidecar_supplies_product_name_downgrades_art_13_15() {
3651        use crate::model::CraSidecarMetadata;
3652        let sbom = NormalizedSbom::default(); // no document name
3653
3654        let sidecar = CraSidecarMetadata {
3655            product_name: Some("Demo Product".to_string()),
3656            ..Default::default()
3657        };
3658        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
3659            .with_sidecar(sidecar)
3660            .check(&sbom);
3661        let downgraded = result.violations.iter().find(|v| {
3662            v.requirement.contains("Art. 13(15)") && v.severity == ViolationSeverity::Info
3663        });
3664        assert!(
3665            downgraded.is_some(),
3666            "Sidecar product_name should downgrade Art. 13(15) to Info"
3667        );
3668    }
3669
3670    #[test]
3671    fn sidecar_supplies_manufacturer_downgrades_art_13_16() {
3672        use crate::model::CraSidecarMetadata;
3673        let sbom = NormalizedSbom::default();
3674        let sidecar = CraSidecarMetadata {
3675            manufacturer_name: Some("Demo Corp".to_string()),
3676            ..Default::default()
3677        };
3678        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
3679            .with_sidecar(sidecar)
3680            .check(&sbom);
3681        let downgraded = result.violations.iter().find(|v| {
3682            v.requirement.contains("Art. 13(16)") && v.severity == ViolationSeverity::Info
3683        });
3684        assert!(
3685            downgraded.is_some(),
3686            "Sidecar manufacturer_name should downgrade Art. 13(16) to Info"
3687        );
3688    }
3689
3690    #[test]
3691    fn sidecar_supplies_cvd_url_downgrades_cvd_policy() {
3692        use crate::model::CraSidecarMetadata;
3693        let sbom = NormalizedSbom::default();
3694        let sidecar = CraSidecarMetadata {
3695            vulnerability_disclosure_url: Some("https://example.com/security".to_string()),
3696            ..Default::default()
3697        };
3698        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
3699            .with_sidecar(sidecar)
3700            .check(&sbom);
3701        let downgraded = result.violations.iter().find(|v| {
3702            v.requirement.contains("Annex I Part II (5)") && v.severity == ViolationSeverity::Info
3703        });
3704        assert!(
3705            downgraded.is_some(),
3706            "Sidecar CVD URL should downgrade the Annex I Part II (5) CVD-policy finding to Info"
3707        );
3708    }
3709
3710    fn vendor_component(name: &str, with_hash: bool) -> crate::model::Component {
3711        use crate::model::{Component, Hash, HashAlgorithm, Organization};
3712        let mut c = Component::new(name.to_string(), name.to_string())
3713            .with_purl(format!("pkg:cargo/{name}@1.0.0"));
3714        c.supplier = Some(Organization::new("VendorCorp".to_string()));
3715        if with_hash {
3716            c.hashes.push(Hash::new(
3717                HashAlgorithm::Sha256,
3718                "0000000000000000000000000000000000000000000000000000000000000000".to_string(),
3719            ));
3720        }
3721        c
3722    }
3723
3724    fn hw_component(
3725        name: &str,
3726        kind: crate::model::ComponentType,
3727        with_purl: bool,
3728        with_supplier: bool,
3729        version: Option<&str>,
3730    ) -> crate::model::Component {
3731        use crate::model::{Component, Organization};
3732        let mut c = Component::new(name.to_string(), name.to_string());
3733        c.component_type = kind;
3734        if with_purl {
3735            c = c.with_purl(format!("pkg:generic/{name}"));
3736        }
3737        if with_supplier {
3738            c.supplier = Some(Organization::new("HardwareCorp".to_string()));
3739        }
3740        if let Some(v) = version {
3741            c = c.with_version(v.to_string());
3742        }
3743        c
3744    }
3745
3746    #[test]
3747    fn hardware_check_skipped_for_software_only_sbom() {
3748        let mut sbom = NormalizedSbom::default();
3749        let c = vendor_component("software", true);
3750        sbom.components.insert(c.canonical_id.clone(), c);
3751        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3752        assert!(
3753            !result
3754                .violations
3755                .iter()
3756                .any(|v| v.requirement.contains("PRE-8-RQ-02")),
3757            "Software-only SBOM should produce no PRE-8-RQ-02 violations"
3758        );
3759    }
3760
3761    #[test]
3762    fn hardware_check_passes_for_complete_firmware() {
3763        use crate::model::ComponentType;
3764        let mut sbom = NormalizedSbom::default();
3765        let c = hw_component(
3766            "router-fw",
3767            ComponentType::Firmware,
3768            true,
3769            true,
3770            Some("1.2.3"),
3771        );
3772        sbom.components.insert(c.canonical_id.clone(), c);
3773        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3774        assert!(
3775            !result
3776                .violations
3777                .iter()
3778                .any(|v| v.requirement.contains("PRE-8-RQ-02")),
3779            "Complete firmware component should pass [PRE-8-RQ-02]"
3780        );
3781    }
3782
3783    #[test]
3784    fn hardware_check_flags_firmware_without_version() {
3785        use crate::model::ComponentType;
3786        let mut sbom = NormalizedSbom::default();
3787        let c = hw_component("router-fw", ComponentType::Firmware, true, true, None);
3788        sbom.components.insert(c.canonical_id.clone(), c);
3789        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3790        assert!(
3791            result.violations.iter().any(|v| {
3792                v.requirement.contains("Firmware version") && v.severity == ViolationSeverity::Error
3793            }),
3794            "Firmware without version should produce an Error"
3795        );
3796    }
3797
3798    #[test]
3799    fn hardware_check_flags_missing_producer() {
3800        use crate::model::ComponentType;
3801        let mut sbom = NormalizedSbom::default();
3802        let c = hw_component("router", ComponentType::Device, true, false, Some("1.0"));
3803        sbom.components.insert(c.canonical_id.clone(), c);
3804        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3805        assert!(
3806            result.violations.iter().any(|v| {
3807                v.requirement.contains("Hardware producer")
3808                    && v.severity == ViolationSeverity::Error
3809            }),
3810            "Hardware without producer should produce an Error"
3811        );
3812    }
3813
3814    #[test]
3815    fn hardware_check_flags_synthetic_identifier() {
3816        use crate::model::{Component, ComponentType, Organization};
3817        let mut sbom = NormalizedSbom::default();
3818        let mut c = Component::new("router".to_string(), "router".to_string())
3819            .with_version("1.0".to_string());
3820        c.component_type = ComponentType::Device;
3821        c.supplier = Some(Organization::new("HardwareCorp".to_string()));
3822        // Note: no PURL/CPE/SWHID/SWID → falls back to synthetic
3823        sbom.components.insert(c.canonical_id.clone(), c);
3824        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3825        assert!(
3826            result.violations.iter().any(|v| {
3827                v.requirement.contains("Hardware identifier")
3828                    && v.severity == ViolationSeverity::Error
3829            }),
3830            "Hardware with synthetic ID should produce an Error"
3831        );
3832    }
3833
3834    #[test]
3835    fn hardware_check_device_with_firmware_dep_passes() {
3836        use crate::model::{ComponentType, DependencyEdge, DependencyType};
3837        let mut sbom = NormalizedSbom::default();
3838        let device = hw_component("router", ComponentType::Device, true, true, None);
3839        let firmware = hw_component(
3840            "router-fw",
3841            ComponentType::Firmware,
3842            true,
3843            true,
3844            Some("1.2.3"),
3845        );
3846        let device_id = device.canonical_id.clone();
3847        let firmware_id = firmware.canonical_id.clone();
3848        sbom.components.insert(device_id.clone(), device);
3849        sbom.components.insert(firmware_id.clone(), firmware);
3850        sbom.edges.push(DependencyEdge::new(
3851            device_id,
3852            firmware_id,
3853            DependencyType::DependsOn,
3854        ));
3855        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3856        assert!(
3857            !result
3858                .violations
3859                .iter()
3860                .any(|v| { v.requirement.contains("Device firmware association") }),
3861            "Device with firmware dependency should not trigger version warning"
3862        );
3863    }
3864
3865    #[test]
3866    fn vendor_hash_coverage_full() {
3867        use crate::quality::HashQualityMetrics;
3868        let mut sbom = NormalizedSbom::default();
3869        for n in ["a", "b", "c", "d", "e"] {
3870            let c = vendor_component(n, true);
3871            sbom.components.insert(c.canonical_id.clone(), c);
3872        }
3873        let m = HashQualityMetrics::from_sbom(&sbom);
3874        assert_eq!(m.vendor_components_total, 5);
3875        assert_eq!(m.vendor_components_with_hash, 5);
3876        assert_eq!(m.vendor_hash_coverage(), Some(1.0));
3877    }
3878
3879    #[test]
3880    fn vendor_hash_coverage_partial_triggers_warning() {
3881        let mut sbom = NormalizedSbom::default();
3882        // 7 with hashes, 3 without → 70% < 80% → Warning under CraPhase2
3883        for n in ["a", "b", "c", "d", "e", "f", "g"] {
3884            let c = vendor_component(n, true);
3885            sbom.components.insert(c.canonical_id.clone(), c);
3886        }
3887        for n in ["h", "i", "j"] {
3888            let c = vendor_component(n, false);
3889            sbom.components.insert(c.canonical_id.clone(), c);
3890        }
3891        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3892        let v = result.violations.iter().find(|v| {
3893            v.requirement.contains("PRE-7-RQ-07-RE") && v.severity == ViolationSeverity::Warning
3894        });
3895        assert!(
3896            v.is_some(),
3897            "70% vendor-hash coverage should produce a Warning under CraPhase2"
3898        );
3899    }
3900
3901    #[test]
3902    fn vendor_hash_coverage_below_50_triggers_error() {
3903        let mut sbom = NormalizedSbom::default();
3904        // 4 with hashes, 6 without → 40% < 50% → Error under CraPhase2
3905        for n in ["a", "b", "c", "d"] {
3906            let c = vendor_component(n, true);
3907            sbom.components.insert(c.canonical_id.clone(), c);
3908        }
3909        for n in ["e", "f", "g", "h", "i", "j"] {
3910            let c = vendor_component(n, false);
3911            sbom.components.insert(c.canonical_id.clone(), c);
3912        }
3913        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3914        let v = result.violations.iter().find(|v| {
3915            v.requirement.contains("PRE-7-RQ-07-RE") && v.severity == ViolationSeverity::Error
3916        });
3917        assert!(
3918            v.is_some(),
3919            "40% vendor-hash coverage should produce an Error under CraPhase2"
3920        );
3921    }
3922
3923    #[test]
3924    fn vendor_hash_coverage_no_vendor_components_no_violation() {
3925        // SBOM with only synthetic-ID components — no vendor classification, no violation
3926        let mut sbom = NormalizedSbom::default();
3927        use crate::model::Component;
3928        for n in ["a", "b", "c"] {
3929            let c = Component::new(n.to_string(), n.to_string());
3930            sbom.components.insert(c.canonical_id.clone(), c);
3931        }
3932        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3933        assert!(
3934            !result
3935                .violations
3936                .iter()
3937                .any(|v| v.requirement.contains("PRE-7-RQ-07-RE")),
3938            "No vendor components → no [PRE-7-RQ-07-RE] violation"
3939        );
3940    }
3941
3942    // ──────────────────────────────────────────────────────────────────
3943    // P2 tests
3944    // ──────────────────────────────────────────────────────────────────
3945
3946    #[test]
3947    fn art_13_2_warns_when_no_risk_assessment_referenced() {
3948        let sbom = NormalizedSbom::default();
3949        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3950        let v = result.violations.iter().find(|v| {
3951            v.requirement.contains("Art. 13(2)") && v.severity == ViolationSeverity::Warning
3952        });
3953        assert!(v.is_some(), "Empty SBOM should produce Art. 13(2) Warning");
3954    }
3955
3956    #[test]
3957    fn art_13_2_silenced_by_sidecar_risk_assessment_url() {
3958        use crate::model::CraSidecarMetadata;
3959        let sbom = NormalizedSbom::default();
3960        let sidecar = CraSidecarMetadata {
3961            risk_assessment_url: Some("https://example.com/ra.pdf".to_string()),
3962            ..Default::default()
3963        };
3964        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
3965            .with_sidecar(sidecar)
3966            .check(&sbom);
3967        assert!(
3968            !result
3969                .violations
3970                .iter()
3971                .any(|v| v.requirement.contains("Art. 13(2)")),
3972            "Sidecar risk_assessment_url should suppress Art. 13(2) violation"
3973        );
3974    }
3975
3976    #[test]
3977    fn article_14_pre_deadline_emits_info_only() {
3978        // The check uses the wall clock; today's date in tests will be
3979        // before/after 2026-09-11 depending on when tests run. We assert
3980        // the *existence* of the readiness violations rather than exact
3981        // severity, then verify with-sidecar suppresses.
3982        let sbom = NormalizedSbom::default();
3983        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
3984        let art14_count = result
3985            .violations
3986            .iter()
3987            .filter(|v| v.requirement.contains("Art. 14"))
3988            .count();
3989        assert!(
3990            art14_count >= 4,
3991            "Art. 14 readiness should produce ≥4 violations (PSIRT, 14(1), 14(2), 14(7)); got {art14_count}"
3992        );
3993    }
3994
3995    /// Pre-deadline (mocked clock 2026-04-26): all four channels missing.
3996    /// PSIRT/14(1)/14(2) surface as Info; 14(7) (ENISA platform) is always Info.
3997    /// Total: 4 Infos, 0 Warnings, 0 Errors at Art. 14 level.
3998    #[test]
3999    fn article_14_pre_deadline_mocked_clock_emits_4_infos() {
4000        let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
4001        let mut violations = Vec::new();
4002        let now = chrono::DateTime::parse_from_rfc3339("2026-04-26T00:00:00Z")
4003            .unwrap()
4004            .with_timezone(&chrono::Utc);
4005        checker.check_article_14_readiness_at(now, &mut violations);
4006
4007        let infos = violations
4008            .iter()
4009            .filter(|v| v.severity == ViolationSeverity::Info && v.requirement.contains("Art. 14"))
4010            .count();
4011        let warnings = violations
4012            .iter()
4013            .filter(|v| {
4014                v.severity == ViolationSeverity::Warning && v.requirement.contains("Art. 14")
4015            })
4016            .count();
4017        assert_eq!(
4018            infos, 4,
4019            "Pre-deadline expects 4 Info-level Art. 14 findings; got {infos} (full list: {violations:?})"
4020        );
4021        assert_eq!(
4022            warnings, 0,
4023            "Pre-deadline expects 0 Warning-level Art. 14 findings"
4024        );
4025    }
4026
4027    /// Post-deadline (mocked clock 2026-12-01): same SBOM-less state, but
4028    /// PSIRT/14(1)/14(2) become Warnings; 14(7) stays Info.
4029    /// Total: 1 Info, 3 Warnings.
4030    #[test]
4031    fn article_14_post_deadline_mocked_clock_emits_3_warnings_1_info() {
4032        let checker = ComplianceChecker::new(ComplianceLevel::CraPhase2);
4033        let mut violations = Vec::new();
4034        let now = chrono::DateTime::parse_from_rfc3339("2026-12-01T00:00:00Z")
4035            .unwrap()
4036            .with_timezone(&chrono::Utc);
4037        checker.check_article_14_readiness_at(now, &mut violations);
4038
4039        let infos = violations
4040            .iter()
4041            .filter(|v| v.severity == ViolationSeverity::Info && v.requirement.contains("Art. 14"))
4042            .count();
4043        let warnings = violations
4044            .iter()
4045            .filter(|v| {
4046                v.severity == ViolationSeverity::Warning && v.requirement.contains("Art. 14")
4047            })
4048            .count();
4049        assert_eq!(
4050            warnings, 3,
4051            "Post-deadline expects 3 Warning-level Art. 14 findings (PSIRT/14(1)/14(2)); got {warnings} (full: {violations:?})"
4052        );
4053        assert_eq!(
4054            infos, 1,
4055            "Post-deadline expects 1 Info-level Art. 14 finding (Art. 14(7) ENISA platform stays Info regardless of date)"
4056        );
4057    }
4058
4059    #[test]
4060    fn article_14_sidecar_suppresses_psirt_warning() {
4061        use crate::model::CraSidecarMetadata;
4062        let sbom = NormalizedSbom::default();
4063        let sidecar = CraSidecarMetadata {
4064            psirt_url: Some("https://example.com/psirt".to_string()),
4065            early_warning_contact: Some("psirt@example.com".to_string()),
4066            incident_report_contact: Some("ir@example.com".to_string()),
4067            ..Default::default()
4068        };
4069        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
4070            .with_sidecar(sidecar)
4071            .check(&sbom);
4072        // PSIRT, 14(1), 14(2) suppressed; 14(7) (ENISA platform) remains as Info.
4073        let art_14_psirt = result
4074            .violations
4075            .iter()
4076            .any(|v| v.requirement.contains("Art. 14: PSIRT"));
4077        let art_14_1 = result
4078            .violations
4079            .iter()
4080            .any(|v| v.requirement.contains("Art. 14(1)"));
4081        let art_14_2 = result
4082            .violations
4083            .iter()
4084            .any(|v| v.requirement.contains("Art. 14(2)"));
4085        assert!(
4086            !art_14_psirt,
4087            "Sidecar psirt_url should suppress PSIRT check"
4088        );
4089        assert!(
4090            !art_14_1,
4091            "Sidecar early_warning_contact should suppress 14(1)"
4092        );
4093        assert!(
4094            !art_14_2,
4095            "Sidecar incident_report_contact should suppress 14(2)"
4096        );
4097    }
4098
4099    #[test]
4100    fn direct_dep_missing_supplier_is_error_under_cra_phase2() {
4101        use crate::model::{Component, DependencyEdge, DependencyType};
4102        let mut sbom = NormalizedSbom::default();
4103        // Primary "app" with one direct dep "lib" missing supplier.
4104        let app = Component::new("app".to_string(), "app".to_string())
4105            .with_purl("pkg:cargo/app@1.0".to_string());
4106        let lib = Component::new("lib".to_string(), "lib".to_string())
4107            .with_purl("pkg:cargo/lib@1.0".to_string());
4108        let app_id = app.canonical_id.clone();
4109        let lib_id = lib.canonical_id.clone();
4110        sbom.primary_component_id = Some(app_id.clone());
4111        sbom.components.insert(app_id.clone(), app);
4112        sbom.components.insert(lib_id.clone(), lib);
4113        sbom.edges.push(DependencyEdge::new(
4114            app_id,
4115            lib_id,
4116            DependencyType::DependsOn,
4117        ));
4118        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
4119        let v = result.violations.iter().find(|v| {
4120            v.requirement.contains("Direct dependency supplier")
4121                && v.severity == ViolationSeverity::Error
4122        });
4123        assert!(
4124            v.is_some(),
4125            "Direct dep without supplier should produce an Error under CraPhase2"
4126        );
4127    }
4128
4129    #[test]
4130    fn transitive_dep_missing_supplier_is_softer_than_direct() {
4131        use crate::model::{Component, DependencyEdge, DependencyType, Organization};
4132        let mut sbom = NormalizedSbom::default();
4133        // app → lib (with supplier) → deep (no supplier)
4134        let mut app = Component::new("app".to_string(), "app".to_string())
4135            .with_purl("pkg:cargo/app@1.0".to_string());
4136        app.supplier = Some(Organization::new("AppCorp".to_string()));
4137        let mut lib = Component::new("lib".to_string(), "lib".to_string())
4138            .with_purl("pkg:cargo/lib@1.0".to_string());
4139        lib.supplier = Some(Organization::new("LibCorp".to_string()));
4140        let deep = Component::new("deep".to_string(), "deep".to_string())
4141            .with_purl("pkg:cargo/deep@1.0".to_string());
4142        let app_id = app.canonical_id.clone();
4143        let lib_id = lib.canonical_id.clone();
4144        let deep_id = deep.canonical_id.clone();
4145        sbom.primary_component_id = Some(app_id.clone());
4146        sbom.components.insert(app_id.clone(), app);
4147        sbom.components.insert(lib_id.clone(), lib);
4148        sbom.components.insert(deep_id.clone(), deep);
4149        sbom.edges.push(DependencyEdge::new(
4150            app_id,
4151            lib_id.clone(),
4152            DependencyType::DependsOn,
4153        ));
4154        sbom.edges.push(DependencyEdge::new(
4155            lib_id,
4156            deep_id,
4157            DependencyType::DependsOn,
4158        ));
4159        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2).check(&sbom);
4160        let direct_err = result.violations.iter().any(|v| {
4161            v.requirement.contains("Direct dependency supplier")
4162                && v.severity == ViolationSeverity::Error
4163        });
4164        let transitive = result
4165            .violations
4166            .iter()
4167            .find(|v| v.requirement.contains("Transitive dependency supplier"));
4168        assert!(
4169            !direct_err,
4170            "No direct deps lack a supplier; should not error"
4171        );
4172        assert!(transitive.is_some(), "Transitive dep should be reported");
4173        assert_ne!(
4174            transitive.unwrap().severity,
4175            ViolationSeverity::Error,
4176            "Transitive supplier missing should never be Error (it's recommended, not mandatory)"
4177        );
4178    }
4179
4180    /// Build a component that satisfies every gating BSI v2.1.0 rule:
4181    /// name, version, purl, SPDX licence, supplier, SHA-512 hash.
4182    fn bsi_ok_component(name: &str) -> crate::model::Component {
4183        use crate::model::{Component, Hash, HashAlgorithm, LicenseExpression, Organization};
4184        let mut c = Component::new(name.to_string(), name.to_string())
4185            .with_purl(format!("pkg:cargo/{name}@1.0"))
4186            .with_version("1.0".to_string());
4187        c.hashes
4188            .push(Hash::new(HashAlgorithm::Sha512, "f".repeat(128)));
4189        c.supplier = Some(Organization::new(format!("{name}-vendor")));
4190        c.licenses
4191            .add_declared(LicenseExpression::new("MIT".to_string()));
4192        c
4193    }
4194
4195    #[test]
4196    fn bsi_tr_03183_2_empty_sbom_emits_errors() {
4197        let sbom = NormalizedSbom::default();
4198        let result = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4199        assert!(
4200            result
4201                .violations
4202                .iter()
4203                .any(|v| v.requirement.contains("BSI TR-03183-2 §5.2.1")
4204                    && v.severity == ViolationSeverity::Error),
4205            "Empty SBOM should fail BSI §5.2.1 (creator missing)"
4206        );
4207    }
4208
4209    /// §5.2.2 names SHA-512 in the normative hash clause: MD5-only and
4210    /// SHA-256-only components must fail (expectation flip: SHA-256 used to
4211    /// satisfy the old "SHA-256+" reading), SHA-512 must pass, and a
4212    /// hash-less component is a §3.2.1-aware Warning instead of an Error.
4213    #[test]
4214    fn bsi_tr_03183_2_requires_sha512_hash() {
4215        use crate::model::{Hash, HashAlgorithm};
4216        let check = |alg: Option<HashAlgorithm>, hexlen: usize| {
4217            let mut sbom = NormalizedSbom::default();
4218            let mut c = bsi_ok_component("lib");
4219            c.hashes.clear();
4220            if let Some(alg) = alg {
4221                c.hashes.push(Hash::new(alg, "0".repeat(hexlen)));
4222            }
4223            sbom.add_component(c);
4224            ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom)
4225        };
4226
4227        let md5 = check(Some(HashAlgorithm::Md5), 32);
4228        assert!(
4229            md5.violations
4230                .iter()
4231                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"
4232                    && v.severity == ViolationSeverity::Error),
4233            "MD5-only component must fail the §5.2.2 SHA-512 requirement"
4234        );
4235
4236        let sha256 = check(Some(HashAlgorithm::Sha256), 64);
4237        assert!(
4238            sha256
4239                .violations
4240                .iter()
4241                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"
4242                    && v.severity == ViolationSeverity::Error),
4243            "SHA-256-only component must now FAIL the hash rule (§5.2.2 names SHA-512)"
4244        );
4245
4246        let sha512 = check(Some(HashAlgorithm::Sha512), 128);
4247        assert!(
4248            !sha512
4249                .violations
4250                .iter()
4251                .any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-4")),
4252            "SHA-512 component must satisfy the hash rule"
4253        );
4254
4255        let none = check(None, 0);
4256        assert!(
4257            none.violations
4258                .iter()
4259                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4-MISSING"
4260                    && v.severity == ViolationSeverity::Warning),
4261            "hash-less component must warn (§3.2.1 legitimate-omission escape)"
4262        );
4263        assert!(
4264            !none
4265                .violations
4266                .iter()
4267                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"),
4268            "hash-less component must not also fire the wrong-algorithm Error"
4269        );
4270    }
4271
4272    /// §4 format gate: CycloneDX < 1.6 and SPDX < 3.0.1 fail; the minimums
4273    /// pass; versions are compared numerically, and a synthetic document
4274    /// with no spec_version skips the gate.
4275    #[test]
4276    fn bsi_tr_03183_2_format_gate() {
4277        use crate::model::SbomFormat;
4278        let check = |format: SbomFormat, version: &str| {
4279            let mut sbom = NormalizedSbom::default();
4280            sbom.document.format = format;
4281            sbom.document.spec_version = version.to_string();
4282            sbom.add_component(bsi_ok_component("lib"));
4283            let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4284            r.violations.iter().any(|v| {
4285                v.rule_id == "SBOM-BSI-TR-03183-2-4" && v.severity == ViolationSeverity::Error
4286            })
4287        };
4288
4289        assert!(
4290            check(SbomFormat::CycloneDx, "1.5"),
4291            "CycloneDX 1.5 must fail the §4 format gate"
4292        );
4293        assert!(
4294            !check(SbomFormat::CycloneDx, "1.6"),
4295            "CycloneDX 1.6 must pass the §4 format gate"
4296        );
4297        assert!(
4298            !check(SbomFormat::CycloneDx, "1.7"),
4299            "CycloneDX 1.7 must pass the §4 format gate"
4300        );
4301        assert!(
4302            check(SbomFormat::Spdx, "2.3"),
4303            "SPDX 2.3 must fail the §4 format gate"
4304        );
4305        assert!(
4306            check(SbomFormat::Spdx, "3.0"),
4307            "SPDX 3.0 is below the 3.0.1 minimum and must fail the §4 gate"
4308        );
4309        assert!(
4310            !check(SbomFormat::Spdx, "3.0.1"),
4311            "SPDX 3.0.1 must pass the §4 format gate"
4312        );
4313        assert!(
4314            !check(SbomFormat::CycloneDx, ""),
4315            "a document with no spec_version must skip the §4 gate"
4316        );
4317    }
4318
4319    /// The TR mandates no generation-tool field in any tier: a Person
4320    /// creator with an email must satisfy §5.2.1 with no tool-related
4321    /// Error (expectation flip: tool absence used to be a gating Error).
4322    #[test]
4323    fn bsi_tr_03183_2_does_not_require_tool_creator() {
4324        use crate::model::{Creator, CreatorType};
4325        let mut sbom = NormalizedSbom::default();
4326        sbom.document.creators.push(Creator {
4327            creator_type: CreatorType::Person,
4328            name: "Jane Doe".to_string(),
4329            email: Some("jane@example.org".to_string()),
4330        });
4331        sbom.add_component(bsi_ok_component("lib"));
4332        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4333        assert!(
4334            !r.violations.iter().any(|v| v.message.contains("tool")),
4335            "no violation may demand a generation tool (not mandated in any TR tier)"
4336        );
4337        assert!(
4338            !r.violations
4339                .iter()
4340                .any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-1")),
4341            "a Person creator with an email satisfies §5.2.1 entirely"
4342        );
4343    }
4344
4345    /// §5.2.1 creator granularity: absent entirely → Error; present without
4346    /// email/URL → Warning; present with email → silent.
4347    #[test]
4348    fn bsi_tr_03183_2_creator_contact_granularity() {
4349        use crate::model::{Creator, CreatorType};
4350        let base = || {
4351            let mut sbom = NormalizedSbom::default();
4352            sbom.add_component(bsi_ok_component("lib"));
4353            sbom
4354        };
4355
4356        let empty = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&base());
4357        assert!(
4358            empty
4359                .violations
4360                .iter()
4361                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1"
4362                    && v.severity == ViolationSeverity::Error),
4363            "no creator at all must be a §5.2.1 Error"
4364        );
4365
4366        let mut contactless = base();
4367        contactless.document.creators.push(Creator {
4368            creator_type: CreatorType::Organization,
4369            name: "Acme".to_string(),
4370            email: None,
4371        });
4372        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&contactless);
4373        assert!(
4374            r.violations
4375                .iter()
4376                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1-CONTACT"
4377                    && v.severity == ViolationSeverity::Warning),
4378            "a creator without email/URL must be a §5.2.1 Warning"
4379        );
4380        assert!(
4381            !r.violations
4382                .iter()
4383                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1"),
4384            "a contactless creator is present — the absence Error must not fire"
4385        );
4386
4387        let mut with_url = base();
4388        with_url.document.creators.push(Creator {
4389            creator_type: CreatorType::Organization,
4390            name: "https://acme.example".to_string(),
4391            email: None,
4392        });
4393        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&with_url);
4394        assert!(
4395            !r.violations
4396                .iter()
4397                .any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-1")),
4398            "a creator URL satisfies §5.2.1 (URL fallback when no email exists)"
4399        );
4400    }
4401
4402    /// §5.2.1 maps "Creator of the SBOM" to Person/Organization (CycloneDX
4403    /// metadata.authors/manufacturer), explicitly NOT metadata.tools: a
4404    /// tools-only SBOM (the default output of common generators) must fail
4405    /// the creator gate (expectation flip: Tool creators used to satisfy the
4406    /// presence test).
4407    #[test]
4408    fn bsi_tr_03183_2_tools_only_creators_fail_creator_gate() {
4409        use crate::model::{Creator, CreatorType};
4410        let mut sbom = NormalizedSbom::default();
4411        sbom.document.creators.push(Creator {
4412            creator_type: CreatorType::Tool,
4413            name: "syft 1.18.0".to_string(),
4414            email: None,
4415        });
4416        sbom.add_component(bsi_ok_component("lib"));
4417        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4418        assert!(
4419            r.violations
4420                .iter()
4421                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1"
4422                    && v.severity == ViolationSeverity::Error),
4423            "a tools-only creators list must fail the §5.2.1 creator gate"
4424        );
4425
4426        // Silent side: adding an Organization creator with an email (the
4427        // metadata.manufacturer mapping) alongside the tool satisfies §5.2.1.
4428        sbom.document.creators.push(Creator {
4429            creator_type: CreatorType::Organization,
4430            name: "Demo Corp".to_string(),
4431            email: Some("sbom@demo.example".to_string()),
4432        });
4433        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4434        assert!(
4435            !r.violations
4436                .iter()
4437                .any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-1")),
4438            "an Organization creator with email satisfies §5.2.1 entirely"
4439        );
4440    }
4441
4442    /// The §5.2.1 -CONTACT Warning must inspect only non-Tool creators: a
4443    /// tool whose name happens to carry contact-looking characters cannot
4444    /// stand in for the creator's email/URL.
4445    #[test]
4446    fn bsi_tr_03183_2_contact_warning_ignores_tool_contacts() {
4447        use crate::model::{Creator, CreatorType};
4448        let mut sbom = NormalizedSbom::default();
4449        sbom.document.creators.push(Creator {
4450            creator_type: CreatorType::Tool,
4451            name: "sbom-gen (https://sbom-gen.example)".to_string(),
4452            email: None,
4453        });
4454        sbom.document.creators.push(Creator {
4455            creator_type: CreatorType::Organization,
4456            name: "Acme".to_string(),
4457            email: None,
4458        });
4459        sbom.add_component(bsi_ok_component("lib"));
4460        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4461        assert!(
4462            r.violations
4463                .iter()
4464                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1-CONTACT"
4465                    && v.severity == ViolationSeverity::Warning),
4466            "a tool's URL must not satisfy the creator contact requirement"
4467        );
4468        assert!(
4469            !r.violations
4470                .iter()
4471                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-1"),
4472            "the Organization creator is present — only the contact Warning may fire"
4473        );
4474    }
4475
4476    /// §5.2.2 SHA-512: only author-attested hashes count. An
4477    /// enrichment-fetched SHA-512 is not part of the document under
4478    /// assessment and must not mask a missing authored SHA-512
4479    /// (HashProvenance::Authored exclusion).
4480    #[test]
4481    fn bsi_tr_03183_2_sha512_counts_only_authored_hashes() {
4482        use crate::model::{Hash, HashAlgorithm};
4483        let check = |hashes: Vec<Hash>| {
4484            let mut sbom = NormalizedSbom::default();
4485            let mut c = bsi_ok_component("lib");
4486            c.hashes = hashes;
4487            sbom.add_component(c);
4488            ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom)
4489        };
4490
4491        // Authored SHA-256 + ENRICHED SHA-512: the SHA-512 Error must still
4492        // fire — the enriched hash is not author-attested.
4493        let r = check(vec![
4494            Hash::new(HashAlgorithm::Sha256, "0".repeat(64)),
4495            Hash::enriched(HashAlgorithm::Sha512, "0".repeat(128)),
4496        ]);
4497        assert!(
4498            r.violations
4499                .iter()
4500                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"
4501                    && v.severity == ViolationSeverity::Error),
4502            "an enriched SHA-512 must not mask a missing authored SHA-512"
4503        );
4504
4505        // Authored SHA-512: silent.
4506        let r = check(vec![Hash::new(HashAlgorithm::Sha512, "f".repeat(128))]);
4507        assert!(
4508            !r.violations
4509                .iter()
4510                .any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-5-4")),
4511            "an authored SHA-512 satisfies §5.2.2"
4512        );
4513
4514        // Enriched hashes only: the component counts as hash-less
4515        // (§3.2.1-aware Warning), not as carrying the wrong algorithm.
4516        let r = check(vec![Hash::enriched(HashAlgorithm::Sha512, "0".repeat(128))]);
4517        assert!(
4518            r.violations
4519                .iter()
4520                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4-MISSING"
4521                    && v.severity == ViolationSeverity::Warning),
4522            "enriched-only hashes must count as no authored hash at all"
4523        );
4524        assert!(
4525            !r.violations
4526                .iter()
4527                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-4"),
4528            "enriched-only hashes must not fire the wrong-algorithm Error"
4529        );
4530    }
4531
4532    /// §5.2.2 component version is a required field: absence gates.
4533    #[test]
4534    fn bsi_tr_03183_2_gates_on_missing_version() {
4535        let mut sbom = NormalizedSbom::default();
4536        let mut c = bsi_ok_component("lib");
4537        c.version = None;
4538        sbom.add_component(c);
4539        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4540        assert!(
4541            r.violations
4542                .iter()
4543                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-VERSION"
4544                    && v.severity == ViolationSeverity::Error),
4545            "a version-less component must fail BSI §5.2.2"
4546        );
4547
4548        let mut ok = NormalizedSbom::default();
4549        ok.add_component(bsi_ok_component("lib"));
4550        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&ok);
4551        assert!(
4552            !r.violations
4553                .iter()
4554                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-VERSION"),
4555            "a versioned component must not fire the version rule"
4556        );
4557    }
4558
4559    /// §5.2.2 distribution licences are required (Error when absent);
4560    /// §6.1 requires SPDX naming (Warning when the expression is not SPDX).
4561    #[test]
4562    fn bsi_tr_03183_2_licence_rules() {
4563        use crate::model::LicenseExpression;
4564        let mut unlicensed = NormalizedSbom::default();
4565        let mut c = bsi_ok_component("lib");
4566        c.licenses.declared.clear();
4567        c.licenses.concluded = None;
4568        unlicensed.add_component(c);
4569        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&unlicensed);
4570        assert!(
4571            r.violations
4572                .iter()
4573                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-LICENSE"
4574                    && v.severity == ViolationSeverity::Error),
4575            "a component without distribution licences must fail §5.2.2"
4576        );
4577
4578        let mut non_spdx = NormalizedSbom::default();
4579        let mut c = bsi_ok_component("lib");
4580        c.licenses.declared.clear();
4581        c.licenses.add_declared(LicenseExpression::new(
4582            "Standard Commercial Terms, see LICENSE.txt".to_string(),
4583        ));
4584        non_spdx.add_component(c);
4585        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&non_spdx);
4586        assert!(
4587            r.violations
4588                .iter()
4589                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-LICENSE-SPDX"
4590                    && v.severity == ViolationSeverity::Warning),
4591            "a non-SPDX licence expression must warn under §6.1"
4592        );
4593        assert!(
4594            !r.violations
4595                .iter()
4596                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-LICENSE"),
4597            "a declared (non-SPDX) licence still counts as present"
4598        );
4599
4600        let mut spdx_ok = NormalizedSbom::default();
4601        spdx_ok.add_component(bsi_ok_component("lib"));
4602        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&spdx_ok);
4603        assert!(
4604            !r.violations
4605                .iter()
4606                .any(|v| v.rule_id.starts_with("SBOM-BSI-TR-03183-2-LICENSE")),
4607            "an SPDX-named licence satisfies both §5.2.2 and §6.1"
4608        );
4609    }
4610
4611    /// §5.2.2 component creator: presence-level Warning when both supplier
4612    /// and author are absent.
4613    #[test]
4614    fn bsi_tr_03183_2_warns_on_missing_component_creator() {
4615        let mut sbom = NormalizedSbom::default();
4616        let mut c = bsi_ok_component("lib");
4617        c.supplier = None;
4618        c.author = None;
4619        sbom.add_component(c);
4620        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4621        assert!(
4622            r.violations
4623                .iter()
4624                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-CREATOR"
4625                    && v.severity == ViolationSeverity::Warning),
4626            "a component without supplier/author must warn under §5.2.2"
4627        );
4628
4629        let mut ok = NormalizedSbom::default();
4630        ok.add_component(bsi_ok_component("lib"));
4631        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&ok);
4632        assert!(
4633            !r.violations
4634                .iter()
4635                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-CREATOR"),
4636            "a supplied component must not fire the creator rule"
4637        );
4638    }
4639
4640    /// §5.2.4 puts purl/CPE in the ADDITIONAL tier ("MUST additionally …
4641    /// if it exists"): absence is a Warning, no longer a gating Error
4642    /// (expectation flip).
4643    #[test]
4644    fn bsi_tr_03183_2_identifier_is_warning_not_error() {
4645        let mut sbom = NormalizedSbom::default();
4646        let mut c = bsi_ok_component("lib");
4647        c.identifiers.purl = None;
4648        c.canonical_id = c.identifiers.canonical_id();
4649        sbom.add_component(c);
4650        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4651        let id_violations: Vec<_> = r
4652            .violations
4653            .iter()
4654            .filter(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-2-4")
4655            .collect();
4656        assert!(
4657            !id_violations.is_empty(),
4658            "a purl-less component must fire the §5.2.4 identifier rule"
4659        );
4660        assert!(
4661            id_violations
4662                .iter()
4663                .all(|v| v.severity == ViolationSeverity::Warning),
4664            "the §5.2.4 identifier rule is additional-tier: Warning, not Error"
4665        );
4666    }
4667
4668    /// §5.2.2 requires the completeness of the dependency enumeration to be
4669    /// clearly indicated (CycloneDX compositions).
4670    #[test]
4671    fn bsi_tr_03183_2_warns_on_undeclared_completeness() {
4672        use crate::model::CompletenessDeclaration;
4673        let mut sbom = NormalizedSbom::default();
4674        sbom.add_component(bsi_ok_component("lib"));
4675        // Default is Unknown → warn.
4676        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4677        assert!(
4678            r.violations
4679                .iter()
4680                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-5-COMPLETENESS"
4681                    && v.severity == ViolationSeverity::Warning),
4682            "an SBOM without a completeness declaration must warn under §5.2.2"
4683        );
4684
4685        sbom.document.completeness_declaration = CompletenessDeclaration::Complete;
4686        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4687        assert!(
4688            !r.violations
4689                .iter()
4690                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-5-5-COMPLETENESS"),
4691            "a declared completeness must not warn"
4692        );
4693    }
4694
4695    /// §3.1: an SBOM MUST NOT contain vulnerability information — a combined
4696    /// document does not conform (Warning; enrichment may attach findings).
4697    #[test]
4698    fn bsi_tr_03183_2_warns_on_embedded_vulnerabilities() {
4699        use crate::model::{VulnerabilityRef, VulnerabilitySource};
4700        let mut sbom = NormalizedSbom::default();
4701        let mut c = bsi_ok_component("lib");
4702        c.vulnerabilities.push(VulnerabilityRef::new(
4703            "CVE-2026-0001".to_string(),
4704            VulnerabilitySource::Cve,
4705        ));
4706        sbom.add_component(c);
4707        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4708        assert!(
4709            r.violations
4710                .iter()
4711                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-3-1"
4712                    && v.severity == ViolationSeverity::Warning),
4713            "embedded vulnerability information must warn under §3.1"
4714        );
4715
4716        let mut clean = NormalizedSbom::default();
4717        clean.add_component(bsi_ok_component("lib"));
4718        let r = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&clean);
4719        assert!(
4720            !r.violations
4721                .iter()
4722                .any(|v| v.rule_id == "SBOM-BSI-TR-03183-2-3-1"),
4723            "a vulnerability-free SBOM must not fire §3.1"
4724        );
4725    }
4726
4727    #[test]
4728    fn bsi_tr_03183_2_passes_for_complete_component() {
4729        use crate::model::{Creator, CreatorType, DependencyEdge, DependencyType};
4730        let mut sbom = NormalizedSbom::default();
4731        sbom.document.creators.push(Creator {
4732            creator_type: CreatorType::Person,
4733            name: "Release Engineering".to_string(),
4734            email: Some("sbom@example.org".to_string()),
4735        });
4736        let a = bsi_ok_component("a");
4737        let b = bsi_ok_component("b");
4738        let a_id = a.canonical_id.clone();
4739        let b_id = b.canonical_id.clone();
4740        sbom.components.insert(a_id.clone(), a);
4741        sbom.components.insert(b_id.clone(), b);
4742        sbom.edges
4743            .push(DependencyEdge::new(a_id, b_id, DependencyType::DependsOn));
4744
4745        let result = ComplianceChecker::new(ComplianceLevel::BsiTr03183_2).check(&sbom);
4746        let errors: Vec<_> = result
4747            .violations
4748            .iter()
4749            .filter(|v| v.severity == ViolationSeverity::Error)
4750            .collect();
4751        assert!(
4752            errors.is_empty(),
4753            "Complete BSI-compliant SBOM should produce no Errors; got: {errors:?}"
4754        );
4755    }
4756
4757    #[test]
4758    fn bsi_tr_03183_2_in_compliance_level_all() {
4759        assert_eq!(ComplianceLevel::all().len(), 19);
4760        assert!(ComplianceLevel::all().contains(&ComplianceLevel::BsiTr03183_2));
4761        assert!(ComplianceLevel::all().contains(&ComplianceLevel::CraOssSteward));
4762        assert!(ComplianceLevel::all().contains(&ComplianceLevel::EuccSubstantial));
4763        assert!(ComplianceLevel::all().contains(&ComplianceLevel::EuAiAct));
4764        assert!(ComplianceLevel::all().contains(&ComplianceLevel::BsiSbomForAi));
4765        assert!(ComplianceLevel::all().contains(&ComplianceLevel::Cisa2026));
4766        assert!(ComplianceLevel::all().contains(&ComplianceLevel::PciDss632));
4767        assert!(ComplianceLevel::all().contains(&ComplianceLevel::Fsct));
4768    }
4769
4770    #[test]
4771    fn sidecar_does_not_override_present_sbom_field() {
4772        use crate::model::{CraSidecarMetadata, Creator, CreatorType};
4773        let mut sbom = NormalizedSbom::default();
4774        sbom.document.creators.push(Creator {
4775            creator_type: CreatorType::Organization,
4776            name: "SbomDeclaredCorp".to_string(),
4777            email: None,
4778        });
4779        let sidecar = CraSidecarMetadata {
4780            manufacturer_name: Some("SidecarCorp".to_string()),
4781            ..Default::default()
4782        };
4783        let result = ComplianceChecker::new(ComplianceLevel::CraPhase2)
4784            .with_sidecar(sidecar)
4785            .check(&sbom);
4786        // No Art. 13(16) violation at all because SBOM provides org
4787        assert!(
4788            !result.violations.iter().any(|v| v
4789                .requirement
4790                .contains("Art. 13(16): Manufacturer identification")),
4791            "When SBOM provides manufacturer, no Art. 13(16) violation should be emitted"
4792        );
4793    }
4794}