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::storage::CsafStorage;
12use crate::validation;
13
14/// Result of a bulk import operation.
15#[derive(Debug, Default)]
16pub struct ImportResult {
17    /// Number of documents successfully imported.
18    pub imported: usize,
19    /// Number of documents skipped due to validation errors.
20    pub skipped: usize,
21    /// Errors encountered during import.
22    pub errors: Vec<String>,
23}
24
25/// Import CSAF documents from a directory (recursively).
26///
27/// Scans for `*.json` files, attempts to parse and validate each one,
28/// and stores valid documents in the provided storage.
29///
30/// # Errors
31///
32/// Returns an error if the directory cannot be read.
33pub fn import_directory(import_dir: &Path, storage: &CsafStorage) -> Result<ImportResult> {
34    if !import_dir.exists() {
35        return Err(CsafError::Import(format!(
36            "Import directory does not exist: {}",
37            import_dir.display()
38        )));
39    }
40
41    let mut result = ImportResult::default();
42    import_recursive(import_dir, storage, &mut result)?;
43
44    tracing::info!(
45        imported = result.imported,
46        skipped = result.skipped,
47        errors = result.errors.len(),
48        "Import completed"
49    );
50
51    Ok(result)
52}
53
54/// Recursively scan a directory for JSON files and import them.
55//
56// Cognitive complexity is intentionally above the default threshold:
57// a single function owns filesystem traversal + skip rules + parse /
58// validate / store / audit-log flow per file, so that import errors
59// land in one place instead of being threaded through helper
60// signatures. Splitting it further hurts readability.
61#[allow(clippy::cognitive_complexity)]
62fn import_recursive(dir: &Path, storage: &CsafStorage, result: &mut ImportResult) -> Result<()> {
63    let entries = std::fs::read_dir(dir)?;
64
65    for entry in entries {
66        let entry = entry?;
67        let path = entry.path();
68
69        if path.is_dir() {
70            import_recursive(&path, storage, result)?;
71            continue;
72        }
73
74        if path.extension().is_some_and(|e| e == "json") {
75            // Skip provider-metadata.json.
76            if path
77                .file_name()
78                .is_some_and(|f| f == "provider-metadata.json")
79            {
80                continue;
81            }
82
83            match import_single_file(&path, storage) {
84                Ok(()) => {
85                    result.imported += 1;
86                },
87                Err(e) => {
88                    let msg = format!("{}: {e}", path.display());
89                    tracing::warn!(path = %path.display(), error = %e, "Skipping file");
90                    result.errors.push(msg);
91                    result.skipped += 1;
92                },
93            }
94        }
95    }
96
97    Ok(())
98}
99
100/// Import a single JSON file as a CSAF document.
101fn import_single_file(path: &Path, storage: &CsafStorage) -> Result<()> {
102    let content = std::fs::read_to_string(path)?;
103    let doc: CsafDocument =
104        serde_json::from_str(&content).map_err(|e| CsafError::Import(e.to_string()))?;
105
106    // Validate.
107    let errors = validation::validate(&doc);
108    let hard_errors: Vec<_> = errors
109        .iter()
110        .filter(|e| e.severity == validation::Severity::Error)
111        .collect();
112
113    if !hard_errors.is_empty() {
114        let messages: Vec<String> = hard_errors.iter().map(|e| e.message.clone()).collect();
115        return Err(CsafError::Validation(messages.join("; ")));
116    }
117
118    storage.put_document(&doc)?;
119
120    tracing::info!(
121        tracking_id = doc.tracking_id(),
122        path = %path.display(),
123        "Imported CSAF document"
124    );
125
126    Ok(())
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_import_test_directory() {
135        let storage = CsafStorage::open_temp().expect("open failed");
136        let test_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/csaf");
137
138        let result = import_directory(&test_dir, &storage).expect("import failed");
139
140        assert!(
141            result.imported >= 15,
142            "Expected at least 15 imports, got {}",
143            result.imported
144        );
145        assert_eq!(
146            result.skipped, 0,
147            "No files should be skipped: {:?}",
148            result.errors
149        );
150    }
151
152    #[test]
153    fn test_import_nonexistent_directory() {
154        let storage = CsafStorage::open_temp().expect("open failed");
155        let result = import_directory(Path::new("/nonexistent/path"), &storage);
156        assert!(result.is_err());
157    }
158
159    #[test]
160    fn test_import_empty_directory() {
161        let dir = tempfile::tempdir().expect("tmpdir failed");
162        let storage = CsafStorage::open_temp().expect("open failed");
163
164        let result = import_directory(dir.path(), &storage).expect("import failed");
165        assert_eq!(result.imported, 0);
166        assert_eq!(result.skipped, 0);
167    }
168
169    #[test]
170    fn test_import_invalid_json() {
171        let dir = tempfile::tempdir().expect("tmpdir failed");
172        std::fs::write(dir.path().join("bad.json"), "not valid json").expect("write failed");
173
174        let storage = CsafStorage::open_temp().expect("open failed");
175        let result = import_directory(dir.path(), &storage).expect("import failed");
176
177        assert_eq!(result.imported, 0);
178        assert_eq!(result.skipped, 1);
179        assert!(!result.errors.is_empty());
180    }
181
182    #[test]
183    fn test_import_skips_provider_metadata() {
184        let dir = tempfile::tempdir().expect("tmpdir failed");
185        let meta = include_str!("../../../test/csaf/provider-metadata.json");
186        std::fs::write(dir.path().join("provider-metadata.json"), meta).expect("write failed");
187
188        let storage = CsafStorage::open_temp().expect("open failed");
189        let result = import_directory(dir.path(), &storage).expect("import failed");
190
191        assert_eq!(result.imported, 0);
192        assert_eq!(result.skipped, 0);
193    }
194}