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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct CraSidecarMetadata {
21    /// Security contact email or URL for vulnerability disclosure
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub security_contact: Option<String>,
24
25    /// URL for vulnerability disclosure policy/portal
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub vulnerability_disclosure_url: Option<String>,
28
29    /// End of support/security updates date
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub support_end_date: Option<DateTime<Utc>>,
32
33    /// Manufacturer/vendor name (supplements SBOM creator info)
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub manufacturer_name: Option<String>,
36
37    /// Manufacturer contact email
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub manufacturer_email: Option<String>,
40
41    /// Product name (supplements SBOM document name)
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub product_name: Option<String>,
44
45    /// Product version
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub product_version: Option<String>,
48
49    /// CE marking declaration reference (URL or document ID)
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub ce_marking_reference: Option<String>,
52
53    /// Security update delivery mechanism description
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub update_mechanism: Option<String>,
56
57    // -------- CRA Article 14 reporting-readiness fields (apply 2026-09-11) --------
58    /// PSIRT (Product Security Incident Response Team) public URL.
59    /// Required to handle external vulnerability reports under Annex I Part II
60    /// and Art. 14 incident reporting.
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub psirt_url: Option<String>,
63
64    /// Channel (email, URL, phone) for the 24-hour early-warning notification
65    /// to ENISA / CSIRT under CRA Art. 14(1) when an actively-exploited
66    /// vulnerability is identified.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub early_warning_contact: Option<String>,
69
70    /// Channel for the 72-hour incident report under CRA Art. 14(2).
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub incident_report_contact: Option<String>,
73
74    /// Manufacturer-side identifier for the ENISA single reporting platform
75    /// (Art. 14(7)). Until ENISA publishes the technical interface this is a
76    /// placeholder string — typically a manufacturer registration ID.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub enisa_reporting_platform_id: Option<String>,
79
80    /// Coordinated vulnerability disclosure policy URL.
81    /// Distinct from `vulnerability_disclosure_url` (which may point at a
82    /// portal) — this is the published *policy* that meets CRA Art. 13(7)
83    /// and ISO/IEC 29147 expectations.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub coordinated_disclosure_policy_url: Option<String>,
86
87    // -------- CRA Article 13(2) risk-assessment fields --------
88    /// URL or document reference for the documented risk assessment
89    /// required by CRA Art. 13(2). Annex V technical documentation must
90    /// include or reference this assessment.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub risk_assessment_url: Option<String>,
93
94    /// Methodology used for the risk assessment (e.g.,
95    /// "ISO/IEC 27005:2022", "NIST SP 800-30 r1", "ETSI TS 102 165-1 TVRA").
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub risk_assessment_methodology: Option<String>,
98
99    // -------- CRA Annex III/IV product class & conformity-assessment route --------
100    /// CRA product class drives the conformity-assessment route and the
101    /// severity calibration of compliance checks (vendor-hash coverage,
102    /// PSIRT, EUCC reference, attestation).
103    #[serde(skip_serializing_if = "Option::is_none")]
104    pub product_class: Option<CraProductClass>,
105
106    /// Conformity-assessment route per CRA Annex VIII (Module A self-assessment,
107    /// B+C EU-type examination, H full QA, or EUCC). Sidecar value wins over
108    /// any CLI-provided default.
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub conformity_assessment_route: Option<ConformityRoute>,
111
112    // -------- CRA Article 24 — open-source steward profile --------
113    /// Whether this product is supplied by an open-source software steward
114    /// (CRA Art. 24). When `true`, manufacturer-only obligations (DoC,
115    /// notified-body attestation, manufacturer email) are not enforced;
116    /// SBOM, vulnerability-handling, and CVD policy are still required.
117    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
118    pub is_oss_steward: bool,
119
120    // -------- Adjacent regulation overlap (CRA-P4.4) --------
121    /// True if the manufacturer is a NIS2 essential entity (Annex I of
122    /// Directive (EU) 2022/2555). Triggers Art. 23 incident-reporting
123    /// guidance in the cra-docs dossier.
124    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
125    pub is_nis2_essential_entity: bool,
126
127    /// True if the manufacturer is a NIS2 important entity (Annex II of
128    /// Directive (EU) 2022/2555).
129    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
130    pub is_nis2_important_entity: bool,
131
132    /// True when the product processes personal data (GDPR Art. 32
133    /// security-of-processing applies in parallel to CRA Annex I).
134    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
135    pub processes_personal_data: bool,
136
137    /// True when the product is a high-risk AI system per the AI Act
138    /// (Regulation (EU) 2024/1689). AI-Act conformity coordination must
139    /// be handled alongside CRA Module assessment.
140    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
141    pub is_high_risk_ai: bool,
142
143    /// Date until which the Radio Equipment Directive (RED, Directive
144    /// 2014/53/EU) cybersecurity provisions still apply for this product.
145    /// CRA repeals RED Art. 3(3)(d/e/f) on 2025-08-01; older device
146    /// inventories may carry RED references through their support
147    /// horizon.
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub red_repealed_until: Option<DateTime<Utc>>,
150
151    // -------- EUCC Substantial (CRA-P5.4 reference profile) --------
152    /// Common Criteria Protection Profile identifier (e.g., "PP-CC-MFR-2024-01").
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub eucc_protection_profile_id: Option<String>,
155
156    /// Common Criteria Target of Evaluation reference (URL or document ID).
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub eucc_target_of_evaluation: Option<String>,
159
160    /// IT Security Evaluation Facility (ITSEF) identifier — the accredited
161    /// laboratory that performed the EUCC evaluation.
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub eucc_itsef_identifier: Option<String>,
164
165    /// EUCC certificate valid-until date.
166    #[serde(skip_serializing_if = "Option::is_none")]
167    pub eucc_valid_until: Option<DateTime<Utc>>,
168
169    // -------- prEN 40000-1-2/1-4 controls-assertion (CRA-P5.5) --------
170    /// Per-control assertions for CRA Annex I Part I, keyed by control ID
171    /// (e.g., `"1.a"` through `"1.l"` for §1, `"2.a"` through `"2.m"` for
172    /// §2 vulnerability-handling). Each entry records whether the
173    /// manufacturer claims the control is satisfied, the evidence URL,
174    /// and the methodology used.
175    ///
176    /// `BTreeMap` for deterministic ordering in dossier output.
177    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
178    pub annex_i_part_i_controls: BTreeMap<String, ControlAssertion>,
179}
180
181/// A manufacturer-supplied assertion that a specific Annex I Part I control
182/// is satisfied. Surfaced verbatim in the cra-docs technical-documentation
183/// dossier and cross-checked by `ComplianceChecker` (a control claimed
184/// `satisfied = true` without an `evidence_url` is flagged as a Warning).
185#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase")]
187pub struct ControlAssertion {
188    /// Whether the manufacturer claims this control is satisfied.
189    #[serde(default)]
190    pub satisfied: bool,
191    /// URL pointing at the evidence document (test report, design review,
192    /// SAST/DAST output, etc.). Required when `satisfied = true`.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub evidence_url: Option<String>,
195    /// Methodology / standard the assertion was made against
196    /// (e.g., `"prEN 40000-1-2 §5.3"`, `"OWASP ASVS L2"`,
197    /// `"NIST SP 800-53 SI-10"`).
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub methodology: Option<String>,
200    /// Free-form notes from the manufacturer (rationale, caveats).
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub note: Option<String>,
203}
204
205/// CRA product class per Regulation (EU) 2024/2847 Annex III/IV.
206///
207/// The class drives the conformity-assessment route and the severity
208/// calibration of compliance checks (per CRA-P3.2 calibration table):
209/// stricter classes upgrade Warning→Error and add EUCC / attestation
210/// expectations.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212#[non_exhaustive]
213pub enum CraProductClass {
214    /// Default — neither Annex III nor Annex IV. Module A self-assessment.
215    #[serde(rename = "default")]
216    Default,
217    /// Annex III items 1–11 (Important Class I). Module A or B+C.
218    #[serde(
219        rename = "important-class-1",
220        alias = "important1",
221        alias = "ImportantClass1"
222    )]
223    ImportantClass1,
224    /// Annex III items 12–17 (Important Class II). Module B+C, H, or EUCC.
225    #[serde(
226        rename = "important-class-2",
227        alias = "important2",
228        alias = "ImportantClass2"
229    )]
230    ImportantClass2,
231    /// Annex IV (Critical). EUCC mandatory.
232    #[serde(rename = "critical")]
233    Critical,
234}
235
236impl CraProductClass {
237    /// Short label for compact display.
238    #[must_use]
239    pub const fn label(self) -> &'static str {
240        match self {
241            Self::Default => "Default",
242            Self::ImportantClass1 => "Important-1",
243            Self::ImportantClass2 => "Important-2",
244            Self::Critical => "Critical",
245        }
246    }
247
248    /// Long human-readable name including Annex reference.
249    #[must_use]
250    pub const fn name(self) -> &'static str {
251        match self {
252            Self::Default => "Default (no Annex)",
253            Self::ImportantClass1 => "Important Class I (Annex III items 1–11)",
254            Self::ImportantClass2 => "Important Class II (Annex III items 12–17)",
255            Self::Critical => "Critical (Annex IV)",
256        }
257    }
258
259    /// Parse from the CLI-friendly kebab-case form. Accepts a few aliases.
260    #[must_use]
261    pub fn parse_cli(s: &str) -> Option<Self> {
262        match s.to_ascii_lowercase().as_str() {
263            "default" | "none" => Some(Self::Default),
264            "important-class-1" | "important-1" | "important1" | "annex-iii-1" => {
265                Some(Self::ImportantClass1)
266            }
267            "important-class-2" | "important-2" | "important2" | "annex-iii-2" => {
268                Some(Self::ImportantClass2)
269            }
270            "critical" | "annex-iv" => Some(Self::Critical),
271            _ => None,
272        }
273    }
274
275    /// The conformity-assessment route the regulation expects (or strictly
276    /// requires) for this class. Manufacturers may choose a stricter route.
277    #[must_use]
278    pub const fn default_route(self) -> ConformityRoute {
279        match self {
280            Self::Default | Self::ImportantClass1 => ConformityRoute::ModuleA,
281            Self::ImportantClass2 => ConformityRoute::ModuleBC,
282            Self::Critical => ConformityRoute::Eucc,
283        }
284    }
285}
286
287/// Conformity-assessment module per CRA Annex VIII.
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "kebab-case")]
290#[non_exhaustive]
291pub enum ConformityRoute {
292    /// Module A — internal control / self-assessment.
293    ModuleA,
294    /// Module B+C — EU-type examination plus production conformity.
295    ModuleBC,
296    /// Module H — full quality assurance.
297    ModuleH,
298    /// EUCC — Common Criteria via European Cybersecurity Certification scheme.
299    Eucc,
300}
301
302impl ConformityRoute {
303    /// Short label.
304    #[must_use]
305    pub const fn label(self) -> &'static str {
306        match self {
307            Self::ModuleA => "Module A",
308            Self::ModuleBC => "Module B+C",
309            Self::ModuleH => "Module H",
310            Self::Eucc => "EUCC",
311        }
312    }
313
314    /// Long descriptive name.
315    #[must_use]
316    pub const fn name(self) -> &'static str {
317        match self {
318            Self::ModuleA => "Module A — internal control (self-assessment)",
319            Self::ModuleBC => "Module B+C — EU-type examination + production conformity",
320            Self::ModuleH => "Module H — full quality assurance",
321            Self::Eucc => "EUCC — Common Criteria via EU certification scheme",
322        }
323    }
324
325    /// Parse from the CLI-friendly kebab-case form.
326    #[must_use]
327    pub fn parse_cli(s: &str) -> Option<Self> {
328        match s.to_ascii_lowercase().as_str() {
329            "module-a" | "a" | "self-assessment" => Some(Self::ModuleA),
330            "module-bc" | "module-b+c" | "module-b-c" | "bc" | "b+c" => Some(Self::ModuleBC),
331            "module-h" | "h" => Some(Self::ModuleH),
332            "eucc" | "common-criteria" => Some(Self::Eucc),
333            _ => None,
334        }
335    }
336}
337
338impl CraSidecarMetadata {
339    /// Load sidecar metadata from a JSON file
340    pub fn from_json_file(path: &Path) -> Result<Self, CraSidecarError> {
341        let content =
342            std::fs::read_to_string(path).map_err(|e| CraSidecarError::IoError(e.to_string()))?;
343        serde_json::from_str(&content).map_err(|e| CraSidecarError::ParseError(e.to_string()))
344    }
345
346    /// Load sidecar metadata from a YAML file
347    pub fn from_yaml_file(path: &Path) -> Result<Self, CraSidecarError> {
348        let content =
349            std::fs::read_to_string(path).map_err(|e| CraSidecarError::IoError(e.to_string()))?;
350        serde_yaml_ng::from_str(&content).map_err(|e| CraSidecarError::ParseError(e.to_string()))
351    }
352
353    /// Load sidecar metadata, auto-detecting format from extension
354    pub fn from_file(path: &Path) -> Result<Self, CraSidecarError> {
355        let extension = path
356            .extension()
357            .and_then(|e| e.to_str())
358            .unwrap_or("")
359            .to_lowercase();
360
361        match extension.as_str() {
362            "json" => Self::from_json_file(path),
363            "yaml" | "yml" => Self::from_yaml_file(path),
364            _ => Err(CraSidecarError::UnsupportedFormat(extension)),
365        }
366    }
367
368    /// Try to find a sidecar file for the given SBOM path.
369    ///
370    /// Looks for `<stem>.cra.{json,yaml,yml}` and `<stem>-cra.{json,yaml}`
371    /// alongside the SBOM. Multi-extension stems (`app.cdx.json`,
372    /// `app.spdx.json`, `app.spdx3.json`) also try the inner stem
373    /// (`app.cra.json`) so the common SBOM naming conventions work
374    /// without forcing operators to repeat the format suffix.
375    #[must_use]
376    pub fn find_for_sbom(sbom_path: &Path) -> Option<Self> {
377        let parent = sbom_path.parent()?;
378        let stem = sbom_path.file_stem()?.to_str()?;
379
380        // Build the list of stems to try. Strip well-known SBOM format
381        // suffixes (`.cdx`, `.spdx`, `.spdx3`, `.cyclonedx`) so e.g.
382        // `app.cdx.json` looks for `app.cra.json` as well as
383        // `app.cdx.cra.json`.
384        let mut stems: Vec<&str> = vec![stem];
385        for suffix in [".cdx", ".cyclonedx", ".spdx", ".spdx3"] {
386            if let Some(inner) = stem.strip_suffix(suffix)
387                && !inner.is_empty()
388            {
389                stems.push(inner);
390            }
391        }
392
393        for s in &stems {
394            for pattern in [
395                format!("{s}.cra.json"),
396                format!("{s}.cra.yaml"),
397                format!("{s}.cra.yml"),
398                format!("{s}-cra.json"),
399                format!("{s}-cra.yaml"),
400            ] {
401                let sidecar_path = parent.join(&pattern);
402                if sidecar_path.exists()
403                    && let Ok(metadata) = Self::from_file(&sidecar_path)
404                {
405                    return Some(metadata);
406                }
407            }
408        }
409
410        None
411    }
412
413    /// Check if any CRA-relevant fields are populated
414    #[must_use]
415    pub fn has_cra_data(&self) -> bool {
416        self.security_contact.is_some()
417            || self.vulnerability_disclosure_url.is_some()
418            || self.support_end_date.is_some()
419            || self.manufacturer_name.is_some()
420            || self.ce_marking_reference.is_some()
421            || self.psirt_url.is_some()
422            || self.early_warning_contact.is_some()
423            || self.incident_report_contact.is_some()
424            || self.enisa_reporting_platform_id.is_some()
425            || self.coordinated_disclosure_policy_url.is_some()
426            || self.risk_assessment_url.is_some()
427            || self.risk_assessment_methodology.is_some()
428            || self.product_class.is_some()
429            || self.conformity_assessment_route.is_some()
430            || self.is_oss_steward
431            || self.is_nis2_essential_entity
432            || self.is_nis2_important_entity
433            || self.processes_personal_data
434            || self.is_high_risk_ai
435            || self.red_repealed_until.is_some()
436            || self.eucc_protection_profile_id.is_some()
437            || self.eucc_target_of_evaluation.is_some()
438            || self.eucc_itsef_identifier.is_some()
439            || self.eucc_valid_until.is_some()
440            || !self.annex_i_part_i_controls.is_empty()
441    }
442
443    /// Generate an example sidecar file content
444    #[must_use]
445    pub fn example_json() -> String {
446        let example = Self {
447            security_contact: Some("security@example.com".to_string()),
448            vulnerability_disclosure_url: Some("https://example.com/security".to_string()),
449            support_end_date: Some(Utc::now() + chrono::Duration::days(365 * 2)),
450            manufacturer_name: Some("Example Corp".to_string()),
451            manufacturer_email: Some("contact@example.com".to_string()),
452            product_name: Some("Example Product".to_string()),
453            product_version: Some("1.0.0".to_string()),
454            ce_marking_reference: Some("EU-DoC-2024-001".to_string()),
455            update_mechanism: Some("Automatic OTA updates via secure channel".to_string()),
456            psirt_url: Some("https://example.com/psirt".to_string()),
457            early_warning_contact: Some("psirt@example.com".to_string()),
458            incident_report_contact: Some("incidents@example.com".to_string()),
459            enisa_reporting_platform_id: Some("EU-MFR-12345".to_string()),
460            coordinated_disclosure_policy_url: Some(
461                "https://example.com/security/cvd-policy".to_string(),
462            ),
463            risk_assessment_url: Some(
464                "https://example.com/docs/risk-assessment-2026.pdf".to_string(),
465            ),
466            risk_assessment_methodology: Some("ISO/IEC 27005:2022".to_string()),
467            product_class: Some(CraProductClass::ImportantClass1),
468            conformity_assessment_route: Some(ConformityRoute::ModuleA),
469            is_oss_steward: false,
470            is_nis2_essential_entity: false,
471            is_nis2_important_entity: false,
472            processes_personal_data: false,
473            is_high_risk_ai: false,
474            red_repealed_until: None,
475            eucc_protection_profile_id: None,
476            eucc_target_of_evaluation: None,
477            eucc_itsef_identifier: None,
478            eucc_valid_until: None,
479            annex_i_part_i_controls: BTreeMap::new(),
480        };
481        serde_json::to_string_pretty(&example).unwrap_or_default()
482    }
483}
484
485/// Errors that can occur when loading sidecar metadata
486#[derive(Debug)]
487pub enum CraSidecarError {
488    IoError(String),
489    ParseError(String),
490    UnsupportedFormat(String),
491}
492
493impl std::fmt::Display for CraSidecarError {
494    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
495        match self {
496            Self::IoError(e) => write!(f, "IO error reading sidecar file: {e}"),
497            Self::ParseError(e) => write!(f, "Parse error in sidecar file: {e}"),
498            Self::UnsupportedFormat(ext) => {
499                write!(f, "Unsupported sidecar file format: .{ext}")
500            }
501        }
502    }
503}
504
505impl std::error::Error for CraSidecarError {}
506
507#[cfg(test)]
508mod tests {
509    use super::*;
510
511    #[test]
512    fn test_default_has_no_data() {
513        let sidecar = CraSidecarMetadata::default();
514        assert!(!sidecar.has_cra_data());
515    }
516
517    #[test]
518    fn test_has_cra_data_with_contact() {
519        let sidecar = CraSidecarMetadata {
520            security_contact: Some("security@example.com".to_string()),
521            ..Default::default()
522        };
523        assert!(sidecar.has_cra_data());
524    }
525
526    #[test]
527    fn test_example_json_is_valid() {
528        let json = CraSidecarMetadata::example_json();
529        let parsed: Result<CraSidecarMetadata, _> = serde_json::from_str(&json);
530        assert!(parsed.is_ok());
531    }
532
533    #[test]
534    fn test_json_roundtrip() {
535        let original = CraSidecarMetadata {
536            security_contact: Some("test@example.com".to_string()),
537            support_end_date: Some(Utc::now()),
538            ..Default::default()
539        };
540        let json = serde_json::to_string(&original).unwrap();
541        let parsed: CraSidecarMetadata = serde_json::from_str(&json).unwrap();
542        assert_eq!(original.security_contact, parsed.security_contact);
543    }
544
545    #[test]
546    fn product_class_parse_cli_accepts_aliases() {
547        assert_eq!(
548            CraProductClass::parse_cli("default"),
549            Some(CraProductClass::Default)
550        );
551        assert_eq!(
552            CraProductClass::parse_cli("important-class-1"),
553            Some(CraProductClass::ImportantClass1)
554        );
555        assert_eq!(
556            CraProductClass::parse_cli("important-2"),
557            Some(CraProductClass::ImportantClass2)
558        );
559        assert_eq!(
560            CraProductClass::parse_cli("CRITICAL"),
561            Some(CraProductClass::Critical)
562        );
563        assert_eq!(CraProductClass::parse_cli("nonsense"), None);
564    }
565
566    #[test]
567    fn product_class_default_route_matches_regulation() {
568        assert_eq!(
569            CraProductClass::Default.default_route(),
570            ConformityRoute::ModuleA
571        );
572        assert_eq!(
573            CraProductClass::ImportantClass1.default_route(),
574            ConformityRoute::ModuleA
575        );
576        assert_eq!(
577            CraProductClass::ImportantClass2.default_route(),
578            ConformityRoute::ModuleBC
579        );
580        assert_eq!(
581            CraProductClass::Critical.default_route(),
582            ConformityRoute::Eucc
583        );
584    }
585
586    #[test]
587    fn product_class_serde_kebab_case() {
588        let json = serde_json::to_string(&CraProductClass::ImportantClass1).unwrap();
589        assert_eq!(json, "\"important-class-1\"");
590        let parsed: CraProductClass = serde_json::from_str("\"critical\"").unwrap();
591        assert_eq!(parsed, CraProductClass::Critical);
592    }
593
594    #[test]
595    fn conformity_route_parse_cli_accepts_aliases() {
596        assert_eq!(
597            ConformityRoute::parse_cli("module-a"),
598            Some(ConformityRoute::ModuleA)
599        );
600        assert_eq!(
601            ConformityRoute::parse_cli("B+C"),
602            Some(ConformityRoute::ModuleBC)
603        );
604        assert_eq!(
605            ConformityRoute::parse_cli("Module-H"),
606            Some(ConformityRoute::ModuleH)
607        );
608        assert_eq!(
609            ConformityRoute::parse_cli("EUCC"),
610            Some(ConformityRoute::Eucc)
611        );
612        assert_eq!(ConformityRoute::parse_cli("module-z"), None);
613    }
614}