csaf-core 0.3.4

CSAF storage, validation, sidecar generation, import/export
// 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::fs::DataDir;
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: impl AsRef<Path>,
    storage: &CsafStorage,
) -> Result<ImportResult> {
    let import_dir = import_dir.as_ref();
    if !import_dir.exists() {
        return Err(CsafError::Import(format!(
            "Import directory does not exist: {}",
            import_dir.display()
        )));
    }

    // Open the import directory as a capability handle. The recursive walk
    // descends through re-opened `Dir` handles, so a symlink or `..` entry
    // in the tree cannot redirect a read outside the import directory.
    let data_dir = DataDir::open(import_dir)?;

    let mut result = ImportResult::default();
    for rel in data_dir.walk_files()? {
        // Case-insensitive `.json` extension check without `Path::new`
        // (which would re-introduce a dynamic-path construction).
        if !rel
            .rsplit_once('.')
            .is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("json"))
        {
            continue;
        }
        let filename = rel.rsplit('/').next().unwrap_or(rel.as_str());
        if filename == "provider-metadata.json" {
            continue;
        }

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

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

    Ok(result)
}

/// Import a single JSON file (confined relative path) as a CSAF document.
fn import_single_file(dir: &DataDir, rel: &str, storage: &CsafStorage) -> Result<()> {
    let content = dir.read_to_string(rel)?;
    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("; ")));
    }

    // OASIS conformance check (0.3.4+, feature `oasis-validator`, on by
    // default): run the official CSAF 2.1 suite and LOG any findings, but
    // do NOT reject the import. Import is intentionally lenient — it
    // ingests third-party advisories whose conformance we do not control —
    // so OASIS issues are surfaced as warnings rather than blocking ingest.
    // Export is the hard gate (see `export::export_document`): everything
    // this application *produces* must be conformant.
    #[cfg(feature = "oasis-validator")]
    if let Ok(findings) = crate::oasis::validate_oasis_json(&content) {
        let oasis_errors = findings
            .iter()
            .filter(|e| e.severity == validation::Severity::Error)
            .count();
        if oasis_errors > 0 {
            tracing::warn!(
                tracking_id = doc.tracking_id(),
                oasis_errors,
                "Imported advisory has OASIS conformance errors (admitted; \
                 a re-export would be refused until they are fixed)"
            );
        }
    }

    storage.put_document(&doc)?;

    tracing::info!(
        tracking_id = doc.tracking_id(),
        path = %rel,
        "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);
    }
}