1use std::path::{Path, PathBuf};
7
8use csaf_models::csaf_document::CsafDocument;
9use csaf_models::provider_meta::ProviderMetadata;
10use csaf_models::settings::Settings;
11
12use crate::error::{CsafError, Result};
13use crate::sidecar;
14use crate::validation;
15
16pub fn export_document(doc: &CsafDocument, settings: &Settings) -> Result<PathBuf> {
33 let tracking_id = doc.tracking_id();
34 let (year, seq) = parse_tracking_id(tracking_id, &settings.naming_convention)?;
35
36 let mut override_doc = doc.clone();
39 if override_doc.document.csaf_version != settings.csaf_mode {
40 override_doc
41 .document
42 .csaf_version
43 .clone_from(&settings.csaf_mode);
44 }
45
46 let findings = validation::validate(&override_doc);
48 let hard: Vec<_> = findings
49 .iter()
50 .filter(|e| e.severity == validation::Severity::Error)
51 .collect();
52 if !hard.is_empty() {
53 let messages: Vec<String> = hard
54 .iter()
55 .map(|e| format!("{}: {}", e.path, e.message))
56 .collect();
57 return Err(CsafError::Export(format!(
58 "validation failed for '{tracking_id}': {}",
59 messages.join("; ")
60 )));
61 }
62
63 let export_dir = Path::new(&settings.export_directory);
64 let doc_dir = export_dir.join(&year).join(&seq);
65 std::fs::create_dir_all(&doc_dir)?;
66
67 let filename = format!("{tracking_id}.json");
68 let file_path = doc_dir.join(&filename);
69
70 let json_bytes = serde_json::to_vec_pretty(&override_doc)?;
71
72 let tmp_path = file_path.with_extension("json.tmp");
74 std::fs::write(&tmp_path, &json_bytes)?;
75 std::fs::rename(&tmp_path, &file_path)?;
76
77 sidecar::write_sidecar_files(
79 &file_path,
80 &json_bytes,
81 settings.sidecar_sha256,
82 settings.sidecar_sha512,
83 settings.sidecar_sha3_512,
84 )?;
85
86 tracing::info!(
87 tracking_id = tracking_id,
88 csaf_version = %override_doc.document.csaf_version,
89 path = %file_path.display(),
90 "Exported CSAF document"
91 );
92
93 Ok(file_path)
94}
95
96pub fn export_provider_metadata(meta: &ProviderMetadata, export_dir: &Path) -> Result<PathBuf> {
102 std::fs::create_dir_all(export_dir)?;
103
104 let file_path = export_dir.join("provider-metadata.json");
105 let json_bytes = serde_json::to_vec_pretty(meta)?;
106
107 let tmp_path = file_path.with_extension("json.tmp");
108 std::fs::write(&tmp_path, &json_bytes)?;
109 std::fs::rename(&tmp_path, &file_path)?;
110
111 tracing::info!(path = %file_path.display(), "Exported provider metadata");
112
113 Ok(file_path)
114}
115
116pub fn parse_tracking_id(tracking_id: &str, prefix: &str) -> Result<(String, String)> {
125 let remainder = tracking_id.strip_prefix(prefix).ok_or_else(|| {
126 CsafError::Export(format!(
127 "Tracking ID '{tracking_id}' does not start with prefix '{prefix}'"
128 ))
129 })?;
130
131 let (year_str, seq_str) = remainder.split_once('-').ok_or_else(|| {
132 CsafError::Export(format!(
133 "Tracking ID '{tracking_id}' does not match expected format '{prefix}YYYY-NNN'"
134 ))
135 })?;
136
137 if seq_str.contains('-') {
138 return Err(CsafError::Export(format!(
139 "Tracking ID '{tracking_id}' has too many segments after prefix"
140 )));
141 }
142
143 let year = year_str.to_owned();
144 let seq = seq_str.to_owned();
145
146 if year.len() != 4 || year.parse::<u16>().is_err() {
148 return Err(CsafError::Export(format!(
149 "Invalid year '{year}' in tracking ID '{tracking_id}'"
150 )));
151 }
152
153 Ok((year, seq))
154}
155
156#[derive(Debug, Default)]
158pub struct ExportResult {
159 pub exported: usize,
161 pub errors: Vec<String>,
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 #[test]
170 fn test_parse_tracking_id() {
171 let (year, seq) =
172 parse_tracking_id("ndaal-sa-2026-003", "ndaal-sa-").expect("parse failed");
173 assert_eq!(year, "2026");
174 assert_eq!(seq, "003");
175 }
176
177 #[test]
178 fn test_parse_tracking_id_invalid_prefix() {
179 let result = parse_tracking_id("other-2026-001", "ndaal-sa-");
180 assert!(result.is_err());
181 }
182
183 #[test]
184 fn test_parse_tracking_id_invalid_format() {
185 let result = parse_tracking_id("ndaal-sa-invalid", "ndaal-sa-");
186 assert!(result.is_err());
187 }
188
189 #[test]
190 fn test_export_document_preserves_version_when_matching() {
191 let dir = tempfile::tempdir().expect("tmpdir failed");
192 let settings = Settings {
193 export_directory: dir.path().to_string_lossy().to_string(),
194 csaf_mode: "2.1".to_owned(),
195 sidecar_sha256: true,
196 sidecar_sha3_512: true,
197 ..Settings::default()
198 };
199
200 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
201 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
202
203 let path = export_document(&doc, &settings).expect("export failed");
204 assert!(path.exists());
205 assert!(path.with_extension("json.sha-256").exists());
206 assert!(path.with_extension("json.sha-512").exists());
207 assert!(path.with_extension("json.sha3-512").exists());
208 assert!(!path.with_extension("json.sha256").exists());
210 assert!(!path.with_extension("json.sha512").exists());
211 assert!(dir.path().join("2026/003/ndaal-sa-2026-003.json").exists());
212
213 let content = std::fs::read_to_string(&path).expect("read failed");
215 let exported: CsafDocument = serde_json::from_str(&content).expect("parse exported");
216 assert_eq!(exported.document.csaf_version, "2.1");
217 }
218
219 #[test]
220 fn test_export_document_overrides_csaf_version_from_settings() {
221 let dir = tempfile::tempdir().expect("tmpdir failed");
222 let settings = Settings {
223 export_directory: dir.path().to_string_lossy().to_string(),
224 csaf_mode: "2.0".to_owned(),
226 sidecar_sha256: true,
227 sidecar_sha3_512: true,
228 ..Settings::default()
229 };
230
231 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
232 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
233 assert_eq!(doc.document.csaf_version, "2.1"); let path = export_document(&doc, &settings).expect("export failed");
236 let content = std::fs::read_to_string(&path).expect("read failed");
237 let exported: CsafDocument = serde_json::from_str(&content).expect("parse exported");
238
239 assert_eq!(
240 exported.document.csaf_version, "2.0",
241 "csaf_mode setting must override document csaf_version on export"
242 );
243
244 assert_eq!(doc.document.csaf_version, "2.1");
246 }
247
248 #[test]
249 fn test_export_document_refuses_invalid_document() {
250 let dir = tempfile::tempdir().expect("tmpdir failed");
251 let settings = Settings {
252 export_directory: dir.path().to_string_lossy().to_string(),
253 ..Settings::default()
254 };
255
256 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
257 let mut doc: CsafDocument = serde_json::from_str(json).expect("parse error");
258 doc.document.title.clear(); let result = export_document(&doc, &settings);
261 assert!(result.is_err(), "Export should refuse invalid document");
262 let err = result.unwrap_err().to_string();
263 assert!(err.contains("validation failed"), "got: {err}");
264 }
265
266 #[test]
267 fn test_export_provider_metadata() {
268 let dir = tempfile::tempdir().expect("tmpdir failed");
269 let json = include_str!("../../../test/csaf/provider-metadata.json");
270 let meta: ProviderMetadata = serde_json::from_str(json).expect("parse error");
271
272 let path = export_provider_metadata(&meta, dir.path()).expect("export failed");
273 assert!(path.exists());
274 assert_eq!(
275 path.file_name().expect("no filename").to_str(),
276 Some("provider-metadata.json")
277 );
278 }
279}