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_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
96/// Export provider metadata to the export directory root.
97///
98/// # Errors
99///
100/// Returns an error if file writing fails.
101pub 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
116/// Parse a tracking ID into (year, sequence) components.
117///
118/// Expects format like `ndaal-sa-2026-003` where the naming convention
119/// prefix is `ndaal-sa-`.
120///
121/// # Errors
122///
123/// Returns an error if the tracking ID doesn't match the expected format.
124pub 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    // Validate year is numeric.
147    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/// Result of a bulk export operation.
157#[derive(Debug, Default)]
158pub struct ExportResult {
159    /// Number of documents successfully exported.
160    pub exported: usize,
161    /// Errors encountered during export.
162    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        // Regression guard — the legacy unhyphenated form must never leak.
209        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        // Verify the exported document still has csaf_version "2.1".
214        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            // User switched the mode to 2.0 — exported files must reflect it.
225            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"); // source is 2.1
234
235        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        // In-memory document must remain unchanged.
245        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(); // invalid: empty title
259
260        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}