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

//! Exhaustive end-to-end tests for the OASIS (csaf-rs) conformance gate
//! wired into import and export in 0.3.4.
//!
//! The gate is on by default via the `oasis-validator` feature; this whole
//! file is compiled only when that feature is active.
//!
//! The central invariant under test: a document can be structurally
//! parseable AND pass the built-in `csaf_core::validation` checks while
//! still being rejected by the official OASIS CSAF 2.1 suite. The gate must
//! catch exactly those documents — on both import (admission to the store)
//! and export (bytes written to disk) — and must NOT false-reject a
//! genuinely conformant advisory.

#![cfg(feature = "oasis-validator")]

use csaf_core::storage::CsafStorage;
use csaf_core::validation::Severity;
use csaf_core::{export, import, oasis, validation};
use csaf_models::csaf_document::CsafDocument;
use csaf_models::settings::Settings;

/// A known OASIS-conformant CSAF 2.1 advisory (cvss_v4 uses the correct
/// `vuln*Impact` names; CWE references use the `cwes` array).
const GOOD: &str = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");

fn good_doc() -> CsafDocument {
    serde_json::from_str(GOOD).expect("fixture must parse")
}

fn settings_for(dir: &std::path::Path) -> Settings {
    Settings {
        export_directory: dir.to_string_lossy().into_owned(),
        csaf_mode: "2.1".to_owned(),
        ..Settings::default()
    }
}

fn hard_errors(json: &str) -> Vec<String> {
    oasis::validate_oasis_json(json)
        .expect("validator runs")
        .into_iter()
        .filter(|f| f.severity == Severity::Error)
        .map(|f| format!("{}: {}", f.path, f.message))
        .collect()
}

// ---------------------------------------------------------------------------
// oasis::validate_oasis_json — the validator itself
// ---------------------------------------------------------------------------

#[test]
fn oasis_accepts_the_conformant_fixture() {
    // The whole gate is worthless if it rejects a valid advisory.
    assert!(
        hard_errors(GOOD).is_empty(),
        "conformant fixture must pass OASIS: {:?}",
        hard_errors(GOOD)
    );
    assert!(oasis::is_oasis_valid(GOOD));
}

#[test]
fn oasis_rejects_cvss_v4_with_v3_style_impact_keys() {
    // Re-introduce the historical bug: cvss_v4 carrying CVSS v3 property
    // names. The built-in validator ignores cvss_v4 property names, so only
    // the OASIS schema (unevaluatedProperties:false) catches this.
    let bad = GOOD
        .replace("vulnConfidentialityImpact", "confidentialityImpact")
        .replace("vulnIntegrityImpact", "integrityImpact")
        .replace("vulnAvailabilityImpact", "availabilityImpact");
    // sanity: built-in validation still passes (it does not inspect v4 keys)
    let doc: CsafDocument = serde_json::from_str(&bad).expect("parses");
    assert!(
        validation::validate(&doc)
            .iter()
            .all(|e| e.severity != Severity::Error),
        "built-in validator should not flag the v4 key bug"
    );
    assert!(
        !hard_errors(&bad).is_empty(),
        "OASIS must reject cvss_v4 with v3-style impact keys"
    );
}

#[test]
fn oasis_rejects_singular_cwe_on_a_2_1_document() {
    // Inject a CSAF 2.0 singular `cwe` object onto the first vulnerability
    // of a 2.1 document — invalid under the 2.1 schema.
    let mut value: serde_json::Value = serde_json::from_str(GOOD).expect("parse");
    value["vulnerabilities"][0]["cwe"] =
        serde_json::json!({ "id": "CWE-79", "name": "Cross-site Scripting" });
    let bad = serde_json::to_string(&value).expect("reserialize");
    assert!(
        !hard_errors(&bad).is_empty(),
        "OASIS must reject a singular `cwe` on a CSAF 2.1 document"
    );
}

