Skip to main content

csaf_core/
export.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! CSAF document export to filesystem with sidecar files.
5
6use 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
16/// Export a single CSAF document to the export directory.
17///
18/// The document's `csaf_version` is rewritten to match the active
19/// `settings.csaf_mode` before writing, so switching the mode and
20/// re-exporting yields files in the selected version.
21///
22/// The document is re-validated after the version override; exports
23/// with hard validation errors are refused.
24///
25/// Creates the directory structure: `{export_dir}/{year}/{seq}/`
26/// and writes the JSON file plus sidecar hash files.
27///
28/// # Errors
29///
30/// Returns an error if the tracking ID format is invalid, validation
31/// fails, or file writing fails.
32pub 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    // Override csaf_version to match the active mode from settings, so the
37    // "CSAF mode" setting is actually reflected in exported files.
38    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    // Validate the (possibly version-overridden) document. Reject on hard errors.
47    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    // Write to temp file then rename for atomicity.
73    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    // Generate sidecar files.
78    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
95/// Export provider metadata to the export directory root.
96///
97/// # Errors
98///
99/// Returns an error if file writing fails.
100pub 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
115/// Parse a tracking ID into (year, sequence) components.
116///
117/// Expects format like `ndaal-sa-2026-003` where the naming convention
118/// prefix is `ndaal-sa-`.
119///
120/// # Errors
121///
122/// Returns an error if the tracking ID doesn't match the expected format.
123pub 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    // Validate year is numeric.
146    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/// Result of a bulk export operation.
156#[derive(Debug, Default)]
157pub struct ExportResult {
158    /// Number of documents successfully exported.
159    pub exported: usize,
160    /// Errors encountered during export.
161    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        // Verify the exported document still has csaf_version "2.1".
209        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            // User switched the mode to 2.0 — exported files must reflect it.
220            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"); // source is 2.1
229
230        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        // In-memory document must remain unchanged.
240        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(); // invalid: empty title
254
255        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}