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 export to filesystem with sidecar files.

use std::path::{Path, PathBuf};

use csaf_models::csaf_document::CsafDocument;
use csaf_models::provider_meta::ProviderMetadata;
use csaf_models::settings::Settings;

use crate::error::{CsafError, Result};
use crate::sidecar;
use crate::validation;

/// Export a single CSAF document to the export directory.
///
/// The document's `csaf_version` is rewritten to match the active
/// `settings.csaf_mode` before writing, so switching the mode and
/// re-exporting yields files in the selected version.
///
/// The document is re-validated after the version override; exports
/// with hard validation errors are refused.
///
/// Creates the directory structure: `{export_dir}/{year}/{seq}/`
/// and writes the JSON file plus sidecar hash files.
///
/// # Errors
///
/// Returns an error if the tracking ID format is invalid, validation
/// fails, or file writing fails.
pub fn export_document(doc: &CsafDocument, settings: &Settings) -> Result<PathBuf> {
    let tracking_id = doc.tracking_id();
    let (year, seq) = parse_tracking_id(tracking_id, &settings.naming_convention)?;

    // Override csaf_version to match the active mode from settings, so the
    // "CSAF mode" setting is actually reflected in exported files.
    let mut override_doc = doc.clone();
    if override_doc.document.csaf_version != settings.csaf_mode {
        override_doc
            .document
            .csaf_version
            .clone_from(&settings.csaf_mode);
    }

    // Validate the (possibly version-overridden) document. Reject on hard errors.
    let findings = validation::validate(&override_doc);
    let hard: Vec<_> = findings
        .iter()
        .filter(|e| e.severity == validation::Severity::Error)
        .collect();
    if !hard.is_empty() {
        let messages: Vec<String> = hard
            .iter()
            .map(|e| format!("{}: {}", e.path, e.message))
            .collect();
        return Err(CsafError::Export(format!(
            "validation failed for '{tracking_id}': {}",
            messages.join("; ")
        )));
    }

    // Open the export directory as a capability handle: every write
    // below is confined to it at the syscall layer. `year` / `seq` derive
    // from the document's tracking ID, which is attacker-controllable on
    // import (validation only checks the ID is non-empty, not its shape),
    // so the relative path is also checked with `is_safe_relative_path`
    // for a clear error before any filesystem mutation.
    let data_dir = crate::fs::DataDir::open_or_create(&settings.export_directory)?;

    let rel_dir = format!("{year}/{seq}");
    let rel_json = format!("{rel_dir}/{tracking_id}.json");
    if !crate::path_security::is_safe_relative_path(&rel_json) {
        return Err(CsafError::Export(format!(
            "tracking ID '{tracking_id}' resolves outside the export directory"
        )));
    }

    data_dir.create_dir_all(&rel_dir)?;

    let json_bytes = serde_json::to_vec_pretty(&override_doc)?;

    // OASIS conformance gate (0.3.4+, feature `oasis-validator`, on by
    // default): validate the exact bytes about to be written against the
    // official CSAF 2.1 test suite (schema + mandatory 6.1.x tests) and
    // refuse to export a non-conformant advisory. Complements the
    // built-in `validation::validate` above with the authoritative suite.
    #[cfg(feature = "oasis-validator")]
    {
        let json_str = core::str::from_utf8(&json_bytes).map_err(|e| {
            CsafError::Export(format!("export produced non-UTF-8 JSON: {e}"))
        })?;
        let oasis_hard: Vec<String> = crate::oasis::validate_oasis_json(json_str)
            .map_err(|e| CsafError::Export(format!("OASIS validation error: {e}")))?
            .into_iter()
            .filter(|f| f.severity == validation::Severity::Error)
            .map(|f| format!("{}: {}", f.path, f.message))
            .collect();
        if !oasis_hard.is_empty() {
            return Err(CsafError::Export(format!(
                "OASIS validation failed for '{tracking_id}': {}",
                oasis_hard.join("; ")
            )));
        }
    }

    data_dir.write_atomic(&rel_json, &json_bytes)?;

    // Generate sidecar files (confined to the same export directory).
    sidecar::write_sidecar_files(
        &data_dir,
        &rel_json,
        &json_bytes,
        sidecar::SidecarHashes::from_settings(settings),
    )?;

    let file_path = data_dir.resolve(&rel_json);
    tracing::info!(
        tracking_id = tracking_id,
        csaf_version = %override_doc.document.csaf_version,
        path = %file_path.display(),
        "Exported CSAF document"
    );

    Ok(file_path)
}

