Skip to main content

csaf_core/
import.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne
3
4//! CSAF document import from filesystem.
5
6use std::path::Path;
7
8use csaf_models::csaf_document::CsafDocument;
9
10use crate::error::{CsafError, Result};
11use crate::fs::DataDir;
12use crate::storage::CsafStorage;
13use crate::validation;
14
15/// Result of a bulk import operation.
16#[derive(Debug, Default)]
17pub struct ImportResult {
18    /// Number of documents successfully imported.
19    pub imported: usize,
20    /// Number of documents skipped due to validation errors.
21    pub skipped: usize,
22    /// Errors encountered during import.
23    pub errors: Vec<String>,
24}
25
26/// Import CSAF documents from a directory (recursively).
27///
28/// Scans for `*.json` files, attempts to parse and validate each one,
29/// and stores valid documents in the provided storage.
30///
31/// # Errors
32///
33/// Returns an error if the directory cannot be read.
34pub fn import_directory(
35    import_dir: impl AsRef<Path>,
36    storage: &CsafStorage,
37) -> Result<ImportResult> {
38    let import_dir = import_dir.as_ref();
39    if !import_dir.exists() {
40        return Err(CsafError::Import(format!(
41            "Import directory does not exist: {}",
42            import_dir.display()
43        )));
44    }
45
46    // Open the import directory as a capability handle. The recursive walk
47    // descends through re-opened `Dir` handles, so a symlink or `..` entry
48    // in the tree cannot redirect a read outside the import directory.
49    let data_dir = DataDir::open(import_dir)?;
50
51    let mut result = ImportResult::default();
52    for rel in data_dir.walk_files()? {
53        // Case-insensitive `.json` extension check without `Path::new`
54        // (which would re-introduce a dynamic-path construction).
55        if !rel
56            .rsplit_once('.')
57            .is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("json"))
58        {
59            continue;
60        }
61        let filename = rel.rsplit('/').next().unwrap_or(rel.as_str());
62        if filename == "provider-metadata.json" {
63            continue;
64        }
65
66        match import_single_file(&data_dir, &rel, storage) {
67            Ok(()) => {
68                result.imported += 1;
69            },
70            Err(e) => {
71                let msg = format!("{rel}: {e}");
72                tracing::warn!(path = %rel, error = %e, "Skipping file");
73                result.errors.push(msg);
74                result.skipped += 1;
75            },
76        }
77    }
78
79    tracing::info!(
80        imported = result.imported,
81        skipped = result.skipped,
82        errors = result.errors.len(),
83        "Import completed"
84    );
85
86    Ok(result)
87}
88
89/// Import a single JSON file (confined relative path) as a CSAF document.
90fn import_single_file(dir: &DataDir, rel: &str, storage: &CsafStorage) -> Result<()> {
91    let content = dir.read_to_string(rel)?;
92    let doc: CsafDocument =
93        serde_json::from_str(&content).map_err(|e| CsafError::Import(e.to_string()))?;
94
95    // Validate.
96    let errors = validation::validate(&doc);
97    let hard_errors: Vec<_> = errors
98        .iter()
99        .filter(|e| e.severity == validation::Severity::Error)
100        .collect();
101
102    if !hard_errors.is_empty() {
103        let messages: Vec<String> = hard_errors.iter().map(|e| e.message.clone()).collect();
104        return Err(CsafError::Validation(messages.join("; ")));
105    }
106
107    storage.put_document(&doc)?;
108
109    tracing::info!(
110        tracking_id = doc.tracking_id(),
111        path = %rel,
112        "Imported CSAF document"
113    );
114
115    Ok(())
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn test_import_test_directory() {
124        let storage = CsafStorage::open_temp().expect("open failed");
125        let test_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/csaf");
126
127        let result = import_directory(&test_dir, &storage).expect("import failed");
128
129        assert!(
130            result.imported >= 15,
131            "Expected at least 15 imports, got {}",
132            result.imported
133        );
134        assert_eq!(
135            result.skipped, 0,
136            "No files should be skipped: {:?}",
137            result.errors
138        );
139    }
140
141    #[test]
142    fn test_import_nonexistent_directory() {
143        let storage = CsafStorage::open_temp().expect("open failed");
144        let result = import_directory(Path::new("/nonexistent/path"), &storage);
145        assert!(result.is_err());
146    }
147
148    #[test]
149    fn test_import_empty_directory() {
150        let dir = tempfile::tempdir().expect("tmpdir failed");
151        let storage = CsafStorage::open_temp().expect("open failed");
152
153        let result = import_directory(dir.path(), &storage).expect("import failed");
154        assert_eq!(result.imported, 0);
155        assert_eq!(result.skipped, 0);
156    }
157
158    #[test]
159    fn test_import_invalid_json() {
160        let dir = tempfile::tempdir().expect("tmpdir failed");
161        std::fs::write(dir.path().join("bad.json"), "not valid json").expect("write failed");
162
163        let storage = CsafStorage::open_temp().expect("open failed");
164        let result = import_directory(dir.path(), &storage).expect("import failed");
165
166        assert_eq!(result.imported, 0);
167        assert_eq!(result.skipped, 1);
168        assert!(!result.errors.is_empty());
169    }
170
171    #[test]
172    fn test_import_skips_provider_metadata() {
173        let dir = tempfile::tempdir().expect("tmpdir failed");
174        let meta = include_str!("../../../test/csaf/provider-metadata.json");
175        std::fs::write(dir.path().join("provider-metadata.json"), meta).expect("write failed");
176
177        let storage = CsafStorage::open_temp().expect("open failed");
178        let result = import_directory(dir.path(), &storage).expect("import failed");
179
180        assert_eq!(result.imported, 0);
181        assert_eq!(result.skipped, 0);
182    }
183}