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 && let Ok(metadata) = Self::from_file(&sidecar_path) {
109 return Some(metadata);
110 }
111 }
112
113 None
114 }
115
116 #[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 #[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#[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}