/// Export provider metadata to the export directory root.
///
/// # Errors
///
/// Returns an error if file writing fails.
pub fn export_provider_metadata(
    meta: &ProviderMetadata,
    export_dir: impl AsRef<Path>,
) -> Result<PathBuf> {
    let data_dir = crate::fs::DataDir::open_or_create(export_dir.as_ref())?;
    let json_bytes = serde_json::to_vec_pretty(meta)?;
    data_dir.write_atomic("provider-metadata.json", &json_bytes)?;

    let file_path = data_dir.resolve("provider-metadata.json");
    tracing::info!(path = %file_path.display(), "Exported provider metadata");

    Ok(file_path)
}

/// Parse a tracking ID into (year, sequence) components.
///
/// Expects format like `ndaal-sa-2026-003` where the naming convention
/// prefix is `ndaal-sa-`.
///
/// # Errors
///
/// Returns an error if the tracking ID doesn't match the expected format.
pub fn parse_tracking_id(tracking_id: &str, prefix: &str) -> Result<(String, String)> {
    let remainder = tracking_id.strip_prefix(prefix).ok_or_else(|| {
        CsafError::Export(format!(
            "Tracking ID '{tracking_id}' does not start with prefix '{prefix}'"
        ))
    })?;

    let (year_str, seq_str) = remainder.split_once('-').ok_or_else(|| {
        CsafError::Export(format!(
            "Tracking ID '{tracking_id}' does not match expected format '{prefix}YYYY-NNN'"
        ))
    })?;

    if seq_str.contains('-') {
        return Err(CsafError::Export(format!(
            "Tracking ID '{tracking_id}' has too many segments after prefix"
        )));
    }

    let year = year_str.to_owned();
    let seq = seq_str.to_owned();

    // Validate year is numeric.
    if year.len() != 4 || year.parse::<u16>().is_err() {
        return Err(CsafError::Export(format!(
            "Invalid year '{year}' in tracking ID '{tracking_id}'"
        )));
    }

    Ok((year, seq))
}

