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