use csaf_models::csaf_document::CsafDocument;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Severity {
Error,
Warning,
}
#[derive(Debug, Clone)]
pub struct ValidationError {
pub path: String,
pub severity: Severity,
pub message: String,
}
#[must_use]
pub fn validate(doc: &CsafDocument) -> Vec<ValidationError> {
let mut errors = Vec::new();
validate_document_metadata(doc, &mut errors);
validate_tracking(doc, &mut errors);
validate_publisher(doc, &mut errors);
validate_product_tree(doc, &mut errors);
validate_vulnerabilities(doc, &mut errors);
validate_product_id_references(doc, &mut errors);
errors
}
#[must_use]
pub fn is_valid(doc: &CsafDocument) -> bool {
validate(doc).iter().all(|e| e.severity != Severity::Error)
}
fn validate_document_metadata(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
let valid_categories = [
"csaf_security_advisory",
"csaf_vex",
"csaf_informational_advisory",
"csaf_base",
];
if !valid_categories.contains(&doc.document.category.as_str()) {
errors.push(ValidationError {
path: "$.document.category".to_owned(),
severity: Severity::Error,
message: format!(
"Invalid category '{}'. Must be one of: {valid_categories:?}",
doc.document.category
),
});
}
if doc.document.csaf_version != "2.0" && doc.document.csaf_version != "2.1" {
errors.push(ValidationError {
path: "$.document.csaf_version".to_owned(),
severity: Severity::Error,
message: format!(
"Invalid CSAF version '{}'. Must be '2.0' or '2.1'",
doc.document.csaf_version
),
});
}
if doc.document.title.trim().is_empty() {
errors.push(ValidationError {
path: "$.document.title".to_owned(),
severity: Severity::Error,
message: "Document title must not be empty".to_owned(),
});
}
}
fn validate_tracking(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
let tracking = &doc.document.tracking;
if tracking.id.trim().is_empty() {
errors.push(ValidationError {
path: "$.document.tracking.id".to_owned(),
severity: Severity::Error,
message: "Tracking ID must not be empty".to_owned(),
});
}
let valid_statuses = ["draft", "interim", "final"];
if !valid_statuses.contains(&tracking.status.as_str()) {
errors.push(ValidationError {
path: "$.document.tracking.status".to_owned(),
severity: Severity::Error,
message: format!(
"Invalid status '{}'. Must be one of: {valid_statuses:?}",
tracking.status
),
});
}
if tracking.version.trim().is_empty() {
errors.push(ValidationError {
path: "$.document.tracking.version".to_owned(),
severity: Severity::Error,
message: "Version must not be empty".to_owned(),
});
}
if chrono::DateTime::parse_from_rfc3339(&tracking.current_release_date).is_err()
&& chrono::NaiveDateTime::parse_from_str(
&tracking.current_release_date,
"%Y-%m-%dT%H:%M:%S%.fZ",
)
.is_err()
{
errors.push(ValidationError {
path: "$.document.tracking.current_release_date".to_owned(),
severity: Severity::Warning,
message: format!(
"Date '{}' may not be valid ISO 8601",
tracking.current_release_date
),
});
}
if tracking.status == "final" && tracking.revision_history.is_empty() {
errors.push(ValidationError {
path: "$.document.tracking.revision_history".to_owned(),
severity: Severity::Warning,
message: "Final documents should have at least one revision history entry".to_owned(),
});
}
}
fn validate_publisher(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
let publisher = &doc.document.publisher;
if publisher.name.trim().is_empty() {
errors.push(ValidationError {
path: "$.document.publisher.name".to_owned(),
severity: Severity::Error,
message: "Publisher name must not be empty".to_owned(),
});
}
if publisher.namespace.trim().is_empty() {
errors.push(ValidationError {
path: "$.document.publisher.namespace".to_owned(),
severity: Severity::Error,
message: "Publisher namespace must not be empty".to_owned(),
});
}
let valid_categories = [
"vendor",
"discoverer",
"coordinator",
"user",
"other",
"translator",
];
if !valid_categories.contains(&publisher.category.as_str()) {
errors.push(ValidationError {
path: "$.document.publisher.category".to_owned(),
severity: Severity::Error,
message: format!(
"Invalid publisher category '{}'. Must be one of: {valid_categories:?}",
publisher.category
),
});
}
}
fn validate_product_tree(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
let product_ids = doc.all_product_ids();
if product_ids.is_empty()
&& doc.product_tree.full_product_names.is_empty()
&& doc.product_tree.branches.is_empty()
{
errors.push(ValidationError {
path: "$.product_tree".to_owned(),
severity: Severity::Warning,
message: "Product tree has no branches or product names".to_owned(),
});
}
let mut seen = std::collections::HashSet::new();
for id in &product_ids {
if !seen.insert(id.as_str()) {
errors.push(ValidationError {
path: "$.product_tree".to_owned(),
severity: Severity::Error,
message: format!("Duplicate product ID: {id}"),
});
}
}
}
fn validate_vulnerabilities(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
for (i, vuln) in doc.vulnerabilities.iter().enumerate() {
let prefix = format!("$.vulnerabilities[{i}]");
for (j, metric) in vuln.metrics.iter().enumerate() {
if let Some(v3) = &metric.content.cvss_v3 {
if !(0.0..=10.0).contains(&v3.base_score) {
errors.push(ValidationError {
path: format!("{prefix}.metrics[{j}].content.cvss_v3.baseScore"),
severity: Severity::Error,
message: format!(
"CVSS v3 baseScore {} out of range [0.0, 10.0]",
v3.base_score
),
});
}
if !v3.vector_string.starts_with("CVSS:3") {
errors.push(ValidationError {
path: format!("{prefix}.metrics[{j}].content.cvss_v3.vectorString"),
severity: Severity::Error,
message: format!(
"CVSS v3 vectorString must start with 'CVSS:3': {}",
v3.vector_string
),
});
}
validate_cvss_severity(
v3.base_score,
&v3.base_severity,
&format!("{prefix}.metrics[{j}].content.cvss_v3"),
errors,
);
}
if let Some(v4) = &metric.content.cvss_v4 {
if !(0.0..=10.0).contains(&v4.base_score) {
errors.push(ValidationError {
path: format!("{prefix}.metrics[{j}].content.cvss_v4.baseScore"),
severity: Severity::Error,
message: format!(
"CVSS v4 baseScore {} out of range [0.0, 10.0]",
v4.base_score
),
});
}
if !v4.vector_string.starts_with("CVSS:4") {
errors.push(ValidationError {
path: format!("{prefix}.metrics[{j}].content.cvss_v4.vectorString"),
severity: Severity::Error,
message: format!(
"CVSS v4 vectorString must start with 'CVSS:4': {}",
v4.vector_string
),
});
}
validate_cvss_severity(
v4.base_score,
&v4.base_severity,
&format!("{prefix}.metrics[{j}].content.cvss_v4"),
errors,
);
}
}
}
}
fn validate_cvss_severity(
score: f64,
severity: &str,
path: &str,
errors: &mut Vec<ValidationError>,
) {
let expected = if score == 0.0 {
"NONE"
} else if score <= 3.9 {
"LOW"
} else if score <= 6.9 {
"MEDIUM"
} else if score <= 8.9 {
"HIGH"
} else {
"CRITICAL"
};
if severity != expected {
errors.push(ValidationError {
path: format!("{path}.baseSeverity"),
severity: Severity::Warning,
message: format!(
"baseSeverity '{severity}' does not match baseScore {score} (expected '{expected}')"
),
});
}
}
fn validate_product_id_references(doc: &CsafDocument, errors: &mut Vec<ValidationError>) {
let defined_ids: std::collections::HashSet<String> =
doc.all_product_ids().into_iter().collect();
for (i, vuln) in doc.vulnerabilities.iter().enumerate() {
let prefix = format!("$.vulnerabilities[{i}]");
if let Some(status) = &vuln.product_status {
for field_name in &[
"known_affected",
"known_not_affected",
"fixed",
"under_investigation",
] {
let ids = match *field_name {
"known_affected" => &status.known_affected,
"known_not_affected" => &status.known_not_affected,
"fixed" => &status.fixed,
"under_investigation" => &status.under_investigation,
_ => continue,
};
for id in ids {
if !defined_ids.contains(id) {
errors.push(ValidationError {
path: format!("{prefix}.product_status.{field_name}"),
severity: Severity::Error,
message: format!("Product ID '{id}' not found in product_tree"),
});
}
}
}
}
for (j, metric) in vuln.metrics.iter().enumerate() {
for id in &metric.products {
if !defined_ids.contains(id) {
errors.push(ValidationError {
path: format!("{prefix}.metrics[{j}].products"),
severity: Severity::Error,
message: format!("Product ID '{id}' not found in product_tree"),
});
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_test_file_003() {
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 errors = validate(&doc);
let hard_errors: Vec<_> = errors
.iter()
.filter(|e| e.severity == Severity::Error)
.collect();
assert!(
hard_errors.is_empty(),
"Test file 003 should have no errors: {hard_errors:?}"
);
}
#[test]
fn test_validate_all_test_files() {
let test_dir =
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../test/csaf/2026");
for entry in std::fs::read_dir(&test_dir).expect("test dir missing") {
let entry = entry.expect("dir entry error");
if !entry.file_type().expect("type error").is_dir() {
continue;
}
for file in std::fs::read_dir(entry.path()).expect("subdir error") {
let file = file.expect("file error");
let path = file.path();
if path.extension().is_some_and(|e| e == "json") {
let content = std::fs::read_to_string(&path).expect("read error");
let doc: CsafDocument = serde_json::from_str(&content).expect("parse error");
let errors = validate(&doc);
let hard_errors: Vec<_> = errors
.iter()
.filter(|e| e.severity == Severity::Error)
.collect();
assert!(
hard_errors.is_empty(),
"File {} has validation errors: {hard_errors:?}",
path.display()
);
}
}
}
}
#[test]
fn test_validate_empty_title() {
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 = String::new();
let errors = validate(&doc);
assert!(errors.iter().any(|e| e.path == "$.document.title"));
}
#[test]
fn test_validate_invalid_category() {
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.category = "invalid_category".to_owned();
let errors = validate(&doc);
assert!(
errors
.iter()
.any(|e| e.path == "$.document.category" && e.severity == Severity::Error)
);
}
#[test]
fn test_validate_invalid_csaf_version() {
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.csaf_version = "3.0".to_owned();
let errors = validate(&doc);
assert!(errors.iter().any(|e| e.path == "$.document.csaf_version"));
}
#[test]
fn test_validate_cvss_score_out_of_range() {
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");
if let Some(metric) = doc.vulnerabilities[0].metrics.first_mut()
&& let Some(v3) = metric.content.cvss_v3.as_mut()
{
v3.base_score = 11.0;
}
let errors = validate(&doc);
assert!(errors.iter().any(|e| e.message.contains("out of range")));
}
#[test]
fn test_validate_missing_product_id_reference() {
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");
if let Some(status) = doc.vulnerabilities[0].product_status.as_mut() {
status.known_affected.push("NONEXISTENT-001".to_owned());
}
let errors = validate(&doc);
assert!(errors.iter().any(|e| e.message.contains("NONEXISTENT-001")));
}
#[test]
fn test_is_valid_returns_true_for_valid_doc() {
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!(is_valid(&doc));
}
}