1use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15use std::path::Path;
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
19#[serde(rename_all = "camelCase")]
20pub struct CraSidecarMetadata {
21 #[serde(skip_serializing_if = "Option::is_none")]
23 pub security_contact: Option<String>,
24
25 #[serde(skip_serializing_if = "Option::is_none")]
27 pub vulnerability_disclosure_url: Option<String>,
28
29 #[serde(skip_serializing_if = "Option::is_none")]
31 pub support_end_date: Option<DateTime<Utc>>,
32
33 #[serde(skip_serializing_if = "Option::is_none")]
35 pub manufacturer_name: Option<String>,
36
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub manufacturer_email: Option<String>,
40
41 #[serde(skip_serializing_if = "Option::is_none")]
43 pub product_name: Option<String>,
44
45 #[serde(skip_serializing_if = "Option::is_none")]
47 pub product_version: Option<String>,
48
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub ce_marking_reference: Option<String>,
52
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub update_mechanism: Option<String>,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
62 pub psirt_url: Option<String>,
63
64 #[serde(skip_serializing_if = "Option::is_none")]
68 pub early_warning_contact: Option<String>,
69
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub incident_report_contact: Option<String>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
78 pub enisa_reporting_platform_id: Option<String>,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
85 pub coordinated_disclosure_policy_url: Option<String>,
86
87 #[serde(skip_serializing_if = "Option::is_none")]
92 pub risk_assessment_url: Option<String>,
93
94 #[serde(skip_serializing_if = "Option::is_none")]
97 pub risk_assessment_methodology: Option<String>,
98
99 #[serde(skip_serializing_if = "Option::is_none")]
104 pub product_class: Option<CraProductClass>,
105
106 #[serde(skip_serializing_if = "Option::is_none")]
110 pub conformity_assessment_route: Option<ConformityRoute>,
111
112 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
118 pub is_oss_steward: bool,
119
120 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
125 pub is_nis2_essential_entity: bool,
126
127 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
130 pub is_nis2_important_entity: bool,
131
132 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
135 pub processes_personal_data: bool,
136
137 #[serde(default, skip_serializing_if = "core::ops::Not::not")]
141 pub is_high_risk_ai: bool,
142
143 #[serde(skip_serializing_if = "Option::is_none")]
149 pub red_repealed_until: Option<DateTime<Utc>>,
150
151 #[serde(skip_serializing_if = "Option::is_none")]
154 pub eucc_protection_profile_id: Option<String>,
155
156 #[serde(skip_serializing_if = "Option::is_none")]
158 pub eucc_target_of_evaluation: Option<String>,
159
160 #[serde(skip_serializing_if = "Option::is_none")]
163 pub eucc_itsef_identifier: Option<String>,
164
165 #[serde(skip_serializing_if = "Option::is_none")]
167 pub eucc_valid_until: Option<DateTime<Utc>>,
168
169 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
178 pub annex_i_part_i_controls: BTreeMap<String, ControlAssertion>,
179}
180
181#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase")]
187pub struct ControlAssertion {
188 #[serde(default)]
190 pub satisfied: bool,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub evidence_url: Option<String>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub methodology: Option<String>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub note: Option<String>,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212#[non_exhaustive]
213pub enum CraProductClass {
214 #[serde(rename = "default")]
216 Default,
217 #[serde(
219 rename = "important-class-1",
220 alias = "important1",
221 alias = "ImportantClass1"
222 )]
223 ImportantClass1,
224 #[serde(
226 rename = "important-class-2",
227 alias = "important2",
228 alias = "ImportantClass2"
229 )]
230 ImportantClass2,
231 #[serde(rename = "critical")]
233 Critical,
234}
235
236impl CraProductClass {
237 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
289#[serde(rename_all = "kebab-case")]
290#[non_exhaustive]
291pub enum ConformityRoute {
292 ModuleA,
294 ModuleBC,
296 ModuleH,
298 Eucc,
300}
301
302impl ConformityRoute {
303 #[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 #[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 #[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 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 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 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 #[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 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 #[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 #[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#[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}