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_sha3_512,
83 )?;
84
85 tracing::info!(
86 tracking_id = tracking_id,
87 csaf_version = %override_doc.document.csaf_version,
88 path = %file_path.display(),
89 "Exported CSAF document"
90 );
91
92 Ok(file_path)
93}
94
95pub fn export_provider_metadata(meta: &ProviderMetadata, export_dir: &Path) -> Result<PathBuf> {
101 std::fs::create_dir_all(export_dir)?;
102
103 let file_path = export_dir.join("provider-metadata.json");
104 let json_bytes = serde_json::to_vec_pretty(meta)?;
105
106 let tmp_path = file_path.with_extension("json.tmp");
107 std::fs::write(&tmp_path, &json_bytes)?;
108 std::fs::rename(&tmp_path, &file_path)?;
109
110 tracing::info!(path = %file_path.display(), "Exported provider metadata");
111
112 Ok(file_path)
113}
114
115pub fn parse_tracking_id(tracking_id: &str, prefix: &str) -> Result<(String, String)> {
124 let remainder = tracking_id.strip_prefix(prefix).ok_or_else(|| {
125 CsafError::Export(format!(
126 "Tracking ID '{tracking_id}' does not start with prefix '{prefix}'"
127 ))
128 })?;
129
130 let (year_str, seq_str) = remainder.split_once('-').ok_or_else(|| {
131 CsafError::Export(format!(
132 "Tracking ID '{tracking_id}' does not match expected format '{prefix}YYYY-NNN'"
133 ))
134 })?;
135
136 if seq_str.contains('-') {
137 return Err(CsafError::Export(format!(
138 "Tracking ID '{tracking_id}' has too many segments after prefix"
139 )));
140 }
141
142 let year = year_str.to_owned();
143 let seq = seq_str.to_owned();
144
145 if year.len() != 4 || year.parse::<u16>().is_err() {
147 return Err(CsafError::Export(format!(
148 "Invalid year '{year}' in tracking ID '{tracking_id}'"
149 )));
150 }
151
152 Ok((year, seq))
153}
154
155#[derive(Debug, Default)]
157pub struct ExportResult {
158 pub exported: usize,
160 pub errors: Vec<String>,
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn test_parse_tracking_id() {
170 let (year, seq) =
171 parse_tracking_id("ndaal-sa-2026-003", "ndaal-sa-").expect("parse failed");
172 assert_eq!(year, "2026");
173 assert_eq!(seq, "003");
174 }
175
176 #[test]
177 fn test_parse_tracking_id_invalid_prefix() {
178 let result = parse_tracking_id("other-2026-001", "ndaal-sa-");
179 assert!(result.is_err());
180 }
181
182 #[test]
183 fn test_parse_tracking_id_invalid_format() {
184 let result = parse_tracking_id("ndaal-sa-invalid", "ndaal-sa-");
185 assert!(result.is_err());
186 }
187
188 #[test]
189 fn test_export_document_preserves_version_when_matching() {
190 let dir = tempfile::tempdir().expect("tmpdir failed");
191 let settings = Settings {
192 export_directory: dir.path().to_string_lossy().to_string(),
193 csaf_mode: "2.1".to_owned(),
194 sidecar_sha256: true,
195 sidecar_sha3_512: true,
196 ..Settings::default()
197 };
198
199 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
200 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
201
202 let path = export_document(&doc, &settings).expect("export failed");
203 assert!(path.exists());
204 assert!(path.with_extension("json.sha256").exists());
205 assert!(path.with_extension("json.sha3-512").exists());
206 assert!(dir.path().join("2026/003/ndaal-sa-2026-003.json").exists());
207
208 let content = std::fs::read_to_string(&path).expect("read failed");
210 let exported: CsafDocument = serde_json::from_str(&content).expect("parse exported");
211 assert_eq!(exported.document.csaf_version, "2.1");
212 }
213
214 #[test]
215 fn test_export_document_overrides_csaf_version_from_settings() {
216 let dir = tempfile::tempdir().expect("tmpdir failed");
217 let settings = Settings {
218 export_directory: dir.path().to_string_lossy().to_string(),
219 csaf_mode: "2.0".to_owned(),
221 sidecar_sha256: true,
222 sidecar_sha3_512: true,
223 ..Settings::default()
224 };
225
226 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
227 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
228 assert_eq!(doc.document.csaf_version, "2.1"); let path = export_document(&doc, &settings).expect("export failed");
231 let content = std::fs::read_to_string(&path).expect("read failed");
232 let exported: CsafDocument = serde_json::from_str(&content).expect("parse exported");
233
234 assert_eq!(
235 exported.document.csaf_version, "2.0",
236 "csaf_mode setting must override document csaf_version on export"
237 );
238
239 assert_eq!(doc.document.csaf_version, "2.1");
241 }
242
243 #[test]
244 fn test_export_document_refuses_invalid_document() {
245 let dir = tempfile::tempdir().expect("tmpdir failed");
246 let settings = Settings {
247 export_directory: dir.path().to_string_lossy().to_string(),
248 ..Settings::default()
249 };
250
251 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
252 let mut doc: CsafDocument = serde_json::from_str(json).expect("parse error");
253 doc.document.title.clear(); let result = export_document(&doc, &settings);
256 assert!(result.is_err(), "Export should refuse invalid document");
257 let err = result.unwrap_err().to_string();
258 assert!(err.contains("validation failed"), "got: {err}");
259 }
260
261 #[test]
262 fn test_export_provider_metadata() {
263 let dir = tempfile::tempdir().expect("tmpdir failed");
264 let json = include_str!("../../../test/csaf/provider-metadata.json");
265 let meta: ProviderMetadata = serde_json::from_str(json).expect("parse error");
266
267 let path = export_provider_metadata(&meta, dir.path()).expect("export failed");
268 assert!(path.exists());
269 assert_eq!(
270 path.file_name().expect("no filename").to_str(),
271 Some("provider-metadata.json")
272 );
273 }
274}