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;
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)?;
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);
}
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)?;
let tmp_path = file_path.with_extension("json.tmp");
std::fs::write(&tmp_path, &json_bytes)?;
std::fs::rename(&tmp_path, &file_path)?;
sidecar::write_sidecar_files(
&file_path,
&json_bytes,
settings.sidecar_sha256,
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)
}
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)
}
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();
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))
}
#[derive(Debug, Default)]
pub struct ExportResult {
pub exported: usize,
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.sha256").exists());
assert!(path.with_extension("json.sha3-512").exists());
assert!(dir.path().join("2026/003/ndaal-sa-2026-003.json").exists());
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(),
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");
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"
);
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();
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")
);
}
}