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 =
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 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 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 #[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 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 #[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 #[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#[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}