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

//! Optional OASIS CSAF validator integration (feature `oasis-validator`).
//!
//! Runs the official [`csaf-rs`](https://crates.io/crates/csaf-rs) test
//! suite (preset `"basic"` = JSON schema + the mandatory 6.1.x tests) over a
//! CSAF JSON string and maps the findings into this crate's
//! [`ValidationError`] shape. This complements — it does not replace — the
//! built-in pure-Rust [`crate::validation`]. The whole module is gated behind
//! the `oasis-validator` feature so the default build pulls none of
//! csaf-rs's transitive dependencies.

use csaf::validation::{TestResultStatus, ValidationResult, validate_by_preset};

use crate::error::{CsafError, Result};
use crate::validation::{Severity, ValidationError};

/// Detect the declared CSAF version (`document.csaf_version`) from raw JSON.
fn detect_version(json: &str) -> Result<String> {
    let value: serde_json::Value =
        serde_json::from_str(json).map_err(|e| CsafError::Validation(e.to_string()))?;
    value
        .get("document")
        .and_then(|d| d.get("csaf_version"))
        .and_then(serde_json::Value::as_str)
        .map(str::to_owned)
        .ok_or_else(|| CsafError::Validation("missing document.csaf_version".to_owned()))
}

/// Map a csaf-rs [`ValidationResult`] into this crate's finding shape.
///
/// Test errors become [`Severity::Error`]; warnings become
/// [`Severity::Warning`]. Informational findings are dropped (the built-in
/// validator has no informational tier). Each message is prefixed with the
/// originating OASIS test id (e.g. `[6.1.1]`).
fn map_result(result: &ValidationResult) -> Vec<ValidationError> {
    let mut out = Vec::new();
    for test in &result.test_results {
        let TestResultStatus::Failure {
            errors, warnings, ..
        } = &test.status
        else {
            continue;
        };
        for e in errors {
            out.push(ValidationError {
                path: e.instance_path.clone(),
                severity: Severity::Error,
                message: format!("[{}] {}", test.test_id, e.message),
            });
        }
        for w in warnings {
            out.push(ValidationError {
                path: w.instance_path.clone(),
                severity: Severity::Warning,
                message: format!("[{}] {}", test.test_id, w.message),
            });
        }
    }
    out
}

/// Validate a CSAF JSON string with the OASIS `csaf-rs` `"basic"` preset.
///
/// Dispatches on the document's declared `csaf_version` (2.0 or 2.1) and
/// returns the mapped findings. An empty vector means the document passed
/// every mandatory OASIS test.
///
/// # Errors
///
/// Returns [`CsafError::Validation`] if the JSON is unparseable, the
/// `csaf_version` field is missing, or the version is not 2.0/2.1.
pub fn validate_oasis_json(json: &str) -> Result<Vec<ValidationError>> {
    let version = detect_version(json)?;
    let result = match version.as_str() {
        "2.0" => {
            let doc = csaf::csaf2_0::loader::load_document_from_str(json)
                .map_err(|e| CsafError::Validation(e.to_string()))?;
            validate_by_preset(&doc, "2.0", "basic")
        },
        "2.1" => {
            let doc = csaf::csaf2_1::loader::load_document_from_str(json)
                .map_err(|e| CsafError::Validation(e.to_string()))?;
            validate_by_preset(&doc, "2.1", "basic")
        },
        other => {
            return Err(CsafError::Validation(format!(
                "unsupported CSAF version for OASIS validation: {other}"
            )));
        },
    };
    Ok(map_result(&result))
}

/// Convenience predicate: `true` if the document passes every mandatory
/// OASIS test (no error-severity findings).
#[must_use]
pub fn is_oasis_valid(json: &str) -> bool {
    validate_oasis_json(json).is_ok_and(|errs| errs.iter().all(|e| e.severity != Severity::Error))
}

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

    const GOOD: &str = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");

    #[test]
    fn malformed_json_is_rejected() {
        let err = validate_oasis_json("not json").unwrap_err();
        assert!(matches!(err, CsafError::Validation(_)));
    }

    #[test]
    fn missing_version_is_rejected() {
        let err = validate_oasis_json(r#"{"document":{}}"#).unwrap_err();
        assert!(matches!(err, CsafError::Validation(_)));
    }

    #[test]
    fn schema_invalid_document_reports_errors() {
        // A 2.1 doc that is structurally wrong (empty document object) must
        // produce at least one error-severity finding from the schema test.
        let json = r#"{"document":{"csaf_version":"2.1"}}"#;
        let findings = validate_oasis_json(json).expect("runs");
        assert!(
            findings.iter().any(|e| e.severity == Severity::Error),
            "expected schema errors, got: {findings:?}"
        );
    }

    #[test]
    fn real_advisory_runs_through_oasis() {
        // The spike's headline question: does our hand-authored advisory pass
        // the official mandatory tests? Print the findings either way; assert
        // only that the validator executed (returned Ok).
        let findings = validate_oasis_json(GOOD).expect("runs");
        let errors = findings.iter().filter(|e| e.severity == Severity::Error).count();
        let warnings = findings.len() - errors;
        eprintln!("OASIS on ndaal-sa-2026-003: {errors} errors, {warnings} warnings");
        for f in &findings {
            eprintln!("  {:?} {} :: {}", f.severity, f.path, f.message);
        }
    }
}