Skip to main content

sbom_tools/pipeline/
mod.rs

1//! Pipeline orchestration for SBOM operations.
2//!
3//! This module provides shared orchestration logic for parse → enrich → diff → report
4//! workflows, reducing duplication across CLI command handlers.
5
6mod diff_stage;
7pub mod enrich;
8mod output;
9mod parse;
10mod report_stage;
11
12pub use diff_stage::{
13    apply_post_diff_filters, compute_diff, graph_diff_config_from, validate_post_diff_filters,
14};
15pub use enrich::{AggregatedEnrichmentStats, enrich_sbom_full, enrich_sboms};
16pub use output::{OutputTarget, auto_detect_format, should_use_color, write_output};
17pub use parse::{ParsedSbom, STDIN_PATH, is_stdin_path, parse_sbom_with_context, read_input};
18pub use report_stage::{discover_cra_sidecar, output_report};
19
20#[cfg(feature = "enrichment")]
21pub use parse::{
22    build_enrichment_config, enrich_eol, enrich_epss, enrich_huggingface, enrich_kev, enrich_sbom,
23    enrich_staleness, enrich_vex,
24};
25
26/// Structured pipeline error types for better diagnostics.
27#[derive(Debug, thiserror::Error)]
28pub enum PipelineError {
29    /// Failed to read or parse an SBOM file
30    #[error("Parse failed for {path}: {source}")]
31    ParseFailed { path: String, source: anyhow::Error },
32
33    /// Enrichment failed (non-fatal by default)
34    #[error("Enrichment failed: {reason}")]
35    EnrichmentFailed { reason: String },
36
37    /// Diff computation failed
38    #[error("Diff failed: {source}")]
39    DiffFailed {
40        #[source]
41        source: anyhow::Error,
42    },
43
44    /// Report generation or output failed
45    #[error("Report failed: {source}")]
46    ReportFailed {
47        #[source]
48        source: anyhow::Error,
49    },
50}
51
52/// Exit codes for CI/CD integration
53///
54/// Contract: `0` = success / gate passed, `1` = gate/verdict failure,
55/// `2` = clap usage errors, `3` = ANY operational error (I/O, parse, config,
56/// unsupported output format, invalid flag values) — routed centrally in
57/// `main()` by mapping every `anyhow` error to [`ERROR`]. Command-specific
58/// gates keep their documented codes (diff/view `--fail-on-vuln` = 2,
59/// `--fail-on-vex-gap` = 4, license-check denial = 5, `--fail-on-kev` = 6,
60/// diff ML regression = 7). Command handlers must return errors (or exit
61/// codes) to `main()` rather than calling `process::exit` on error paths, so
62/// operational failures cannot leak out as exit 1.
63pub mod exit_codes {
64    /// Success - no changes detected (or --no-fail-on-change)
65    pub const SUCCESS: i32 = 0;
66    /// Changes were detected
67    pub const CHANGES_DETECTED: i32 = 1;
68    /// Vulnerabilities were introduced
69    pub const VULNS_INTRODUCED: i32 = 2;
70    /// An operational error occurred (I/O, parse, config, invalid flag
71    /// values). `main()` maps every `anyhow` error to this code.
72    pub const ERROR: i32 = 3;
73    /// Introduced vulnerabilities lack VEX statements (--fail-on-vex-gap)
74    pub const VEX_GAPS_FOUND: i32 = 4;
75    /// License policy violations found
76    pub const LICENSE_VIOLATIONS: i32 = 5;
77    /// Introduced vulnerabilities are in CISA's KEV catalog (--fail-on-kev)
78    pub const KEV_INTRODUCED: i32 = 6;
79    /// A supported ML performance metric regressed (--fail-on-ml-regression)
80    pub const ML_REGRESSION: i32 = 7;
81
82    // --- Per-command meanings (aliases preserving the numeric values above) ---
83
84    /// `validate`: compliance errors found (non-compliant SBOM). Same value as
85    /// [`CHANGES_DETECTED`].
86    pub const COMPLIANCE_ERRORS: i32 = CHANGES_DETECTED;
87    /// `validate --fail-on-warning`: compliance warnings found. Same value as
88    /// [`VULNS_INTRODUCED`].
89    pub const COMPLIANCE_WARNINGS: i32 = VULNS_INTRODUCED;
90    /// `quality --min-score`: overall score below the requested threshold. Same
91    /// value as [`CHANGES_DETECTED`].
92    pub const QUALITY_BELOW_THRESHOLD: i32 = CHANGES_DETECTED;
93    /// `query`: no components matched the filter. Same value as
94    /// [`CHANGES_DETECTED`].
95    pub const NO_MATCHES: i32 = CHANGES_DETECTED;
96}
97
98/// Platform-specific cache directory utilities
99pub mod dirs {
100    use std::path::PathBuf;
101
102    /// Get the platform-specific cache directory
103    #[must_use]
104    pub fn cache_dir() -> Option<PathBuf> {
105        #[cfg(target_os = "macos")]
106        {
107            std::env::var("HOME")
108                .ok()
109                .map(|h| PathBuf::from(h).join("Library").join("Caches"))
110        }
111        #[cfg(target_os = "linux")]
112        {
113            std::env::var("XDG_CACHE_HOME")
114                .ok()
115                .map(PathBuf::from)
116                .or_else(|| {
117                    std::env::var("HOME")
118                        .ok()
119                        .map(|h| PathBuf::from(h).join(".cache"))
120                })
121        }
122        #[cfg(target_os = "windows")]
123        {
124            std::env::var("LOCALAPPDATA").ok().map(PathBuf::from)
125        }
126        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
127        {
128            std::env::var("HOME")
129                .ok()
130                .map(|h| PathBuf::from(h).join(".cache"))
131        }
132    }
133
134    /// Get the default OSV cache directory
135    #[must_use]
136    pub fn osv_cache_dir() -> PathBuf {
137        cache_dir()
138            .unwrap_or_else(|| PathBuf::from(".cache"))
139            .join("sbom-tools")
140            .join("osv")
141    }
142
143    /// Get the default EOL cache directory
144    #[must_use]
145    pub fn eol_cache_dir() -> PathBuf {
146        cache_dir()
147            .unwrap_or_else(|| PathBuf::from(".cache"))
148            .join("sbom-tools")
149            .join("eol")
150    }
151
152    /// Get the default CISA KEV cache directory
153    #[must_use]
154    pub fn kev_cache_dir() -> PathBuf {
155        cache_dir()
156            .unwrap_or_else(|| PathBuf::from(".cache"))
157            .join("sbom-tools")
158            .join("kev")
159    }
160
161    /// Get the default FIRST EPSS cache directory
162    #[must_use]
163    pub fn epss_cache_dir() -> PathBuf {
164        cache_dir()
165            .unwrap_or_else(|| PathBuf::from(".cache"))
166            .join("sbom-tools")
167            .join("epss")
168    }
169
170    /// Get the default staleness (registry) cache directory
171    #[must_use]
172    pub fn staleness_cache_dir() -> PathBuf {
173        cache_dir()
174            .unwrap_or_else(|| PathBuf::from(".cache"))
175            .join("sbom-tools")
176            .join("staleness")
177    }
178
179    /// Get the default HuggingFace Hub cache directory
180    #[must_use]
181    pub fn huggingface_cache_dir() -> PathBuf {
182        cache_dir()
183            .unwrap_or_else(|| PathBuf::from(".cache"))
184            .join("sbom-tools")
185            .join("huggingface")
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_exit_codes_values() {
195        assert_eq!(exit_codes::SUCCESS, 0);
196        assert_eq!(exit_codes::CHANGES_DETECTED, 1);
197        assert_eq!(exit_codes::VULNS_INTRODUCED, 2);
198        assert_eq!(exit_codes::ERROR, 3);
199        assert_eq!(exit_codes::VEX_GAPS_FOUND, 4);
200        assert_eq!(exit_codes::LICENSE_VIOLATIONS, 5);
201        assert_eq!(exit_codes::KEV_INTRODUCED, 6);
202    }
203
204    #[test]
205    fn test_per_command_exit_code_aliases_preserve_values() {
206        // Per-command aliases must not introduce new numeric exit codes.
207        assert_eq!(exit_codes::COMPLIANCE_ERRORS, exit_codes::CHANGES_DETECTED);
208        assert_eq!(
209            exit_codes::COMPLIANCE_WARNINGS,
210            exit_codes::VULNS_INTRODUCED
211        );
212        assert_eq!(
213            exit_codes::QUALITY_BELOW_THRESHOLD,
214            exit_codes::CHANGES_DETECTED
215        );
216        assert_eq!(exit_codes::NO_MATCHES, exit_codes::CHANGES_DETECTED);
217    }
218
219    #[test]
220    fn test_cache_dir_returns_some() {
221        // Should return Some on most platforms when HOME is set
222        // This test verifies the function doesn't panic
223        let _ = dirs::cache_dir();
224    }
225
226    #[test]
227    fn test_osv_cache_dir_path() {
228        let path = dirs::osv_cache_dir();
229        let path_str = path.to_string_lossy();
230        assert!(path_str.contains("osv"));
231    }
232}