csaf-core 0.3.0

CSAF storage, validation, sidecar generation, import/export
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2026 Pierre Gronau, ndaal in Cologne

//! CSAF document import from filesystem.

use std::path::Path;

use csaf_models::csaf_document::CsafDocument;

use crate::error::{CsafError, Result};
use crate::storage::CsafStorage;
use crate::validation;

/// Result of a bulk import operation.
#[derive(Debug, Default)]
pub struct ImportResult {
    /// Number of documents successfully imported.
    pub imported: usize,
    /// Number of documents skipped due to validation errors.
    pub skipped: usize,
    /// Errors encountered during import.
    pub errors: Vec<String>,
}

/// Import CSAF documents from a directory (recursively).
///
/// Scans for `*.json` files, attempts to parse and validate each one,
/// and stores valid documents in the provided storage.
///
/// # Errors
///
/// Returns an error if the directory cannot be read.
pub fn import_directory(import_dir: &Path, storage: &CsafStorage) -> Result<ImportResult> {
    if !import_dir.exists() {
        return Err(CsafError::Import(format!(
            "Import directory does not exist: {}",
            import_dir.display()
        )));
    }

    let mut result = ImportResult::default();
    import_recursive(import_dir, storage, &mut result)?;

    tracing::info!(
        imported = result.imported,
        skipped = result.skipped,
        errors = result.errors.len(),
        "Import completed"
    );

    Ok(result)
}

/// Recursively scan a directory for JSON files and import them.
//
// Cognitive complexity is intentionally above the default threshold:
// a single function owns filesystem traversal + skip rules + parse /
// validate / store / audit-log flow per file, so that import errors
// land in one place instead of being threaded through helper
// signatures. Splitting it further hurts readability.
#[allow(clippy::cognitive_complexity)]
fn import_recursive(dir: &Path, storage: &CsafStorage, result: &mut ImportResult) -> Result<()> {
    let entries = std::fs::read_dir(dir)?;

    for entry in entries {
        let entry = entry?;
        let path = entry.path();

        if path.is_dir() {
            import_recursive(&path, storage, result)?;
            continue;
        }

        if path.extension().is_some_and(|e| e == "json") {
            // Skip provider-metadata.json.
            if path
                .file_name()
                .is_some_and(|f| f == "provider-metadata.json")
            {
                continue;
            }

            match import_single_file(&path, storage) {
                Ok(()) => {
                    result.imported += 1;
                },
                Err(e) => {
                    let msg = format!("{}: {e}", path.display());
                    tracing::warn!(path = %path.display(), error = %e, "Skipping file");
                    result.errors.push(msg);
                    result.skipped += 1;
                },
            }
        }
    }

    Ok(())
}

/// Import a single JSON file as a CSAF document.
fn import_single_file(path: &Path, storage: &CsafStorage) -> Result<()> {
    let content = std::fs::read_to_string(path)?;
    let doc: CsafDocument =
        serde_json::from_str(&content).map_err(|e| CsafError::Import(e.to_string()))?;

    // Validate.
    let errors = validation::validate(&doc);
    let hard_errors: Vec<_> = errors
        .iter()
        .filter(|e| e.severity == validation::Severity::Error)
        .collect();

    if !hard_errors.is_empty() {
        let messages: Vec<String> = hard_errors.iter().map(|e| e.message.clone()).collect();
        return Err(CsafError::Validation(messages.join("; ")));
    }

    storage.put_document(&doc)?;

    tracing::info!(
        tracking_id = doc.tracking_id(),
        path = %path.display(),
        "Imported CSAF document"
    );

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_import_test_directory() {
        let storage = CsafStorage::open_temp().expect("open failed");
        let test_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/csaf");

        let result = import_directory(&test_dir, &storage).expect("import failed");

        assert!(
            result.imported >= 15,
            "Expected at least 15 imports, got {}",
            result.imported
        );
        assert_eq!(
            result.skipped, 0,
            "No files should be skipped: {:?}",
            result.errors
        );
    }

    #[test]
    fn test_import_nonexistent_directory() {
        let storage = CsafStorage::open_temp().expect("open failed");
        let result = import_directory(Path::new("/nonexistent/path"), &storage);
        assert!(result.is_err());
    }

    #[test]
    fn test_import_empty_directory() {
        let dir = tempfile::tempdir().expect("tmpdir failed");
        let storage = CsafStorage::open_temp().expect("open failed");

        let result = import_directory(dir.path(), &storage).expect("import failed");
        assert_eq!(result.imported, 0);
        assert_eq!(result.skipped, 0);
    }

    #[test]
    fn test_import_invalid_json() {
        let dir = tempfile::tempdir().expect("tmpdir failed");
        std::fs::write(dir.path().join("bad.json"), "not valid json").expect("write failed");

        let storage = CsafStorage::open_temp().expect("open failed");
        let result = import_directory(dir.path(), &storage).expect("import failed");

        assert_eq!(result.imported, 0);
        assert_eq!(result.skipped, 1);
        assert!(!result.errors.is_empty());
    }

    #[test]
    fn test_import_skips_provider_metadata() {
        let dir = tempfile::tempdir().expect("tmpdir failed");
        let meta = include_str!("../../../test/csaf/provider-metadata.json");
        std::fs::write(dir.path().join("provider-metadata.json"), meta).expect("write failed");

        let storage = CsafStorage::open_temp().expect("open failed");
        let result = import_directory(dir.path(), &storage).expect("import failed");

        assert_eq!(result.imported, 0);
        assert_eq!(result.skipped, 0);
    }
}