1use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::path::Path;
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct CraSidecarMetadata {
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub security_contact: Option<String>,
29
30 #[serde(skip_serializing_if = "Option::is_none")]
32 pub vulnerability_disclosure_url: Option<String>,
33
34 #[serde(skip_serializing_if = "Option::is_none")]
36 pub support_end_date: Option<DateTime<Utc>>,
37
38 #[serde(skip_serializing_if = "Option::is_none")]
40 pub manufacturer_name: Option<String>,
41
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub manufacturer_email: Option<String>,
45
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub product_name: Option<String>,
49
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub product_version: Option<String>,
53
54 #[serde(skip_serializing_if = "Option::is_none")]
56 pub ce_marking_reference: Option<String>,
57
58 #[serde(skip_serializing_if = "Option::is_none")]
60 pub update_mechanism: Option<String>,
61
62 #[serde(skip_serializing_if = "Option::is_none")]
67 pub psirt_url: Option<String>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
73 pub early_warning_contact: Option<String>,
74
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub incident_report_contact: Option<String>,
78
79 #[serde(skip_serializing_if = "Option::is_none")]
83 pub enisa_reporting_platform_id: Option<String>,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
90 pub coordinated_disclosure_policy_url: Option<String>,
91
92 #[serde(skip_serializing_if = "Option::is_none")]
97 pub risk_assessment_url: Option<String>,
98
99 #[serde(skip_serializing_if = "Option::is_none")]
102 pub risk_assessment_methodology: Option<String>,
103
104 #[serde(skip_serializing_if = "Option::is_none")]
109 pub product_class: Option<CraProductClass>,
110
111 #[serde(skip_serializing_if = "Option::is_none")]
115 pub conformity_assessment_route: Option<ConformityRoute>,
116
117 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
123 pub is_oss_steward: bool,
124
125 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
130 pub is_nis2_essential_entity: bool,
131
132 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
135 pub is_nis2_important_entity: bool,
136
137 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
140 pub processes_personal_data: bool,
141
142 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
146 pub is_high_risk_ai: bool,
147
148 #[serde(skip_serializing_if = "Option::is_none")]
154 pub red_repealed_until: Option<DateTime<Utc>>,
155
156 #[serde(skip_serializing_if = "Option::is_none")]
159 pub eucc_protection_profile_id: Option<String>,
160
161 #[serde(skip_serializing_if = "Option::is_none")]
163 pub eucc_target_of_evaluation: Option<String>,
164
165 #[serde(skip_serializing_if = "Option::is_none")]
168 pub eucc_itsef_identifier: Option<String>,
169
170 #[serde(skip_serializing_if = "Option::is_none")]
172 pub eucc_valid_until: Option<DateTime<Utc>>,
173
174 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
183 pub annex_i_part_i_controls: BTreeMap<String, ControlAssertion>,
184}
185
186#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase", deny_unknown_fields)]
192pub struct ControlAssertion {
193 #[serde(default)]
195 pub satisfied: bool,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub evidence_url: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub methodology: Option<String>,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
207 pub note: Option<String>,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
217#[non_exhaustive]
218pub enum CraProductClass {
219 #[serde(rename = "default")]
221 Default,
222 #[serde(
224 rename = "important-class-1",
225 alias = "important1",
226 alias = "ImportantClass1"
227 )]
228 ImportantClass1,
229 #[serde(
231 rename = "important-class-2",
232 alias = "important2",
233 alias = "ImportantClass2"
234 )]
235 ImportantClass2,
236 #[serde(rename = "critical")]
238 Critical,
239}
240
241impl CraProductClass {
242 #[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 #[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 #[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 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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
313#[serde(rename_all = "kebab-case")]
314#[non_exhaustive]
315pub enum ConformityRoute {
316 ModuleA,
318 ModuleBC,
320 ModuleH,
322 Eucc,
324}
325
326impl ConformityRoute {
327 #[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 #[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 #[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
362fn 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 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 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 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 pub fn discover_for_sbom(sbom_path: &Path) -> Result<Option<Self>, CraSidecarError> {
424 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 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 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 #[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 #[must_use]
495 pub fn has_live_eucc_evidence(&self) -> bool {
496 self.has_live_eucc_evidence_at(Utc::now())
497 }
498
499 #[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 #[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 #[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#[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 #[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 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 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 assert!(CraSidecarMetadata::find_for_sbom(&sbom_path).is_none());
740 }
741
742 #[test]
743 fn discover_for_sbom_skips_stdin() {
744 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}