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    pub fn find_for_sbom(sbom_path: &Path) -> Option<Self> {
92        let parent = sbom_path.parent()?;
93        let stem = sbom_path.file_stem()?.to_str()?;
94
95        // Try common sidecar naming patterns
96        let patterns = [
97            format!("{}.cra.json", stem),
98            format!("{}.cra.yaml", stem),
99            format!("{}.cra.yml", stem),
100            format!("{}-cra.json", stem),
101            format!("{}-cra.yaml", stem),
102        ];
103
104        for pattern in &patterns {
105            let sidecar_path = parent.join(pattern);
106            if sidecar_path.exists() {
107                if let Ok(metadata) = Self::from_file(&sidecar_path) {
108                    return Some(metadata);
109                }
110            }
111        }
112
113        None
114    }
115
116    /// Check if any CRA-relevant fields are populated
117    pub fn has_cra_data(&self) -> bool {
118        self.security_contact.is_some()
119            || self.vulnerability_disclosure_url.is_some()
120            || self.support_end_date.is_some()
121            || self.manufacturer_name.is_some()
122            || self.ce_marking_reference.is_some()
123    }
124
125    /// Generate an example sidecar file content
126    pub fn example_json() -> String {
127        let example = Self {
128            security_contact: Some("security@example.com".to_string()),
129            vulnerability_disclosure_url: Some("https://example.com/security".to_string()),
130            support_end_date: Some(Utc::now() + chrono::Duration::days(365 * 2)),
131            manufacturer_name: Some("Example Corp".to_string()),
132            manufacturer_email: Some("contact@example.com".to_string()),
133            product_name: Some("Example Product".to_string()),
134            product_version: Some("1.0.0".to_string()),
135            ce_marking_reference: Some("EU-DoC-2024-001".to_string()),
136            update_mechanism: Some("Automatic OTA updates via secure channel".to_string()),
137        };
138        serde_json::to_string_pretty(&example).unwrap_or_default()
139    }
140}
141
142/// Errors that can occur when loading sidecar metadata
143#[derive(Debug)]
144pub enum CraSidecarError {
145    IoError(String),
146    ParseError(String),
147    UnsupportedFormat(String),
148}
149
150impl std::fmt::Display for CraSidecarError {
151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152        match self {
153            Self::IoError(e) => write!(f, "IO error reading sidecar file: {}", e),
154            Self::ParseError(e) => write!(f, "Parse error in sidecar file: {}", e),
155            Self::UnsupportedFormat(ext) => {
156                write!(f, "Unsupported sidecar file format: .{}", ext)
157            }
158        }
159    }
160}
161
162impl std::error::Error for CraSidecarError {}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn test_default_has_no_data() {
170        let sidecar = CraSidecarMetadata::default();
171        assert!(!sidecar.has_cra_data());
172    }
173
174    #[test]
175    fn test_has_cra_data_with_contact() {
176        let sidecar = CraSidecarMetadata {
177            security_contact: Some("security@example.com".to_string()),
178            ..Default::default()
179        };
180        assert!(sidecar.has_cra_data());
181    }
182
183    #[test]
184    fn test_example_json_is_valid() {
185        let json = CraSidecarMetadata::example_json();
186        let parsed: Result<CraSidecarMetadata, _> = serde_json::from_str(&json);
187        assert!(parsed.is_ok());
188    }
189
190    #[test]
191    fn test_json_roundtrip() {
192        let original = CraSidecarMetadata {
193            security_contact: Some("test@example.com".to_string()),
194            support_end_date: Some(Utc::now()),
195            ..Default::default()
196        };
197        let json = serde_json::to_string(&original).unwrap();
198        let parsed: CraSidecarMetadata = serde_json::from_str(&json).unwrap();
199        assert_eq!(original.security_contact, parsed.security_contact);
200    }
201}