sbom_tools/model/
cra_sidecar.rs1use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::path::Path;
15
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct CraSidecarMetadata {
20 #[serde(skip_serializing_if = "Option::is_none")]
22 pub security_contact: Option<String>,
23
24 #[serde(skip_serializing_if = "Option::is_none")]
26 pub vulnerability_disclosure_url: Option<String>,
27
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub support_end_date: Option<DateTime<Utc>>,
31
32 #[serde(skip_serializing_if = "Option::is_none")]
34 pub manufacturer_name: Option<String>,
35
36 #[serde(skip_serializing_if = "Option::is_none")]
38 pub manufacturer_email: Option<String>,
39
40 #[serde(skip_serializing_if = "Option::is_none")]
42 pub product_name: Option<String>,
43
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub product_version: Option<String>,
47
48 #[serde(skip_serializing_if = "Option::is_none")]
50 pub ce_marking_reference: Option<String>,
51
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub update_mechanism: Option<String>,
55}
56
57impl CraSidecarMetadata {
58 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 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 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 #[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 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 #[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 #[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#[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}