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 data_dir = crate::fs::DataDir::open_or_create(&settings.export_directory)?;
70
71 let rel_dir = format!("{year}/{seq}");
72 let rel_json = format!("{rel_dir}/{tracking_id}.json");
73 if !crate::path_security::is_safe_relative_path(&rel_json) {
74 return Err(CsafError::Export(format!(
75 "tracking ID '{tracking_id}' resolves outside the export directory"
76 )));
77 }
78
79 data_dir.create_dir_all(&rel_dir)?;
80
81 let json_bytes = serde_json::to_vec_pretty(&override_doc)?;
82 data_dir.write_atomic(&rel_json, &json_bytes)?;
83
84 sidecar::write_sidecar_files(
86 &data_dir,
87 &rel_json,
88 &json_bytes,
89 sidecar::SidecarHashes::from_settings(settings),
90 )?;
91
92 let file_path = data_dir.resolve(&rel_json);
93 tracing::info!(
94 tracking_id = tracking_id,
95 csaf_version = %override_doc.document.csaf_version,
96 path = %file_path.display(),
97 "Exported CSAF document"
98 );
99
100 Ok(file_path)
101}
102
103pub fn export_provider_metadata(
109 meta: &ProviderMetadata,
110 export_dir: impl AsRef<Path>,
111) -> Result<PathBuf> {
112 let data_dir = crate::fs::DataDir::open_or_create(export_dir.as_ref())?;
113 let json_bytes = serde_json::to_vec_pretty(meta)?;
114 data_dir.write_atomic("provider-metadata.json", &json_bytes)?;
115
116 let file_path = data_dir.resolve("provider-metadata.json");
117 tracing::info!(path = %file_path.display(), "Exported provider metadata");
118
119 Ok(file_path)
120}
121
122pub fn parse_tracking_id(tracking_id: &str, prefix: &str) -> Result<(String, String)> {
131 let remainder = tracking_id.strip_prefix(prefix).ok_or_else(|| {
132 CsafError::Export(format!(
133 "Tracking ID '{tracking_id}' does not start with prefix '{prefix}'"
134 ))
135 })?;
136
137 let (year_str, seq_str) = remainder.split_once('-').ok_or_else(|| {
138 CsafError::Export(format!(
139 "Tracking ID '{tracking_id}' does not match expected format '{prefix}YYYY-NNN'"
140 ))
141 })?;
142
143 if seq_str.contains('-') {
144 return Err(CsafError::Export(format!(
145 "Tracking ID '{tracking_id}' has too many segments after prefix"
146 )));
147 }
148
149 let year = year_str.to_owned();
150 let seq = seq_str.to_owned();
151
152 if year.len() != 4 || year.parse::<u16>().is_err() {
154 return Err(CsafError::Export(format!(
155 "Invalid year '{year}' in tracking ID '{tracking_id}'"
156 )));
157 }
158
159 Ok((year, seq))
160}
161
162#[derive(Debug, Default)]
164pub struct ExportResult {
165 pub exported: usize,
167 pub errors: Vec<String>,
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn test_parse_tracking_id() {
177 let (year, seq) =
178 parse_tracking_id("ndaal-sa-2026-003", "ndaal-sa-").expect("parse failed");
179 assert_eq!(year, "2026");
180 assert_eq!(seq, "003");
181 }
182
183 #[test]
184 fn test_parse_tracking_id_invalid_prefix() {
185 let result = parse_tracking_id("other-2026-001", "ndaal-sa-");
186 assert!(result.is_err());
187 }
188
189 #[test]
190 fn test_parse_tracking_id_invalid_format() {
191 let result = parse_tracking_id("ndaal-sa-invalid", "ndaal-sa-");
192 assert!(result.is_err());
193 }
194
195 #[test]
196 fn test_export_document_preserves_version_when_matching() {
197 let dir = tempfile::tempdir().expect("tmpdir failed");
198 let settings = Settings {
199 export_directory: dir.path().to_string_lossy().to_string(),
200 csaf_mode: "2.1".to_owned(),
201 sidecar_sha256: true,
202 sidecar_sha3_512: true,
203 ..Settings::default()
204 };
205
206 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
207 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
208
209 let path = export_document(&doc, &settings).expect("export failed");
210 assert!(path.exists());
211 assert!(path.with_extension("json.sha-256").exists());
212 assert!(path.with_extension("json.sha-512").exists());
213 assert!(path.with_extension("json.sha3-512").exists());
214 assert!(!path.with_extension("json.sha256").exists());
216 assert!(!path.with_extension("json.sha512").exists());
217 assert!(dir.path().join("2026/003/ndaal-sa-2026-003.json").exists());
218
219 let content = std::fs::read_to_string(&path).expect("read failed");
221 let exported: CsafDocument = serde_json::from_str(&content).expect("parse exported");
222 assert_eq!(exported.document.csaf_version, "2.1");
223 }
224
225 #[test]
226 fn test_export_document_overrides_csaf_version_from_settings() {
227 let dir = tempfile::tempdir().expect("tmpdir failed");
228 let settings = Settings {
229 export_directory: dir.path().to_string_lossy().to_string(),
230 csaf_mode: "2.0".to_owned(),
232 sidecar_sha256: true,
233 sidecar_sha3_512: true,
234 ..Settings::default()
235 };
236
237 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
238 let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
239 assert_eq!(doc.document.csaf_version, "2.1"); let path = export_document(&doc, &settings).expect("export failed");
242 let content = std::fs::read_to_string(&path).expect("read failed");
243 let exported: CsafDocument = serde_json::from_str(&content).expect("parse exported");
244
245 assert_eq!(
246 exported.document.csaf_version, "2.0",
247 "csaf_mode setting must override document csaf_version on export"
248 );
249
250 assert_eq!(doc.document.csaf_version, "2.1");
252 }
253
254 #[test]
255 fn test_export_document_refuses_invalid_document() {
256 let dir = tempfile::tempdir().expect("tmpdir failed");
257 let settings = Settings {
258 export_directory: dir.path().to_string_lossy().to_string(),
259 ..Settings::default()
260 };
261
262 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
263 let mut doc: CsafDocument = serde_json::from_str(json).expect("parse error");
264 doc.document.title.clear(); let result = export_document(&doc, &settings);
267 assert!(result.is_err(), "Export should refuse invalid document");
268 let err = result.unwrap_err().to_string();
269 assert!(err.contains("validation failed"), "got: {err}");
270 }
271
272 #[test]
273 fn test_export_document_rejects_traversal_tracking_id() {
274 let dir = tempfile::tempdir().expect("tmpdir failed");
275 let export_root = dir.path().join("export");
278 let settings = Settings {
279 export_directory: export_root.to_string_lossy().to_string(),
280 ..Settings::default()
281 };
282
283 let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
284 let mut doc: CsafDocument = serde_json::from_str(json).expect("parse error");
285 doc.document.tracking.id = "ndaal-sa-2026-../../escaped".to_owned();
289
290 let result = export_document(&doc, &settings);
291 assert!(
292 result.is_err(),
293 "export must refuse a tracking ID that escapes the export directory"
294 );
295 assert!(!dir.path().join("escaped").exists());
297 }
298
299 #[test]
300 fn test_export_provider_metadata() {
301 let dir = tempfile::tempdir().expect("tmpdir failed");
302 let json = include_str!("../../../test/csaf/provider-metadata.json");
303 let meta: ProviderMetadata = serde_json::from_str(json).expect("parse error");
304
305 let path = export_provider_metadata(&meta, dir.path()).expect("export failed");
306 assert!(path.exists());
307 assert_eq!(
308 path.file_name().expect("no filename").to_str(),
309 Some("provider-metadata.json")
310 );
311 }
312}