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 =
61            std::fs::read_to_string(path).map_err(|e| CraSidecarError::IoError(e.to_string()))?;
62        serde_json::from_str(&content).map_err(|e| CraSidecarError::ParseError(e.to_string()))
63    }
64
65    /// Load sidecar metadata from a YAML file
66    pub fn from_yaml_file(path: &Path) -> Result<Self, CraSidecarError> {
67        let content =
68            std::fs::read_to_string(path).map_err(|e| CraSidecarError::IoError(e.to_string()))?;
69        serde_yaml_ng::from_str(&content).map_err(|e| CraSidecarError::ParseError(e.to_string()))
70    }
71
72    /// Load sidecar metadata, auto-detecting format from extension
73    pub fn from_file(path: &Path) -> Result<Self, CraSidecarError> {
74        let extension = path
75            .extension()
76            .and_then(|e| e.to_str())
77            .unwrap_or("")
78            .to_lowercase();
79
80        match extension.as_str() {
81            "json" => Self::from_json_file(path),
82            "yaml" | "yml" => Self::from_yaml_file(path),
83            _ => Err(CraSidecarError::UnsupportedFormat(extension)),
84        }
85    }
86
87    /// Try to find a sidecar file for the given SBOM path
88    /// Looks for .cra.json or .cra.yaml files alongside the SBOM
89    #[must_use]
90    pub fn find_for_sbom(sbom_path: &Path) -> Option<Self> {
91        let parent = sbom_path.parent()?;
92        let stem = sbom_path.file_stem()?.to_str()?;
93
94        // Try common sidecar naming patterns
95        let patterns = [
96            format!("{stem}.cra.json"),
97            format!("{stem}.cra.yaml"),
98            format!("{stem}.cra.yml"),
99            format!("{stem}-cra.json"),
100            format!("{stem}-cra.yaml"),
101        ];
102
103        for pattern in &patterns {
104            let sidecar_path = parent.join(pattern);
105            if sidecar_path.exists()
106                && let Ok(metadata) = Self::from_file(&sidecar_path)
107            {
108                return Some(metadata);
109            }
110        }
111
112        None
113    }
114
115    /// Check if any CRA-relevant fields are populated
116    #[must_use]
117    pub const 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    #[must_use]
127    pub fn example_json() -> String {
128        let example = Self {
129            security_contact: Some("security@example.com".to_string()),
130            vulnerability_disclosure_url: Some("https://example.com/security".to_string()),
131            support_end_date: Some(Utc::now() + chrono::Duration::days(365 * 2)),
132            manufacturer_name: Some("Example Corp".to_string()),
133            manufacturer_email: Some("contact@example.com".to_string()),
134            product_name: Some("Example Product".to_string()),
135            product_version: Some("1.0.0".to_string()),
136            ce_marking_reference: Some("EU-DoC-2024-001".to_string()),
137            update_mechanism: Some("Automatic OTA updates via secure channel".to_string()),
138        };
139        serde_json::to_string_pretty(&example).unwrap_or_default()
140    }
141}
142
143/// Errors that can occur when loading sidecar metadata
144#[derive(Debug)]
145pub enum CraSidecarError {
146    IoError(String),
147    ParseError(String),
148    UnsupportedFormat(String),
149}
150
151impl std::fmt::Display for CraSidecarError {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        match self {
154            Self::IoError(e) => write!(f, "IO error reading sidecar file: {e}"),
155            Self::ParseError(e) => write!(f, "Parse error in sidecar file: {e}"),
156            Self::UnsupportedFormat(ext) => {
157                write!(f, "Unsupported sidecar file format: .{ext}")
158            }
159        }
160    }
161}
162
163impl std::error::Error for CraSidecarError {}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_default_has_no_data() {
171        let sidecar = CraSidecarMetadata::default();
172        assert!(!sidecar.has_cra_data());
173    }
174
175    #[test]
176    fn test_has_cra_data_with_contact() {
177        let sidecar = CraSidecarMetadata {
178            security_contact: Some("security@example.com".to_string()),
179            ..Default::default()
180        };
181        assert!(sidecar.has_cra_data());
182    }
183
184    #[test]
185    fn test_example_json_is_valid() {
186        let json = CraSidecarMetadata::example_json();
187        let parsed: Result<CraSidecarMetadata, _> = serde_json::from_str(&json);
188        assert!(parsed.is_ok());
189    }
190
191    #[test]
192    fn test_json_roundtrip() {
193        let original = CraSidecarMetadata {
194            security_contact: Some("test@example.com".to_string()),
195            support_end_date: Some(Utc::now()),
196            ..Default::default()
197        };
198        let json = serde_json::to_string(&original).unwrap();
199        let parsed: CraSidecarMetadata = serde_json::from_str(&json).unwrap();
200        assert_eq!(original.security_contact, parsed.security_contact);
201    }
202}