Skip to main content

sbom_tools/model/
cra_sidecar.rs

1//! CRA Sidecar Metadata Support
2//!
3//! Allows loading additional CRA-required metadata from a sidecar file
4//! when the SBOM doesn't contain this information.
5//!
6//! The sidecar file can be JSON or YAML and supplements the SBOM with:
7//! - Security contact information
8//! - Vulnerability disclosure URLs
9//! - Support end dates
10//! - Manufacturer details
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::path::Path;
16
17/// CRA sidecar metadata that supplements SBOM information
18///
19/// `deny_unknown_fields` makes a typo'd key (e.g. snake_case
20/// `security_contact` instead of `securityContact`) a load error instead of
21/// silently deserializing to an all-`None` sidecar that then fails
22/// compliance checks for the wrong reason.
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct CraSidecarMetadata {
26    /// Security contact email or URL for vulnerability disclosure
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub security_contact: Option<String>,
29
30    /// URL for vulnerability disclosure policy/portal
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub vulnerability_disclosure_url: Option<String>,
33
34    /// End of support/security updates date
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub support_end_date: Option<DateTime<Utc>>,
37
38    /// Manufacturer/vendor name (supplements SBOM creator info)
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub manufacturer_name: Option<String>,
41
42    /// Manufacturer contact email
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub manufacturer_email: Option<String>,
45
46    /// Product name (supplements SBOM document name)
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub product_name: Option<String>,
49
50    /// Product version
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub product_version: Option<String>,
53
54    /// CE marking declaration reference (URL or document ID)
55    #[serde(skip_serializing_if = "Option::is_none")]
56    pub ce_marking_reference: Option<String>,
57
58    /// Security update delivery mechanism description
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub update_mechanism: Option<String>,
61
62    // -------- CRA Article 14 reporting-readiness fields (apply 2026-09-11) --------
63    /// PSIRT (Product Security Incident Response Team) public URL.
64    /// Required to handle external vulnerability reports under Annex I Part II
65    /// and Art. 14 incident reporting.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub psirt_url: Option<String>,
68
69    /// Channel (email, URL, phone) for the 24-hour early-warning notification
70    /// to ENISA / CSIRT under CRA Art. 14(1) when an actively-exploited
71    /// vulnerability is identified.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub early_warning_contact: Option<String>,
74
75    /// Channel for the 72-hour incident report under CRA Art. 14(2).
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub incident_report_contact: Option<String>,
78
79    /// Manufacturer-side identifier for the ENISA single reporting platform
80    /// (Art. 14(7)). Until ENISA publishes the technical interface this is a
81    /// placeholder string — typically a manufacturer registration ID.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub enisa_reporting_platform_id: Option<String>,
84
85    /// Coordinated vulnerability disclosure policy URL.
86    /// Distinct from `vulnerability_disclosure_url` (which may point at a
87    /// portal) — this is the published *policy* that meets CRA Annex I
88    /// Part II (5) / Art. 13(8) and ISO/IEC 29147 expectations.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub coordinated_disclosure_policy_url: Option<String>,
91
92    // -------- CRA Article 13(2) risk-assessment fields --------
93    /// URL or document reference for the documented risk assessment
94    /// required by CRA Art. 13(2); Art. 13(4) requires it to be included
95    /// in the Annex VII technical documentation.
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub risk_assessment_url: Option<String>,
98
99    /// Methodology used for the risk assessment (e.g.,
100    /// "ISO/IEC 27005:2022", "NIST SP 800-30 r1", "ETSI TS 102 165-1 TVRA").
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub risk_assessment_methodology: Option<String>,
103
104    // -------- CRA Annex III/IV product class & conformity-assessment route --------
105    /// CRA product class drives the conformity-assessment route and the
106    /// severity calibration of compliance checks (vendor-hash coverage,
107    /// PSIRT, EUCC reference, attestation).
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub product_class: Option<CraProductClass>,
110
111    /// Conformity-assessment route per CRA Annex VIII (Module A self-assessment,
112    /// B+C EU-type examination, H full QA, or EUCC). Sidecar value wins over
113    /// any CLI-provided default.
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub conformity_assessment_route: Option<ConformityRoute>,
116
117    // -------- CRA Article 24 — open-source steward profile --------
118    /// Whether this product is supplied by an open-source software steward
119    /// (CRA Art. 24). When `true`, manufacturer-only obligations (DoC,
120    /// notified-body attestation, manufacturer email) are not enforced;
121    /// SBOM, vulnerability-handling, and CVD policy are still required.
122    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
123    pub is_oss_steward: bool,
124
125    // -------- Adjacent regulation overlap (CRA-P4.4) --------
126    /// True if the manufacturer is a NIS2 essential entity (Annex I of
127    /// Directive (EU) 2022/2555). Triggers Art. 23 incident-reporting
128    /// guidance in the cra-docs dossier.
129    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
130    pub is_nis2_essential_entity: bool,
131
132    /// True if the manufacturer is a NIS2 important entity (Annex II of
133    /// Directive (EU) 2022/2555).
134    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
135    pub is_nis2_important_entity: bool,
136
137    /// True when the product processes personal data (GDPR Art. 32
138    /// security-of-processing applies in parallel to CRA Annex I).
139    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
140    pub processes_personal_data: bool,
141
142    /// True when the product is a high-risk AI system per the AI Act
143    /// (Regulation (EU) 2024/1689). AI-Act conformity coordination must
144    /// be handled alongside CRA Module assessment.
145    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
146    pub is_high_risk_ai: bool,
147
148    /// Date until which the Radio Equipment Directive (RED, Directive
149    /// 2014/53/EU) cybersecurity provisions still apply for this product.
150    /// CRA repeals RED Art. 3(3)(d/e/f) on 2025-08-01; older device
151    /// inventories may carry RED references through their support
152    /// horizon.
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub red_repealed_until: Option<DateTime<Utc>>,
155
156    // -------- EUCC Substantial (CRA-P5.4 reference profile) --------
157    /// Common Criteria Protection Profile identifier (e.g., "PP-CC-MFR-2024-01").
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub eucc_protection_profile_id: Option<String>,
160
161    /// Common Criteria Target of Evaluation reference (URL or document ID).
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub eucc_target_of_evaluation: Option<String>,
164
165    /// IT Security Evaluation Facility (ITSEF) identifier — the accredited
166    /// laboratory that performed the EUCC evaluation.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub eucc_itsef_identifier: Option<String>,
169
170    /// EUCC certificate valid-until date.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub eucc_valid_until: Option<DateTime<Utc>>,
173
174    // -------- prEN 40000-1-2/1-4 controls-assertion (CRA-P5.5) --------
175    /// Per-control assertions for CRA Annex I Part I, keyed by control ID
176    /// (e.g., `"1.a"` through `"1.l"` for §1, `"2.a"` through `"2.m"` for
177    /// §2 vulnerability-handling). Each entry records whether the
178    /// manufacturer claims the control is satisfied, the evidence URL,
179    /// and the methodology used.
180    ///
181    /// `BTreeMap` for deterministic ordering in dossier output.
182    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
183    pub annex_i_part_i_controls: BTreeMap<String, ControlAssertion>,
184}
185
186/// A manufacturer-supplied assertion that a specific Annex I Part I control
187/// is satisfied. Surfaced verbatim in the cra-docs technical-documentation
188/// dossier and cross-checked by `ComplianceChecker` (a control claimed
189/// `satisfied = true` without an `evidence_url` is flagged as a Warning).
190#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase", deny_unknown_fields)]
192pub struct ControlAssertion {
193    /// Whether the manufacturer claims this control is satisfied.
194    #[serde(default)]
195    pub satisfied: bool,
196    /// URL pointing at the evidence document (test report, design review,
197    /// SAST/DAST output, etc.). Required when `satisfied = true`.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub evidence_url: Option<String>,
200    /// Methodology / standard the assertion was made against
201    /// (e.g., `"prEN 40000-1-2 §5.3"`, `"OWASP ASVS L2"`,
202    /// `"NIST SP 800-53 SI-10"`).
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub methodology: Option<String>,
205    /// Free-form notes from the manufacturer (rationale, caveats).
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub note: Option<String>,
208}
209
210/// CRA product class per Regulation (EU) 2024/2847 Annex III/IV.
211///
212/// The class drives the conformity-assessment route and the severity
213/// calibration of compliance checks (per CRA-P3.2 calibration table):
214/// stricter classes upgrade Warning→Error and add EUCC / attestation
215/// expectations.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
217#[non_exhaustive]
218pub enum CraProductClass {
219    /// Default — neither Annex III nor Annex IV. Module A self-assessment.
220    #[serde(rename = "default")]
221    Default,
222    /// Annex III items 1–11 (Important Class I). Module A or B+C.
223    #[serde(
224        rename = "important-class-1",
225        alias = "important1",
226        alias = "ImportantClass1"
227    )]
228    ImportantClass1,
229    /// Annex III items 12–17 (Important Class II). Module B+C, H, or EUCC.
230    #[serde(
231        rename = "important-class-2",
232        alias = "important2",
233        alias = "ImportantClass2"
234    )]
235    ImportantClass2,
236    /// Annex IV (Critical). EUCC mandatory.
237    #[serde(rename = "critical")]
238    Critical,
239}
240
241impl CraProductClass {
242    /// Short label for compact display.
243    #[must_use]
244    pub const fn label(self) -> &'static str {
245        match self {
246            Self::Default => "Default",
247            Self::ImportantClass1 => "Important-1",
248            Self::ImportantClass2 => "Important-2",
249            Self::Critical => "Critical",
250        }
251    }
252
253    /// Long human-readable name including Annex reference.
254    #[must_use]
255    pub const fn name(self) -> &'static str {
256        match self {
257            Self::Default => "Default (no Annex)",
258            Self::ImportantClass1 => "Important Class I (Annex III items 1–11)",
259            Self::ImportantClass2 => "Important Class II (Annex III items 12–17)",
260            Self::Critical => "Critical (Annex IV)",
261        }
262    }
263
264    /// Parse from the CLI-friendly kebab-case form. Accepts a few aliases.
265    #[must_use]
266    pub fn parse_cli(s: &str) -> Option<Self> {
267        match s.to_ascii_lowercase().as_str() {
268            "default" | "none" => Some(Self::Default),
269            "important-class-1" | "important-1" | "important1" | "annex-iii-1" => {
270                Some(Self::ImportantClass1)
271            }
272            "important-class-2" | "important-2" | "important2" | "annex-iii-2" => {
273                Some(Self::ImportantClass2)
274            }
275            "critical" | "annex-iv" => Some(Self::Critical),
276            _ => None,
277        }
278    }
279
280    /// Strict variant of [`Self::parse_cli`]: an unrecognized spelling is an
281    /// error naming the valid values instead of `None`.
282    ///
283    /// Used by the `--cra-product-class` CLI flags and the config-file
284    /// validator so a typo can never be silently dropped (which would score
285    /// the SBOM as `Default` class and flip CRA verdicts).
286    ///
287    /// # Errors
288    /// Returns a message listing the valid canonical spellings when `s` is
289    /// not a recognized product class.
290    pub fn parse_cli_strict(s: &str) -> Result<Self, String> {
291        Self::parse_cli(s).ok_or_else(|| {
292            format!(
293                "Invalid product class '{s}'. Valid options: \
294                 default, important-class-1, important-class-2, critical"
295            )
296        })
297    }
298
299    /// The conformity-assessment route the regulation expects (or strictly
300    /// requires) for this class. Manufacturers may choose a stricter route.
301    #[must_use]
302    pub const fn default_route(self) -> ConformityRoute {
303        match self {
304            Self::Default | Self::ImportantClass1 => ConformityRoute::ModuleA,
305            Self::ImportantClass2 => ConformityRoute::ModuleBC,
306            Self::Critical => ConformityRoute::Eucc,
307        }
308    }
309}
310
311/// Conformity-assessment module per CRA Annex VIII.
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
313#[serde(rename_all = "kebab-case")]
314#[non_exhaustive]
315pub enum ConformityRoute {
316    /// Module A — internal control / self-assessment.
317    ModuleA,
318    /// Module B+C — EU-type examination plus production conformity.
319    ModuleBC,
320    /// Module H — full quality assurance.
321    ModuleH,
322    /// EUCC — Common Criteria via European Cybersecurity Certification scheme.
323    Eucc,
324}
325
326impl ConformityRoute {
327    /// Short label.
328    #[must_use]
329    pub const fn label(self) -> &'static str {
330        match self {
331            Self::ModuleA => "Module A",
332            Self::ModuleBC => "Module B+C",
333            Self::ModuleH => "Module H",
334            Self::Eucc => "EUCC",
335        }
336    }
337
338    /// Long descriptive name.
339    #[must_use]
340    pub const fn name(self) -> &'static str {
341        match self {
342            Self::ModuleA => "Module A — internal control (self-assessment)",
343            Self::ModuleBC => "Module B+C — EU-type examination + production conformity",
344            Self::ModuleH => "Module H — full quality assurance",
345            Self::Eucc => "EUCC — Common Criteria via EU certification scheme",
346        }
347    }
348
349    /// Parse from the CLI-friendly kebab-case form.
350    #[must_use]
351    pub fn parse_cli(s: &str) -> Option<Self> {
352        match s.to_ascii_lowercase().as_str() {
353            "module-a" | "a" | "self-assessment" => Some(Self::ModuleA),
354            "module-bc" | "module-b+c" | "module-b-c" | "bc" | "b+c" => Some(Self::ModuleBC),
355            "module-h" | "h" => Some(Self::ModuleH),
356            "eucc" | "common-criteria" => Some(Self::Eucc),
357            _ => None,
358        }
359    }
360}
361
362/// Format a serde parse error for sidecar files. serde already names the
363/// offending key on unknown-field errors; append a camelCase hint because the
364/// most common mistake is writing SBOM-style snake_case keys.
365fn sidecar_parse_error(e: &dyn std::fmt::Display) -> CraSidecarError {
366    let msg = e.to_string();
367    if msg.contains("unknown field") {
368        CraSidecarError::ParseError(format!(
369            "{msg}. Note: CRA sidecar keys use camelCase (e.g. `securityContact`, \
370             not `security_contact`)"
371        ))
372    } else {
373        CraSidecarError::ParseError(msg)
374    }
375}
376
377impl CraSidecarMetadata {
378    /// Load sidecar metadata from a JSON file
379    pub fn from_json_file(path: &Path) -> Result<Self, CraSidecarError> {
380        let content =
381            std::fs::read_to_string(path).map_err(|e| CraSidecarError::IoError(e.to_string()))?;
382        serde_json::from_str(&content).map_err(|e| sidecar_parse_error(&e))
383    }
384
385    /// Load sidecar metadata from a YAML file
386    pub fn from_yaml_file(path: &Path) -> Result<Self, CraSidecarError> {
387        let content =
388            std::fs::read_to_string(path).map_err(|e| CraSidecarError::IoError(e.to_string()))?;
389        serde_yaml_ng::from_str(&content).map_err(|e| sidecar_parse_error(&e))
390    }
391
392    /// Load sidecar metadata, auto-detecting format from extension
393    pub fn from_file(path: &Path) -> Result<Self, CraSidecarError> {
394        let extension = path
395            .extension()
396            .and_then(|e| e.to_str())
397            .unwrap_or("")
398            .to_lowercase();
399
400        match extension.as_str() {
401            "json" => Self::from_json_file(path),
402            "yaml" | "yml" => Self::from_yaml_file(path),
403            _ => Err(CraSidecarError::UnsupportedFormat(extension)),
404        }
405    }
406
407    /// Try to find and load a sidecar file for the given SBOM path.
408    ///
409    /// Looks for `<stem>.cra.{json,yaml,yml}` and `<stem>-cra.{json,yaml,yml}`
410    /// alongside the SBOM. Multi-extension stems (`app.cdx.json`,
411    /// `app.spdx.json`, `app.spdx3.json`) also try the inner stem
412    /// (`app.cra.json`) so the common SBOM naming conventions work
413    /// without forcing operators to repeat the format suffix.
414    ///
415    /// Discovery is **strict**: once a candidate file exists, it must load.
416    /// A broken discovered sidecar (typo'd field, bad YAML) is a hard error
417    /// naming the file and field — exactly like an explicitly passed
418    /// `--cra-sidecar` — instead of a stderr warning that silently shifts
419    /// the compliance verdict.
420    ///
421    /// Stdin input (`-`) has no adjacent files, so it never resolves a
422    /// sidecar (previously this probed a literal `-.cra.json`).
423    pub fn discover_for_sbom(sbom_path: &Path) -> Result<Option<Self>, CraSidecarError> {
424        // Stdin: no directory to discover in.
425        if sbom_path.as_os_str() == "-" {
426            return Ok(None);
427        }
428        let Some(parent) = sbom_path.parent() else {
429            return Ok(None);
430        };
431        let Some(stem) = sbom_path.file_stem().and_then(|s| s.to_str()) else {
432            return Ok(None);
433        };
434
435        // Build the list of stems to try. Strip well-known SBOM format
436        // suffixes (`.cdx`, `.spdx`, `.spdx3`, `.cyclonedx`) so e.g.
437        // `app.cdx.json` looks for `app.cra.json` as well as
438        // `app.cdx.cra.json`.
439        let mut stems: Vec<&str> = vec![stem];
440        for suffix in [".cdx", ".cyclonedx", ".spdx", ".spdx3"] {
441            if let Some(inner) = stem.strip_suffix(suffix)
442                && !inner.is_empty()
443            {
444                stems.push(inner);
445            }
446        }
447
448        for s in &stems {
449            for pattern in [
450                format!("{s}.cra.json"),
451                format!("{s}.cra.yaml"),
452                format!("{s}.cra.yml"),
453                format!("{s}-cra.json"),
454                format!("{s}-cra.yaml"),
455                format!("{s}-cra.yml"),
456            ] {
457                let sidecar_path = parent.join(&pattern);
458                if sidecar_path.exists() {
459                    // First existing candidate is authoritative: it either
460                    // loads or the command fails naming the broken file.
461                    return Self::from_file(&sidecar_path)
462                        .map(Some)
463                        .map_err(|e| e.with_path(&sidecar_path));
464                }
465            }
466        }
467
468        Ok(None)
469    }
470
471    /// Lenient wrapper around [`Self::discover_for_sbom`] that downgrades a
472    /// broken discovered sidecar to a warning.
473    ///
474    /// Transitional: call sites should migrate to [`Self::discover_for_sbom`]
475    /// so a broken sidecar aborts the command instead of silently shifting
476    /// the verdict; this wrapper only exists to keep the old signature alive
477    /// until they do.
478    #[must_use]
479    pub fn find_for_sbom(sbom_path: &Path) -> Option<Self> {
480        match Self::discover_for_sbom(sbom_path) {
481            Ok(found) => found,
482            Err(e) => {
483                tracing::warn!("Ignoring auto-discovered CRA sidecar: {e}");
484                None
485            }
486        }
487    }
488
489    /// Whether the sidecar carries live EUCC evidence: a non-empty
490    /// Protection-Profile / Target-of-Evaluation / ITSEF identifier, or a
491    /// certificate validity date that has not expired. Empty strings and
492    /// expired certificates must not satisfy the Critical-class Annex IV
493    /// gate (mirrors the dedicated EUCC profile's semantics).
494    #[must_use]
495    pub fn has_live_eucc_evidence(&self) -> bool {
496        self.has_live_eucc_evidence_at(Utc::now())
497    }
498
499    /// [`Self::has_live_eucc_evidence`] against a pinned evaluation instant
500    /// (reproducible compliance runs; see `ComplianceChecker::with_as_of`).
501    #[must_use]
502    pub fn has_live_eucc_evidence_at(&self, now: DateTime<Utc>) -> bool {
503        let live = |v: &Option<String>| v.as_deref().is_some_and(|s| !s.trim().is_empty());
504        live(&self.eucc_protection_profile_id)
505            || live(&self.eucc_target_of_evaluation)
506            || live(&self.eucc_itsef_identifier)
507            || self.eucc_valid_until.is_some_and(|d| d > now)
508    }
509
510    /// Check if any CRA-relevant fields are populated
511    #[must_use]
512    pub fn has_cra_data(&self) -> bool {
513        self.security_contact.is_some()
514            || self.vulnerability_disclosure_url.is_some()
515            || self.support_end_date.is_some()
516            || self.manufacturer_name.is_some()
517            || self.ce_marking_reference.is_some()
518            || self.psirt_url.is_some()
519            || self.early_warning_contact.is_some()
520            || self.incident_report_contact.is_some()
521            || self.enisa_reporting_platform_id.is_some()
522            || self.coordinated_disclosure_policy_url.is_some()
523            || self.risk_assessment_url.is_some()
524            || self.risk_assessment_methodology.is_some()
525            || self.product_class.is_some()
526            || self.conformity_assessment_route.is_some()
527            || self.is_oss_steward
528            || self.is_nis2_essential_entity
529            || self.is_nis2_important_entity
530            || self.processes_personal_data
531            || self.is_high_risk_ai
532            || self.red_repealed_until.is_some()
533            || self.eucc_protection_profile_id.is_some()
534            || self.eucc_target_of_evaluation.is_some()
535            || self.eucc_itsef_identifier.is_some()
536            || self.eucc_valid_until.is_some()
537            || !self.annex_i_part_i_controls.is_empty()
538    }
539
540    /// Generate an example sidecar file content
541    #[must_use]
542    pub fn example_json() -> String {
543        let example = Self {
544            security_contact: Some("security@example.com".to_string()),
545            vulnerability_disclosure_url: Some("https://example.com/security".to_string()),
546            support_end_date: Some(Utc::now() + chrono::Duration::days(365 * 2)),
547            manufacturer_name: Some("Example Corp".to_string()),
548            manufacturer_email: Some("contact@example.com".to_string()),
549            product_name: Some("Example Product".to_string()),
550            product_version: Some("1.0.0".to_string()),
551            ce_marking_reference: Some("EU-DoC-2024-001".to_string()),
552            update_mechanism: Some("Automatic OTA updates via secure channel".to_string()),
553            psirt_url: Some("https://example.com/psirt".to_string()),
554            early_warning_contact: Some("psirt@example.com".to_string()),
555            incident_report_contact: Some("incidents@example.com".to_string()),
556            enisa_reporting_platform_id: Some("EU-MFR-12345".to_string()),
557            coordinated_disclosure_policy_url: Some(
558                "https://example.com/security/cvd-policy".to_string(),
559            ),
560            risk_assessment_url: Some(
561                "https://example.com/docs/risk-assessment-2026.pdf".to_string(),
562            ),
563            risk_assessment_methodology: Some("ISO/IEC 27005:2022".to_string()),
564            product_class: Some(CraProductClass::ImportantClass1),
565            conformity_assessment_route: Some(ConformityRoute::ModuleA),
566            is_oss_steward: false,
567            is_nis2_essential_entity: false,
568            is_nis2_important_entity: false,
569            processes_personal_data: false,
570            is_high_risk_ai: false,
571            red_repealed_until: None,
572            eucc_protection_profile_id: None,
573            eucc_target_of_evaluation: None,
574            eucc_itsef_identifier: None,
575            eucc_valid_until: None,
576            annex_i_part_i_controls: BTreeMap::new(),
577        };
578        serde_json::to_string_pretty(&example).unwrap_or_default()
579    }
580}
581
582/// Errors that can occur when loading sidecar metadata
583#[derive(Debug)]
584pub enum CraSidecarError {
585    IoError(String),
586    ParseError(String),
587    UnsupportedFormat(String),
588}
589
590impl std::fmt::Display for CraSidecarError {
591    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
592        match self {
593            Self::IoError(e) => write!(f, "IO error reading sidecar file: {e}"),
594            Self::ParseError(e) => write!(f, "Parse error in sidecar file: {e}"),
595            Self::UnsupportedFormat(ext) => {
596                write!(f, "Unsupported sidecar file format: .{ext}")
597            }
598        }
599    }
600}
601
602impl std::error::Error for CraSidecarError {}
603
604impl CraSidecarError {
605    /// Prefix the error message with the sidecar path so discovery errors
606    /// name the exact file that failed (serde already names the field).
607    #[must_use]
608    pub fn with_path(self, path: &Path) -> Self {
609        let p = path.display();
610        match self {
611            Self::IoError(e) => Self::IoError(format!("{p}: {e}")),
612            Self::ParseError(e) => Self::ParseError(format!("{p}: {e}")),
613            Self::UnsupportedFormat(e) => Self::UnsupportedFormat(format!("{e} ({p})")),
614        }
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn test_default_has_no_data() {
624        let sidecar = CraSidecarMetadata::default();
625        assert!(!sidecar.has_cra_data());
626    }
627
628    #[test]
629    fn test_has_cra_data_with_contact() {
630        let sidecar = CraSidecarMetadata {
631            security_contact: Some("security@example.com".to_string()),
632            ..Default::default()
633        };
634        assert!(sidecar.has_cra_data());
635    }
636
637    #[test]
638    fn test_example_json_is_valid() {
639        let json = CraSidecarMetadata::example_json();
640        let parsed: Result<CraSidecarMetadata, _> = serde_json::from_str(&json);
641        assert!(parsed.is_ok());
642    }
643
644    #[test]
645    fn test_json_roundtrip() {
646        let original = CraSidecarMetadata {
647            security_contact: Some("test@example.com".to_string()),
648            support_end_date: Some(Utc::now()),
649            ..Default::default()
650        };
651        let json = serde_json::to_string(&original).unwrap();
652        let parsed: CraSidecarMetadata = serde_json::from_str(&json).unwrap();
653        assert_eq!(original.security_contact, parsed.security_contact);
654    }
655
656    #[test]
657    fn unknown_field_is_rejected_not_silently_dropped() {
658        // A snake_case typo used to deserialize to an all-None sidecar; it
659        // must now fail, and the error must name the offending key.
660        let json = r#"{"security_contact": "security@example.com"}"#;
661        let err = serde_json::from_str::<CraSidecarMetadata>(json).unwrap_err();
662        assert!(
663            err.to_string().contains("security_contact"),
664            "error must name the offending key: {err}"
665        );
666    }
667
668    #[test]
669    fn from_file_unknown_field_error_names_key_and_hints_camel_case() {
670        let dir = tempfile::tempdir().unwrap();
671        let path = dir.path().join("typo.cra.json");
672        std::fs::write(&path, r#"{"security_contact": "security@example.com"}"#).unwrap();
673        let err = CraSidecarMetadata::from_file(&path).unwrap_err();
674        let msg = err.to_string();
675        assert!(
676            msg.contains("security_contact"),
677            "error must name the offending key: {msg}"
678        );
679        assert!(
680            msg.contains("camelCase") && msg.contains("securityContact"),
681            "error must hint at camelCase keys: {msg}"
682        );
683    }
684
685    #[test]
686    fn from_yaml_file_unknown_field_is_rejected_with_hint() {
687        let dir = tempfile::tempdir().unwrap();
688        let path = dir.path().join("typo.cra.yaml");
689        std::fs::write(&path, "manufacturer_name: ExampleCorp\n").unwrap();
690        let err = CraSidecarMetadata::from_file(&path).unwrap_err();
691        let msg = err.to_string();
692        assert!(msg.contains("manufacturer_name"), "{msg}");
693        assert!(msg.contains("camelCase"), "{msg}");
694    }
695
696    #[test]
697    fn known_camel_case_fields_still_deserialize() {
698        let json = r#"{
699            "securityContact": "security@example.com",
700            "productClass": "critical",
701            "isOssSteward": true,
702            "annexIPartIControls": {
703                "1.a": { "satisfied": true, "evidenceUrl": "https://example.com/e" }
704            }
705        }"#;
706        let sidecar: CraSidecarMetadata = serde_json::from_str(json).unwrap();
707        assert_eq!(
708            sidecar.security_contact.as_deref(),
709            Some("security@example.com")
710        );
711        assert_eq!(sidecar.product_class, Some(CraProductClass::Critical));
712        assert!(sidecar.is_oss_steward);
713        assert!(sidecar.annex_i_part_i_controls["1.a"].satisfied);
714    }
715
716    #[test]
717    fn discover_for_sbom_hard_fails_on_broken_candidate_naming_file_and_field() {
718        // Strict discovery: a broken adjacent sidecar is a hard error that
719        // names the file and the offending field — the same treatment an
720        // explicit --cra-sidecar gets — never a silent verdict shift.
721        let dir = tempfile::tempdir().unwrap();
722        let sbom_path = dir.path().join("app.cdx.json");
723        std::fs::write(&sbom_path, "{}").unwrap();
724        std::fs::write(
725            dir.path().join("app.cra.json"),
726            r#"{"security_contact": "typo"}"#,
727        )
728        .unwrap();
729        let err = CraSidecarMetadata::discover_for_sbom(&sbom_path)
730            .expect_err("broken discovered sidecar must hard-error");
731        let msg = err.to_string();
732        assert!(msg.contains("app.cra.json"), "must name the file: {msg}");
733        assert!(
734            msg.contains("security_contact"),
735            "must name the field: {msg}"
736        );
737
738        // The transitional lenient wrapper still downgrades to None.
739        assert!(CraSidecarMetadata::find_for_sbom(&sbom_path).is_none());
740    }
741
742    #[test]
743    fn discover_for_sbom_skips_stdin() {
744        // Stdin input previously probed a literal `-.cra.json` in the cwd.
745        assert!(
746            CraSidecarMetadata::discover_for_sbom(Path::new("-"))
747                .unwrap()
748                .is_none()
749        );
750    }
751
752    #[test]
753    fn discover_for_sbom_finds_hyphen_yml_variant() {
754        let dir = tempfile::tempdir().unwrap();
755        let sbom_path = dir.path().join("app.cdx.json");
756        std::fs::write(&sbom_path, "{}").unwrap();
757        std::fs::write(
758            dir.path().join("app-cra.yml"),
759            "securityContact: sec@example.com\n",
760        )
761        .unwrap();
762        let found = CraSidecarMetadata::discover_for_sbom(&sbom_path)
763            .unwrap()
764            .expect("hyphen .yml sidecar must be discovered");
765        assert_eq!(found.security_contact.as_deref(), Some("sec@example.com"));
766    }
767
768    #[test]
769    fn discover_for_sbom_loads_valid_candidate() {
770        let dir = tempfile::tempdir().unwrap();
771        let sbom_path = dir.path().join("app.cdx.json");
772        std::fs::write(&sbom_path, "{}").unwrap();
773        std::fs::write(
774            dir.path().join("app.cra.json"),
775            r#"{"securityContact": "sec@example.com"}"#,
776        )
777        .unwrap();
778        let found = CraSidecarMetadata::discover_for_sbom(&sbom_path)
779            .unwrap()
780            .expect("valid sidecar must be discovered");
781        assert_eq!(found.security_contact.as_deref(), Some("sec@example.com"));
782    }
783
784    #[test]
785    fn product_class_parse_cli_accepts_aliases() {
786        assert_eq!(
787            CraProductClass::parse_cli("default"),
788            Some(CraProductClass::Default)
789        );
790        assert_eq!(
791            CraProductClass::parse_cli("important-class-1"),
792            Some(CraProductClass::ImportantClass1)
793        );
794        assert_eq!(
795            CraProductClass::parse_cli("important-2"),
796            Some(CraProductClass::ImportantClass2)
797        );
798        assert_eq!(
799            CraProductClass::parse_cli("CRITICAL"),
800            Some(CraProductClass::Critical)
801        );
802        assert_eq!(CraProductClass::parse_cli("nonsense"), None);
803    }
804
805    #[test]
806    fn product_class_default_route_matches_regulation() {
807        assert_eq!(
808            CraProductClass::Default.default_route(),
809            ConformityRoute::ModuleA
810        );
811        assert_eq!(
812            CraProductClass::ImportantClass1.default_route(),
813            ConformityRoute::ModuleA
814        );
815        assert_eq!(
816            CraProductClass::ImportantClass2.default_route(),
817            ConformityRoute::ModuleBC
818        );
819        assert_eq!(
820            CraProductClass::Critical.default_route(),
821            ConformityRoute::Eucc
822        );
823    }
824
825    #[test]
826    fn product_class_serde_kebab_case() {
827        let json = serde_json::to_string(&CraProductClass::ImportantClass1).unwrap();
828        assert_eq!(json, "\"important-class-1\"");
829        let parsed: CraProductClass = serde_json::from_str("\"critical\"").unwrap();
830        assert_eq!(parsed, CraProductClass::Critical);
831    }
832
833    #[test]
834    fn conformity_route_parse_cli_accepts_aliases() {
835        assert_eq!(
836            ConformityRoute::parse_cli("module-a"),
837            Some(ConformityRoute::ModuleA)
838        );
839        assert_eq!(
840            ConformityRoute::parse_cli("B+C"),
841            Some(ConformityRoute::ModuleBC)
842        );
843        assert_eq!(
844            ConformityRoute::parse_cli("Module-H"),
845            Some(ConformityRoute::ModuleH)
846        );
847        assert_eq!(
848            ConformityRoute::parse_cli("EUCC"),
849            Some(ConformityRoute::Eucc)
850        );
851        assert_eq!(ConformityRoute::parse_cli("module-z"), None);
852    }
853}