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::path::Path;
15
16/// CRA sidecar metadata that supplements SBOM information
17#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct CraSidecarMetadata {
20    /// Security contact email or URL for vulnerability disclosure
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub security_contact: Option<String>,
23
24    /// URL for vulnerability disclosure policy/portal
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub vulnerability_disclosure_url: Option<String>,
27
28    /// End of support/security updates date
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub support_end_date: Option<DateTime<Utc>>,
31
32    /// Manufacturer/vendor name (supplements SBOM creator info)
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub manufacturer_name: Option<String>,
35
36    /// Manufacturer contact email
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub manufacturer_email: Option<String>,
39
40    /// Product name (supplements SBOM document name)
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub product_name: Option<String>,
43
44    /// Product version
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub product_version: Option<String>,
47
48    /// CE marking declaration reference (URL or document ID)
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub ce_marking_reference: Option<String>,
51
52    /// Security update delivery mechanism description
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub update_mechanism: Option<String>,
55}
56
57impl CraSidecarMetadata {
58    /// Load sidecar metadata from a JSON file
59    pub fn from_json_file(path: &Path) -> Result<Self, CraSidecarError> {
60        let content = std::fs::read_to_string(path)
61            .map_err(|e| CraSidecarError::IoError(e.to_string()))?;
62        serde_json::from_str(&content)
63            .map_err(|e| CraSidecarError::ParseError(e.to_string()))
64    }
65
66    /// Load sidecar metadata from a YAML file
67    pub fn from_yaml_file(path: &Path) -> Result<Self, CraSidecarError> {
68        let content = std::fs::read_to_string(path)
69            .map_err(|e| CraSidecarError::IoError(e.to_string()))?;
70        serde_yaml_ng::from_str(&content)
71            .map_err(|e| CraSidecarError::ParseError(e.to_string()))
72    }
73
74    /// Load sidecar metadata, auto-detecting format from extension
75    pub fn from_file(path: &Path) -> Result<Self, CraSidecarError> {
76        let extension = path
77            .extension()
78            .and_then(|e| e.to_str())
79            .unwrap_or("")
80            .to_lowercase();
81
82        match extension.as_str() {
83            "json" => Self::from_json_file(path),
84            "yaml" | "yml" => Self::from_yaml_file(path),
85            _ => Err(CraSidecarError::UnsupportedFormat(extension)),
86        }
87    }
88
89    /// Try to find a sidecar file for the given SBOM path
90    /// Looks for .cra.json or .cra.yaml files alongside the SBOM
91    #[must_use] 
92    pub fn find_for_sbom(sbom_path: &Path) -> Option<Self> {
93        let parent = sbom_path.parent()?;
94        let stem = sbom_path.file_stem()?.to_str()?;
95
96        // Try common sidecar naming patterns
97        let patterns = [
98            format!("{stem}.cra.json"),
99            format!("{stem}.cra.yaml"),
100            format!("{stem}.cra.yml"),
101            format!("{stem}-cra.json"),
102            format!("{stem}-cra.yaml"),
103        ];
104
105        for pattern in &patterns {
106            let sidecar_path = parent.join(pattern);
107            if sidecar_path.exists()
108                && let Ok(metadata) = Self::from_file(&sidecar_path) {
109                    return Some(metadata);
110                }
111        }
112
113        None
114    }
115
116    /// Check if any CRA-relevant fields are populated
117    #[must_use] 
118    pub const fn has_cra_data(&self) -> bool {
119        self.security_contact.is_some()
120            || self.vulnerability_disclosure_url.is_some()
121            || self.support_end_date.is_some()
122            || self.manufacturer_name.is_some()
123            || self.ce_marking_reference.is_some()
124    }
125
126    /// Generate an example sidecar file content
127    #[must_use] 
128    pub fn example_json() -> String {
129        let example = Self {
130            security_contact: Some("security@example.com".to_string()),
131            vulnerability_disclosure_url: Some("https://example.com/security".to_string()),
132            support_end_date: Some(Utc::now() + chrono::Duration::days(365 * 2)),
133            manufacturer_name: Some("Example Corp".to_string()),
134            manufacturer_email: Some("contact@example.com".to_string()),
135            product_name: Some("Example Product".to_string()),
136            product_version: Some("1.0.0".to_string()),
137            ce_marking_reference: Some("EU-DoC-2024-001".to_string()),
138            update_mechanism: Some("Automatic OTA updates via secure channel".to_string()),
139        };
140        serde_json::to_string_pretty(&example).unwrap_or_default()
141    }
142}
143
144/// Errors that can occur when loading sidecar metadata
145#[derive(Debug)]
146pub enum CraSidecarError {
147    IoError(String),
148    ParseError(String),
149    UnsupportedFormat(String),
150}
151
152impl std::fmt::Display for CraSidecarError {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        match self {
155            Self::IoError(e) => write!(f, "IO error reading sidecar file: {e}"),
156            Self::ParseError(e) => write!(f, "Parse error in sidecar file: {e}"),
157            Self::UnsupportedFormat(ext) => {
158                write!(f, "Unsupported sidecar file format: .{ext}")
159            }
160        }
161    }
162}
163
164impl std::error::Error for CraSidecarError {}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn test_default_has_no_data() {
172        let sidecar = CraSidecarMetadata::default();
173        assert!(!sidecar.has_cra_data());
174    }
175
176    #[test]
177    fn test_has_cra_data_with_contact() {
178        let sidecar = CraSidecarMetadata {
179            security_contact: Some("security@example.com".to_string()),
180            ..Default::default()
181        };
182        assert!(sidecar.has_cra_data());
183    }
184
185    #[test]
186    fn test_example_json_is_valid() {
187        let json = CraSidecarMetadata::example_json();
188        let parsed: Result<CraSidecarMetadata, _> = serde_json::from_str(&json);
189        assert!(parsed.is_ok());
190    }
191
192    #[test]
193    fn test_json_roundtrip() {
194        let original = CraSidecarMetadata {
195            security_contact: Some("test@example.com".to_string()),
196            support_end_date: Some(Utc::now()),
197            ..Default::default()
198        };
199        let json = serde_json::to_string(&original).unwrap();
200        let parsed: CraSidecarMetadata = serde_json::from_str(&json).unwrap();
201        assert_eq!(original.security_contact, parsed.security_contact);
202    }
203}