1use crate::model::NormalizedSbom;
6use crate::pipeline::{OutputTarget, exit_codes, parse_sbom_with_context, write_output};
7use crate::quality::{
8 ComplianceChecker, ComplianceLevel, ComplianceResult, StandardSelector, ViolationSeverity,
9};
10use crate::reports::{ReportFormat, generate_compliance_sarif};
11use anyhow::Result;
12use std::path::PathBuf;
13
14pub const VALIDATE_OUTPUT_FORMATS: &[ReportFormat] = &[
19 ReportFormat::Auto,
20 ReportFormat::Summary,
21 ReportFormat::Json,
22 ReportFormat::Sarif,
23 ReportFormat::OscalJson,
24];
25
26#[allow(clippy::needless_pass_by_value, clippy::too_many_arguments)]
46pub fn run_validate(
47 sbom_path: PathBuf,
48 standards: Vec<StandardSelector>,
49 output: ReportFormat,
50 output_file: Option<PathBuf>,
51 fail_on_warning: bool,
52 summary: bool,
53 cra_sidecar_path: Option<PathBuf>,
54 cra_product_class: Option<String>,
55 as_of: Option<&str>,
56) -> Result<i32> {
57 if !summary {
60 super::ensure_output_format_supported("validate", output, VALIDATE_OUTPUT_FORMATS)?;
61 }
62 let as_of: Option<chrono::DateTime<chrono::Utc>> = as_of.map(super::parse_as_of).transpose()?;
65 anyhow::ensure!(
66 !standards.is_empty(),
67 "no compliance standard selected; pass --standard (valid values: {})",
68 StandardSelector::valid_values()
69 );
70
71 let parsed = parse_sbom_with_context(&sbom_path, false)?;
72
73 let cra_sidecar = super::load_cra_sidecar(cra_sidecar_path.as_deref(), &sbom_path)?;
76
77 let cli_class = super::parse_cra_product_class(cra_product_class.as_deref())?;
82 let sidecar_class = cra_sidecar.as_ref().and_then(|s| s.product_class);
83 if let (Some(cli), Some(side)) = (cli_class, sidecar_class)
84 && cli != side
85 {
86 tracing::warn!(
87 "CRA product class mismatch: --cra-product-class={} but sidecar says {}; using sidecar.",
88 cli.label(),
89 side.label()
90 );
91 }
92 let effective_class = sidecar_class.or(cli_class);
93
94 let mut results = Vec::new();
95
96 for selector in &standards {
97 let level = selector.level();
98 let mut checker = ComplianceChecker::new(level);
99 if let Some(now) = as_of {
100 checker = checker.with_as_of(now);
101 }
102 if matches!(
106 level,
107 ComplianceLevel::CraPhase1
108 | ComplianceLevel::CraPhase2
109 | ComplianceLevel::CraOssSteward
110 | ComplianceLevel::EuccSubstantial
111 | ComplianceLevel::EuAiAct
112 ) && let Some(sc) = cra_sidecar.clone()
113 {
114 checker = checker.with_sidecar(sc);
115 }
116 if matches!(
118 level,
119 ComplianceLevel::CraPhase1 | ComplianceLevel::CraPhase2
120 ) && let Some(c) = effective_class
121 {
122 checker = checker.with_product_class(c);
123 }
124 results.push(checker.check(parsed.sbom()));
125 }
126
127 if results.len() == 1 {
128 let result = &results[0];
129 if summary {
130 write_compliance_summary(result, output_file)?;
131 } else {
132 write_compliance_output(result, output, output_file)?;
133 }
134
135 if result.error_count > 0 {
136 return Ok(exit_codes::COMPLIANCE_ERRORS);
137 }
138 if fail_on_warning && result.warning_count > 0 {
139 return Ok(exit_codes::COMPLIANCE_WARNINGS);
140 }
141 } else {
142 if summary {
144 write_multi_compliance_summary(&results, output_file)?;
145 } else {
146 write_multi_compliance_output(&results, output, output_file)?;
147 }
148
149 let has_errors = results.iter().any(|r| r.error_count > 0);
150 let has_warnings = results.iter().any(|r| r.warning_count > 0);
151 if has_errors {
152 return Ok(exit_codes::COMPLIANCE_ERRORS);
153 }
154 if fail_on_warning && has_warnings {
155 return Ok(exit_codes::COMPLIANCE_WARNINGS);
156 }
157 }
158
159 Ok(exit_codes::SUCCESS)
160}
161
162fn write_compliance_output(
163 result: &ComplianceResult,
164 output: ReportFormat,
165 output_file: Option<PathBuf>,
166) -> Result<()> {
167 let target = OutputTarget::from_option(output_file);
168
169 let content = match output {
170 ReportFormat::Json => serde_json::to_string_pretty(result)
171 .map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
172 ReportFormat::Sarif => generate_compliance_sarif(result)?,
173 ReportFormat::OscalJson => {
174 crate::reports::oscal::generate_assessment_results(std::slice::from_ref(result))?
175 }
176 _ => format_compliance_text(result),
177 };
178
179 write_output(&content, &target, false)?;
180 Ok(())
181}
182
183#[derive(serde::Serialize)]
185struct ComplianceSummary {
186 standard: String,
187 applicable: bool,
191 #[serde(skip_serializing_if = "Option::is_none")]
192 not_applicable_reason: Option<String>,
193 compliant: bool,
194 score: Option<u8>,
195 errors: usize,
196 warnings: usize,
197 info: usize,
198}
199
200fn compliance_summary(result: &ComplianceResult) -> ComplianceSummary {
201 let not_applicable_reason = match &result.applicability {
202 crate::quality::Applicability::NotApplicable(reason) => Some(reason.clone()),
203 crate::quality::Applicability::Applicable => None,
204 };
205 ComplianceSummary {
206 standard: result.level.name().to_string(),
207 applicable: result.is_applicable(),
208 not_applicable_reason,
209 compliant: result.is_compliant,
210 score: result.score(),
211 errors: result.error_count,
212 warnings: result.warning_count,
213 info: result.info_count,
214 }
215}
216
217fn write_compliance_summary(result: &ComplianceResult, output_file: Option<PathBuf>) -> Result<()> {
218 let target = OutputTarget::from_option(output_file);
219 let summary = compliance_summary(result);
220 let content = serde_json::to_string(&summary)
221 .map_err(|e| anyhow::anyhow!("Failed to serialize summary: {e}"))?;
222 write_output(&content, &target, false)?;
223 Ok(())
224}
225
226fn write_multi_compliance_output(
227 results: &[ComplianceResult],
228 output: ReportFormat,
229 output_file: Option<PathBuf>,
230) -> Result<()> {
231 let target = OutputTarget::from_option(output_file);
232
233 let content = match output {
234 ReportFormat::Json => serde_json::to_string_pretty(results)
235 .map_err(|e| anyhow::anyhow!("Failed to serialize compliance JSON: {e}"))?,
236 ReportFormat::Sarif => crate::reports::generate_multi_compliance_sarif(results)?,
237 ReportFormat::OscalJson => crate::reports::oscal::generate_assessment_results(results)?,
238 _ => {
239 let mut parts = Vec::new();
240 for result in results {
241 parts.push(format_compliance_text(result));
242 }
243 parts.join("\n---\n\n")
244 }
245 };
246
247 write_output(&content, &target, false)?;
248 Ok(())
249}
250
251fn write_multi_compliance_summary(
252 results: &[ComplianceResult],
253 output_file: Option<PathBuf>,
254) -> Result<()> {
255 let target = OutputTarget::from_option(output_file);
256 let summaries: Vec<ComplianceSummary> = results.iter().map(compliance_summary).collect();
257
258 let content = serde_json::to_string(&summaries)
259 .map_err(|e| anyhow::anyhow!("Failed to serialize multi-standard summary: {e}"))?;
260 write_output(&content, &target, false)?;
261 Ok(())
262}
263
264fn format_compliance_text(result: &ComplianceResult) -> String {
265 let mut lines = Vec::new();
266 lines.push(format!("Compliance ({})", result.level.name()));
267 if let crate::quality::Applicability::NotApplicable(reason) = &result.applicability {
268 lines.push(format!("Status: NOT APPLICABLE — {reason}"));
269 } else {
270 lines.push(format!(
271 "Status: {} ({} errors, {} warnings, {} info)",
272 if result.is_compliant {
273 "COMPLIANT"
274 } else {
275 "NON-COMPLIANT"
276 },
277 result.error_count,
278 result.warning_count,
279 result.info_count
280 ));
281 }
282 lines.push(String::new());
283
284 if result.violations.is_empty() {
285 lines.push("No violations found.".to_string());
286 return lines.join("\n");
287 }
288
289 for v in &result.violations {
290 let severity = match v.severity {
291 ViolationSeverity::Error => "ERROR",
292 ViolationSeverity::Warning => "WARN",
293 ViolationSeverity::Info => "INFO",
294 };
295 let element = v.element.as_deref().unwrap_or("-");
296 lines.push(format!(
297 "[{}] {} | {} | {}",
298 severity,
299 v.category.name(),
300 v.requirement,
301 element
302 ));
303 lines.push(format!(" {}", v.message));
304 }
305
306 lines.join("\n")
307}
308
309pub fn print_ntia_validation(sbom: &NormalizedSbom) {
313 let result = ComplianceChecker::new(ComplianceLevel::NtiaMinimum).check(sbom);
314 for v in result.violations_by_severity(ViolationSeverity::Warning) {
315 tracing::warn!("{}", v.message);
316 }
317 if result.is_compliant {
318 tracing::info!("SBOM passes NTIA minimum elements validation");
319 println!("NTIA Validation: PASSED");
320 } else {
321 tracing::warn!("SBOM has {} NTIA validation errors", result.error_count);
322 println!("NTIA Validation: FAILED");
323 for v in result.violations_by_severity(ViolationSeverity::Error) {
324 println!(" - {}", v.message);
325 }
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use super::*;
332
333 #[test]
334 fn print_ntia_validation_does_not_panic_on_empty_sbom() {
335 print_ntia_validation(&NormalizedSbom::default());
336 }
337
338 #[test]
339 fn rejects_unsupported_output_format_before_reading_sbom() {
340 for format in [
344 ReportFormat::Html,
345 ReportFormat::Markdown,
346 ReportFormat::Csv,
347 ReportFormat::Ndjson,
348 ReportFormat::Table,
349 ReportFormat::SideBySide,
350 ReportFormat::Tui,
351 ] {
352 let err = run_validate(
353 PathBuf::from("/nonexistent/never-read.cdx.json"),
354 vec![StandardSelector::Ntia],
355 format,
356 None,
357 false,
358 false,
359 None,
360 None,
361 None,
362 )
363 .expect_err("unsupported format must be rejected");
364 let msg = err.to_string();
365 assert!(
366 msg.contains("not supported by `sbom-tools validate`"),
367 "unexpected error for {format}: {msg}"
368 );
369 assert!(
370 msg.contains("oscal-json") && msg.contains("sarif"),
371 "error must list the supported formats: {msg}"
372 );
373 }
374 }
375
376 #[test]
377 fn rejects_empty_standard_list() {
378 let err = run_validate(
379 PathBuf::from("/nonexistent/never-read.cdx.json"),
380 Vec::new(),
381 ReportFormat::Summary,
382 None,
383 false,
384 false,
385 None,
386 None,
387 None,
388 )
389 .expect_err("empty standard list must be rejected");
390 assert!(err.to_string().contains("no compliance standard selected"));
391 }
392
393 #[test]
394 fn summary_overrides_output_and_skips_the_format_gate() {
395 let dir = tempfile::tempdir().unwrap();
398 let sbom_path = dir.path().join("app.cdx.json");
399 std::fs::write(
400 &sbom_path,
401 r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
402 )
403 .unwrap();
404 let out = dir.path().join("summary.json");
405 run_validate(
406 sbom_path,
407 vec![StandardSelector::Ntia],
408 ReportFormat::Html, Some(out.clone()),
410 false,
411 true,
412 None,
413 None,
414 None,
415 )
416 .expect("--summary must override -o html instead of hard-erroring");
417 let content = std::fs::read_to_string(out).unwrap();
418 let json: serde_json::Value =
419 serde_json::from_str(content.trim()).expect("compact summary JSON");
420 assert!(json["standard"].is_string(), "summary JSON shape: {json}");
421 }
422
423 #[test]
424 fn typod_cra_product_class_is_a_hard_error() {
425 let dir = tempfile::tempdir().unwrap();
428 let sbom_path = dir.path().join("app.cdx.json");
429 std::fs::write(
430 &sbom_path,
431 r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
432 )
433 .unwrap();
434 let err = run_validate(
435 sbom_path,
436 vec![StandardSelector::Cra],
437 ReportFormat::Summary,
438 None,
439 false,
440 true,
441 None,
442 Some("critcal".to_string()),
443 None,
444 )
445 .expect_err("typo'd --cra-product-class must be a hard error");
446 let msg = err.to_string();
447 assert!(msg.contains("critcal"), "must name the bad value: {msg}");
448 assert!(
449 msg.contains("important-class-2") && msg.contains("critical"),
450 "must list the valid values: {msg}"
451 );
452 }
453
454 #[test]
455 fn as_of_accepts_offsetless_datetime() {
456 let dir = tempfile::tempdir().unwrap();
459 let sbom_path = dir.path().join("app.cdx.json");
460 std::fs::write(
461 &sbom_path,
462 r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
463 )
464 .unwrap();
465 run_validate(
466 sbom_path,
467 vec![StandardSelector::Cra],
468 ReportFormat::Summary,
469 Some(dir.path().join("out.json")),
470 false,
471 true,
472 None,
473 None,
474 Some("2027-01-01T00:00:00"),
475 )
476 .expect("offset-less --as-of datetime must parse (assumed UTC)");
477 }
478
479 #[test]
480 fn explicit_missing_sidecar_is_a_hard_error() {
481 let dir = tempfile::tempdir().unwrap();
484 let sbom_path = dir.path().join("app.cdx.json");
485 std::fs::write(
486 &sbom_path,
487 r#"{"bomFormat":"CycloneDX","specVersion":"1.5","components":[]}"#,
488 )
489 .unwrap();
490 let err = run_validate(
491 sbom_path,
492 vec![StandardSelector::Cra],
493 ReportFormat::Summary,
494 None,
495 false,
496 true,
497 Some(dir.path().join("missing.cra.json")),
498 None,
499 None,
500 )
501 .expect_err("broken explicit sidecar must be a hard error");
502 assert!(err.to_string().contains("Failed to load CRA sidecar"));
503 }
504}