/// Result of a bulk export operation.
#[derive(Debug, Default)]
pub struct ExportResult {
    /// Number of documents successfully exported.
    pub exported: usize,
    /// Errors encountered during export.
    pub errors: Vec<String>,
}

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

    #[test]
    fn test_parse_tracking_id() {
        let (year, seq) =
            parse_tracking_id("ndaal-sa-2026-003", "ndaal-sa-").expect("parse failed");
        assert_eq!(year, "2026");
        assert_eq!(seq, "003");
    }

    #[test]
    fn test_parse_tracking_id_invalid_prefix() {
        let result = parse_tracking_id("other-2026-001", "ndaal-sa-");
        assert!(result.is_err());
    }

    #[test]
    fn test_parse_tracking_id_invalid_format() {
        let result = parse_tracking_id("ndaal-sa-invalid", "ndaal-sa-");
        assert!(result.is_err());
    }

    #[test]
    fn test_export_document_preserves_version_when_matching() {
        let dir = tempfile::tempdir().expect("tmpdir failed");
        let settings = Settings {
            export_directory: dir.path().to_string_lossy().to_string(),
            csaf_mode: "2.1".to_owned(),
            sidecar_sha256: true,
            sidecar_sha3_512: true,
            ..Settings::default()
        };

        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
        let doc: CsafDocument = serde_json::from_str(json).expect("parse error");

        let path = export_document(&doc, &settings).expect("export failed");
        assert!(path.exists());
        assert!(path.with_extension("json.sha-256").exists());
        assert!(path.with_extension("json.sha-512").exists());
        assert!(path.with_extension("json.sha3-512").exists());
        // Regression guard — the legacy unhyphenated form must never leak.
        assert!(!path.with_extension("json.sha256").exists());
        assert!(!path.with_extension("json.sha512").exists());
        assert!(dir.path().join("2026/003/ndaal-sa-2026-003.json").exists());

        // Verify the exported document still has csaf_version "2.1".
        let content = std::fs::read_to_string(&path).expect("read failed");
        let exported: CsafDocument = serde_json::from_str(&content).expect("parse exported");
        assert_eq!(exported.document.csaf_version, "2.1");
    }

    #[test]
    fn test_export_document_overrides_csaf_version_from_settings() {
        // The `csaf_mode` setting relabels `document.csaf_version` on export.
        // The data model only emits CSAF 2.1 structures (a `metrics` array
        // and `cvss_v4`); CSAF 2.0 instead uses `scores` and has no cvss_v4.
        // So relabelling a structurally-2.1 advisory to "2.0" produces a
        // document that is NOT 2.0-conformant, and the on-by-default OASIS
        // export gate (0.3.4+) must REFUSE it rather than write a mislabelled,
        // schema-invalid advisory. (Net effect: csaf_mode="2.0" only succeeds
        // for advisories that are already 2.0-structurally-compatible.)
        let dir = tempfile::tempdir().expect("tmpdir failed");
        let settings = Settings {
            export_directory: dir.path().to_string_lossy().to_string(),
            csaf_mode: "2.0".to_owned(),
            ..Settings::default()
        };

        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
        let doc: CsafDocument = serde_json::from_str(json).expect("parse error");
        assert_eq!(doc.document.csaf_version, "2.1"); // source is 2.1

        let result = export_document(&doc, &settings);
        assert!(
            result.is_err(),
            "relabelling a 2.1-structured advisory to CSAF 2.0 must be refused by the OASIS export gate"
        );

        // In-memory document must remain unchanged.
        assert_eq!(doc.document.csaf_version, "2.1");
    }

    #[test]
    fn test_export_document_refuses_invalid_document() {
        let dir = tempfile::tempdir().expect("tmpdir failed");
        let settings = Settings {
            export_directory: dir.path().to_string_lossy().to_string(),
            ..Settings::default()
        };

        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
        let mut doc: CsafDocument = serde_json::from_str(json).expect("parse error");
        doc.document.title.clear(); // invalid: empty title

        let result = export_document(&doc, &settings);
        assert!(result.is_err(), "Export should refuse invalid document");
        let err = result.unwrap_err().to_string();
        assert!(err.contains("validation failed"), "got: {err}");
    }

    #[test]
    fn test_export_document_rejects_traversal_tracking_id() {
        let dir = tempfile::tempdir().expect("tmpdir failed");
        // Export into a subdirectory so an escaping `..` would land
        // elsewhere under the (cleaned-up) temp root, never the real FS.
        let export_root = dir.path().join("export");
        let settings = Settings {
            export_directory: export_root.to_string_lossy().to_string(),
            ..Settings::default()
        };

        let json = include_str!("../../../test/csaf/2026/003/ndaal-sa-2026-003.json");
        let mut doc: CsafDocument = serde_json::from_str(json).expect("parse error");
        // Crafted ID: passes validation (non-empty) and parse_tracking_id
        // (4-digit year, no extra '-' in the sequence) but the sequence
        // escapes the export directory via `..`.
        doc.document.tracking.id = "ndaal-sa-2026-../../escaped".to_owned();

        let result = export_document(&doc, &settings);
        assert!(
            result.is_err(),
            "export must refuse a tracking ID that escapes the export directory"
        );
        // Nothing may have been written outside the export directory.
        assert!(!dir.path().join("escaped").exists());
    }

    #[test]
    fn test_export_provider_metadata() {
        let dir = tempfile::tempdir().expect("tmpdir failed");
        let json = include_str!("../../../test/csaf/provider-metadata.json");
        let meta: ProviderMetadata = serde_json::from_str(json).expect("parse error");

        let path = export_provider_metadata(&meta, dir.path()).expect("export failed");
        assert!(path.exists());
        assert_eq!(
            path.file_name().expect("no filename").to_str(),
            Some("provider-metadata.json")
        );
    }
}