use crate::model::NormalizedSbom;
use crate::pipeline::{OutputTarget, exit_codes, parse_sbom_with_context, write_output};
use crate::quality::{
ComplianceChecker, ComplianceLevel, ComplianceResult, StandardSelector, ViolationSeverity,
};
use crate::reports::{ReportFormat, generate_compliance_sarif};
use anyhow::Result;
use std::path::PathBuf;
pub const VALIDATE_OUTPUT_FORMATS: &[ReportFormat] = &[
ReportFormat::Auto,
ReportFormat::Summary,
ReportFormat::Json,
ReportFormat::Sarif,
ReportFormat::OscalJson,
];
#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
pub fn run_validate(
sbom_path: PathBuf,
standards: Vec<StandardSelector>,
output: ReportFormat,
output_file: Option<PathBuf>,
fail_on_warning: bool,
summary: bool,
cra_sidecar_path: Option<PathBuf>,
cra_product_class: Option<String>,
as_of: Option<&str>,
) -> Result<i32> {
if !summary {
super::ensure_output_format_supported("validate", output, VALIDATE_OUTPUT_FORMATS)?;
}
let as_of: Option<chrono::DateTime<chrono::Utc>> = as_of.map(super::parse_as_of).transpose()?;
anyhow::ensure!(
!standards.is_empty(),
"no compliance standard selected; pass --standard (valid values: {})",
StandardSelector::valid_values()
);
let parsed = parse_sbom_with_context(&sbom_path, false)?;
let cra_sidecar = super::load_cra_sidecar(cra_sidecar_path.as_deref(), &sbom_path)?;
let cli_class = super::parse_cra_product_class(cra_product_class.as_deref())?;
let sidecar_class = cra_sidecar.as_ref().and_then(|s| s.product_class);
if let (Some(cli), Some(side)) = (cli_class, sidecar_class)
&& cli != side
{
tracing::warn!(
"CRA product class mismatch: --cra-product-class={} but sidecar says {}; using sidecar.",
cli.label(),
side.label()
);
}
let effective_class = sidecar_class.or(cli_class);
let mut results = Vec::new();
for selector in &standards {
let level = selector.level();
let mut checker = ComplianceChecker::new(level);
if let Some(now) = as_of {
checker = checker.with_as_of(now);
}
if matches!(
level,
ComplianceLevel::CraPhase1
| ComplianceLevel::CraPhase2
| ComplianceLevel::CraOssSteward
| ComplianceLevel::EuccSubstantial
| ComplianceLevel::EuAiAct
) && let Some(sc) = cra_sidecar.clone()
{
checker = checker.with_sidecar(sc);
}
if matches!(
level,
ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2
) && let Some(c) = effective_class
{
checker = checker.with_product_class(c);
}
results.push(checker.check(parsed.sbom()));
}
if results.len() == 1 {
let result = &results[0];
if summary {
write_compliance_summary(result, output_file)?;
} else {
write_compliance_output(result, output, output_file)?;
}
if result.error_count > 0 {
return Ok(exit_codes::COMPLIANCE_ERRORS);
}
if fail_on_warning && result.warning_count > 0 {
return Ok(exit_codes::COMPLIANCE_WARNINGS);
}
} else {
if summary {
write_multi_compliance_summary(&results, output_file)?;
} else {
write_multi_compliance_output(&results, output, output_file)?;
}
let has_errors = results.iter().any(|r| r.error_count > 0);
let has_warnings = results.iter().any(|r| r.warning_count > 0);
if has_errors {
return Ok(exit_codes::COMPLIANCE_ERRORS);
}
if fail_on_warning && has_warnings {
return Ok(exit_codes::COMPLIANCE_WARNINGS);
}
}
Ok(exit_codes::SUCCESS)
}
fn write_compliance_output(
result: &ComplianceResult,
output: ReportFormat,
output_file: Option<PathBuf>,
) -> Result<()> {
let target = OutputTarget::from_option(output_file);
let content = match output {
ReportFormat::Json => serde_json::to_string_pretty(result)
.map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
ReportFormat::Sarif => generate_compliance_sarif(result)?,
ReportFormat::OscalJson => {
crate::reports::oscal::generate_assessment_results(std::slice::from_ref(result))?
}
_ => format_compliance_text(result),
};
write_output(&content, &target, false)?;
Ok(())
}
#[derive(serde::Serialize)]
struct ComplianceSummary {
standard: String,
applicable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
not_applicable_reason: Option<String>,
compliant: bool,
score: Option<u8>,
errors: usize,
warnings: usize,
info: usize,
}
fn compliance_summary(result: &ComplianceResult) -> ComplianceSummary {
let not_applicable_reason = match &result.applicability {
crate::quality::Applicability::NotApplicable(reason) => Some(reason.clone()),
crate::quality::Applicability::Applicable => None,
};
ComplianceSummary {
standard: result.level.name().to_string(),
applicable: result.is_applicable(),
not_applicable_reason,
compliant: result.is_compliant,
score: result.score(),
errors: result.error_count,
warnings: result.warning_count,
info: result.info_count,
}
}
fn write_compliance_summary(result: &ComplianceResult, output_file: Option<PathBuf>) -> Result<()> {
let target = OutputTarget::from_option(output_file);
let summary = compliance_summary(result);
let content = serde_json::to_string(&summary)
.map_err(|e| anyhow::anyhow!("Failed to serialize summary: {e}"))?;
write_output(&content, &target, false)?;
Ok(())
}
fn write_multi_compliance_output(
results: &[ComplianceResult],
output: ReportFormat,
output_file: Option<PathBuf>,
) -> Result<()> {
let target = OutputTarget::from_option(output_file);
let content = match output {
ReportFormat::Json => serde_json::to_string_pretty(results)
.map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
ReportFormat::Sarif => crate::reports::generate_multi_compliance_sarif(results)?,
ReportFormat::OscalJson => crate::reports::oscal::generate_assessment_results(results)?,
_ => {
let mut parts = Vec::new();
for result in results {
parts.push(format_compliance_text(result));
}
parts.join("\n---\n\n")
}
};
write_output(&content, &target, false)?;
Ok(())
}
fn write_multi_compliance_summary(
results: &[ComplianceResult],
output_file: Option<PathBuf>,
) -> Result<()> {
let target = OutputTarget::from_option(output_file);
let summaries: Vec<ComplianceSummary> = results.iter().map(compliance_summary).collect();
let content = serde_json::to_string(&summaries)
.map_err(|e| anyhow::anyhow!("Failed to serialize multi-standard summary: {e}"))?;
write_output(&content, &target, false)?;
Ok(())
}
fn format_compliance_text(result: &ComplianceResult) -> String {
let mut lines = Vec::new();
lines.push(format!("Compliance ({})", result.level.name()));
if let crate::quality::Applicability::NotApplicable(reason) = &result.applicability {
lines.push(format!("Status: NOT APPLICABLE — {reason}"));
} else {
lines.push(format!(
"Status: {} ({} errors, {} warnings, {} info)",
if result.is_compliant {
"COMPLIANT"
} else {
"NON-COMPLIANT"
},
result.error_count,
result.warning_count,
result.info_count
));
}
lines.push(String::new());
if result.violations.is_empty() {
lines.push("No violations found.".to_string());
return lines.join("\n");
}
for v in &result.violations {
let severity = match v.severity {
ViolationSeverity::Error => "ERROR",
ViolationSeverity::Warning => "WARN",
ViolationSeverity::Info => "INFO",
};
let element = v.element.as_deref().unwrap_or("-");
lines.push(format!(
"[{}] {} | {} | {}",
severity,
v.category.name(),
v.requirement,
element
));
lines.push(format!(" {}", v.message));
}
lines.join("\n")
}
pub fn print_ntia_validation(sbom: &NormalizedSbom) {
let result = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(sbom);
for v in result.violations_by_severity(ViolationSeverity::Warning) {
tracing::warn!("{}", v.message);
}
if result.is_compliant {
tracing::info!("SBOM passes NTIA minimum elements validation");
println!("NTIA Validation: PASSED");
} else {
tracing::warn!("SBOM has {} NTIA validation errors", result.error_count);
println!("NTIA Validation: FAILED");
for v in result.violations_by_severity(ViolationSeverity::Error) {
println!(" - {}", v.message);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn print_ntia_validation_does_not_panic_on_empty_sbom() {
print_ntia_validation(&NormalizedSbom::default());
}
#[test]
fn rejects_unsupported_output_format_before_reading_sbom() {
for format in [
ReportFormat::Html,
ReportFormat::Markdown,
ReportFormat::Csv,
ReportFormat::Ndjson,
ReportFormat::Table,
ReportFormat::SideBySide,
ReportFormat::Tui,
] {
let err = run_validate(
PathBuf::from("/nonexistent/never-read.cdx.json"),
vec![StandardSelector::Ntia],
format,
None,
false,
false,
None,
None,
None,
)
.expect_err("unsupported format must be rejected");
let msg = err.to_string();
assert!(
msg.contains("not supported by `sbom-tools validate`"),
"unexpected error for {format}: {msg}"
);
assert!(
msg.contains("oscal-json") && msg.contains("sarif"),
"error must list the supported formats: {msg}"
);
}
}
#[test]
fn rejects_empty_standard_list() {
let err = run_validate(
PathBuf::from("/nonexistent/never-read.cdx.json"),
Vec::new(),
ReportFormat::Summary,
None,
false,
false,
None,
None,
None,
)
.expect_err("empty standard list must be rejected");
assert!(err.to_string().contains("no compliance standard selected"));
}
#[test]
fn summary_overrides_output_and_skips_the_format_gate() {
let dir = tempfile::tempdir().unwrap();
let sbom_path = dir.path().join("app.cdx.json");
std::fs::write(
&sbom_path,
r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
)
.unwrap();
let out = dir.path().join("summary.json");
run_validate(
sbom_path,
vec![StandardSelector::Ntia],
ReportFormat::Html, Some(out.clone()),
false,
true,
None,
None,
None,
)
.expect("--summary must override -o html instead of hard-erroring");
let content = std::fs::read_to_string(out).unwrap();
let json: serde_json::Value =
serde_json::from_str(content.trim()).expect("compact summary JSON");
assert!(json["standard"].is_string(), "summary JSON shape: {json}");
}
#[test]
fn typod_cra_product_class_is_a_hard_error() {
let dir = tempfile::tempdir().unwrap();
let sbom_path = dir.path().join("app.cdx.json");
std::fs::write(
&sbom_path,
r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
)
.unwrap();
let err = run_validate(
sbom_path,
vec![StandardSelector::Cra],
ReportFormat::Summary,
None,
false,
true,
None,
Some("critcal".to_string()),
None,
)
.expect_err("typo'd --cra-product-class must be a hard error");
let msg = err.to_string();
assert!(msg.contains("critcal"), "must name the bad value: {msg}");
assert!(
msg.contains("important-class-2") && msg.contains("critical"),
"must list the valid values: {msg}"
);
}
#[test]
fn as_of_accepts_offsetless_datetime() {
let dir = tempfile::tempdir().unwrap();
let sbom_path = dir.path().join("app.cdx.json");
std::fs::write(
&sbom_path,
r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
)
.unwrap();
run_validate(
sbom_path,
vec![StandardSelector::Cra],
ReportFormat::Summary,
Some(dir.path().join("out.json")),
false,
true,
None,
None,
Some("2027-01-01T00:00:00"),
)
.expect("offset-less --as-of datetime must parse (assumed UTC)");
}
#[test]
fn explicit_missing_sidecar_is_a_hard_error() {
let dir = tempfile::tempdir().unwrap();
let sbom_path = dir.path().join("app.cdx.json");
std::fs::write(
&sbom_path,
r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
)
.unwrap();
let err = run_validate(
sbom_path,
vec![StandardSelector::Cra],
ReportFormat::Summary,
None,
false,
true,
Some(dir.path().join("missing.cra.json")),
None,
None,
)
.expect_err("broken explicit sidecar must be a hard error");
assert!(err.to_string().contains("Failed to load CRA sidecar"));
}
}