#[test]
fn oasis_rejects_unparseable_and_versionless_input() {
    assert!(oasis::validate_oasis_json("not json at all").is_err());
    assert!(oasis::validate_oasis_json(r#"{"document":{}}"#).is_err());
}

// ---------------------------------------------------------------------------
// export gate — bytes must be conformant before they hit disk
// ---------------------------------------------------------------------------

#[test]
fn export_accepts_a_conformant_document() {
    let dir = tempfile::tempdir().expect("tmpdir");
    let path = export::export_document(&good_doc(), &settings_for(dir.path()))
        .expect("conformant export must succeed");
    assert!(path.exists(), "exported file must be on disk");
}

#[test]
fn export_refuses_an_oasis_invalid_document_and_writes_nothing() {
    let dir = tempfile::tempdir().expect("tmpdir");
    let mut doc = good_doc();
    // `lang` is not checked by the built-in validator, but the OASIS schema
    // constrains it to a BCP-47 pattern. A space + `!!` violates the
    // pattern, so the document is built-in-valid yet OASIS-invalid.
    doc.document.lang = Some("invalid lang!!".to_owned());

    let result = export::export_document(&doc, &settings_for(dir.path()));
    assert!(
        result.is_err(),
        "export must refuse an OASIS-invalid advisory"
    );

    let expected = dir
        .path()
        .join("2026")
        .join("003")
        .join("ndaal-sa-2026-003.json");
    assert!(
        !expected.exists(),
        "no advisory bytes must be written when the OASIS gate fails"
    );
}

// ---------------------------------------------------------------------------
// import gate — only conformant advisories enter the store
// ---------------------------------------------------------------------------

#[test]
fn import_admits_a_conformant_advisory() {
    let dir = tempfile::tempdir().expect("tmpdir");
    std::fs::write(dir.path().join("good.json"), GOOD).expect("write");
    let storage = CsafStorage::open_temp().expect("storage");

    let result = import::import_directory(dir.path(), &storage).expect("import runs");
    assert_eq!(result.imported, 1, "conformant advisory must be imported");
    assert_eq!(result.skipped, 0, "errors: {:?}", result.errors);
}

#[test]
fn import_admits_oasis_imperfect_advisory_soft_gate() {
    // Import is a SOFT OASIS gate: an advisory that passes the built-in
    // validator but fails the OASIS suite is still admitted (the failure is
    // logged, not fatal) because import ingests third-party advisories whose
    // conformance we do not control. Export is the hard gate — see
    // `export_refuses_an_oasis_invalid_document_and_writes_nothing`.
    let dir = tempfile::tempdir().expect("tmpdir");
    // built-in-valid (parses, v4 key names not inspected) but OASIS-invalid.
    let bad = GOOD.replace("vulnConfidentialityImpact", "confidentialityImpact");
    std::fs::write(dir.path().join("bad.json"), bad).expect("write");
    let storage = CsafStorage::open_temp().expect("storage");

    let result = import::import_directory(dir.path(), &storage).expect("import runs");
    assert_eq!(result.imported, 1, "soft import admits OASIS-imperfect docs");
    assert_eq!(result.skipped, 0, "errors: {:?}", result.errors);
}

#[test]
fn import_then_export_roundtrip_stays_conformant() {
    // A conformant advisory must survive import -> store -> export with the
    // gate active on both ends (proves the model round-trips cvss_v4 with the
    // correct v4 property names).
    let in_dir = tempfile::tempdir().expect("tmpdir");
    std::fs::write(in_dir.path().join("good.json"), GOOD).expect("write");
    let storage = CsafStorage::open_temp().expect("storage");
    let result = import::import_directory(in_dir.path(), &storage).expect("import runs");
    assert_eq!(result.imported, 1);

    let doc = storage
        .get_document("ndaal-sa-2026-003")
        .expect("lookup")
        .expect("stored");
    let out_dir = tempfile::tempdir().expect("tmpdir");
    export::export_document(&doc, &settings_for(out_dir.path()))
        .expect("round-tripped advisory must re-export conformantly");
}