csaf-core 0.3.1

CSAF storage, validation, sidecar generation, import/export
Documentation
// 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("; ")
        )));
    }

    let export_dir = Path::new(&settings.export_directory);
    let doc_dir = export_dir.join(&year).join(&seq);
    std::fs::create_dir_all(&doc_dir)?;

    let filename = format!("{tracking_id}.json");
    let file_path = doc_dir.join(&filename);

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

    // Write to temp file then rename for atomicity.
    let tmp_path = file_path.with_extension("json.tmp");
    std::fs::write(&tmp_path, &json_bytes)?;
    std::fs::rename(&tmp_path, &file_path)?;

    // Generate sidecar files.
    sidecar::write_sidecar_files(
        &file_path,
        &json_bytes,
        settings.sidecar_sha256,
        settings.sidecar_sha512,
        settings.sidecar_sha3_512,
    )?;

    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: &Path) -> Result<PathBuf> {
    std::fs::create_dir_all(export_dir)?;

    let file_path = export_dir.join("provider-metadata.json");
    let json_bytes = serde_json::to_vec_pretty(meta)?;

    let tmp_path = file_path.with_extension("json.tmp");
    std::fs::write(&tmp_path, &json_bytes)?;
    std::fs::rename(&tmp_path, &file_path)?;

    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() {
        let dir = tempfile::tempdir().expect("tmpdir failed");
        let settings = Settings {
            export_directory: dir.path().to_string_lossy().to_string(),
            // User switched the mode to 2.0 — exported files must reflect it.
            csaf_mode: "2.0".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");
        assert_eq!(doc.document.csaf_version, "2.1"); // source is 2.1

        let path = export_document(&doc, &settings).expect("export failed");
        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.0",
            "csaf_mode setting must override document csaf_version on export"
        );

        // 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_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")
        );
    }
}