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    // Open the export directory as a capability handle: every write
64    // below is confined to it at the syscall layer. `year` / `seq` derive
65    // from the document's tracking ID, which is attacker-controllable on
66    // import (validation only checks the ID is non-empty, not its shape),
67    // so the relative path is also checked with `is_safe_relative_path`
68    // for a clear error before any filesystem mutation.
69    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    // Generate sidecar files (confined to the same export directory).
85    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
103/// Export provider metadata to the export directory root.
104///
105/// # Errors
106///
107/// Returns an error if file writing fails.
108pub 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
122/// Parse a tracking ID into (year, sequence) components.
123///
124/// Expects format like `ndaal-sa-2026-003` where the naming convention
125/// prefix is `ndaal-sa-`.
126///
127/// # Errors
128///
129/// Returns an error if the tracking ID doesn't match the expected format.
130pub 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    // Validate year is numeric.
153    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/// Result of a bulk export operation.
163#[derive(Debug, Default)]
164pub struct ExportResult {
165    /// Number of documents successfully exported.
166    pub exported: usize,
167    /// Errors encountered during export.
168    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        // Regression guard — the legacy unhyphenated form must never leak.
215        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        // Verify the exported document still has csaf_version "2.1".
220        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            // User switched the mode to 2.0 — exported files must reflect it.
231            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"); // source is 2.1
240
241        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        // In-memory document must remain unchanged.
251        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(); // invalid: empty title
265
266        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        // Export into a subdirectory so an escaping `..` would land
276        // elsewhere under the (cleaned-up) temp root, never the real FS.
277        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        // Crafted ID: passes validation (non-empty) and parse_tracking_id
286        // (4-digit year, no extra '-' in the sequence) but the sequence
287        // escapes the export directory via `..`.
288        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        // Nothing may have been written outside the export directory.
296        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}