Skip to main content

sbom_tools/cli/
mod.rs

1//! CLI command handlers.
2//!
3//! This module provides testable command handlers that are invoked by main.rs.
4//! Each handler implements the business logic for a specific CLI subcommand.
5
6#[cfg(feature = "enrichment")]
7mod cache;
8mod convert;
9mod cra_docs;
10mod cra_standards_watch;
11mod diff;
12#[cfg(feature = "enrichment")]
13mod enrich;
14mod license_check;
15mod merge;
16mod multi;
17mod quality;
18mod query;
19mod tailor;
20mod validate;
21mod verify;
22mod vex;
23mod view;
24mod watch;
25
26#[cfg(feature = "enrichment")]
27pub use cache::{CacheAction, run_cache};
28pub use convert::run_convert;
29pub use cra_docs::{run_cra_docs, run_cra_docs_with_force};
30pub use cra_standards_watch::{
31    OnlineProbe, TrackedStandard, WatchOutputFormat, cra_catalogue, probe_cra_standards,
32    run_cra_standards_watch,
33};
34pub use diff::run_diff;
35#[cfg(feature = "enrichment")]
36pub use enrich::run_enrich;
37pub use license_check::run_license_check;
38pub use merge::run_merge;
39pub use multi::{run_diff_multi, run_matrix, run_timeline};
40pub use quality::{QUALITY_OUTPUT_FORMATS, run_quality};
41pub use query::{QueryFilter, run_query};
42pub use tailor::run_tailor;
43pub use validate::{VALIDATE_OUTPUT_FORMATS, run_validate};
44pub use verify::{VerifyAction, run_verify};
45pub use vex::{VexAction, VexExportFormat, run_vex};
46pub use view::run_view;
47pub use watch::run_watch;
48
49// Re-export config types used by handlers
50pub use crate::config::{DiffConfig, ViewConfig};
51
52/// Reject an output format the given command has no real renderer for.
53///
54/// Each command handler declares the formats it genuinely supports and calls
55/// this before doing any work, so an unsupported `(command, format)` pair
56/// fails with a clear error instead of silently substituting another format
57/// (e.g. `validate -o html` used to fall through to the plain-text renderer,
58/// and `diff -o oscal-json` used to emit plain JSON).
59pub(crate) fn ensure_output_format_supported(
60    command: &str,
61    requested: crate::reports::ReportFormat,
62    supported: &[crate::reports::ReportFormat],
63) -> anyhow::Result<()> {
64    if supported.contains(&requested) {
65        return Ok(());
66    }
67    anyhow::bail!(
68        "output format '{requested}' is not supported by `sbom-tools {command}`; \
69         supported formats: {}",
70        supported
71            .iter()
72            .map(std::string::ToString::to_string)
73            .collect::<Vec<_>>()
74            .join(", ")
75    )
76}
77
78/// Resolve the CRA sidecar for a command.
79///
80/// A sidecar that fails to load is a hard error whether it was **explicitly**
81/// requested (CLI flag or config file) or **auto-discovered** next to the
82/// SBOM: silently scoring without discovered-but-broken metadata shifts the
83/// verdict with only a stderr warning, and the identical file passed via
84/// `--cra-sidecar` already hard-errors — the trust decision must not depend
85/// on how the file was found.
86pub(crate) fn load_cra_sidecar(
87    explicit: Option<&std::path::Path>,
88    sbom_path: &std::path::Path,
89) -> anyhow::Result<Option<crate::model::CraSidecarMetadata>> {
90    match explicit {
91        Some(p) => crate::model::CraSidecarMetadata::from_file(p)
92            .map(Some)
93            .map_err(|e| anyhow::anyhow!("Failed to load CRA sidecar from {}: {e}", p.display())),
94        None => crate::model::CraSidecarMetadata::discover_for_sbom(sbom_path)
95            .map_err(|e| anyhow::anyhow!("Failed to load auto-discovered CRA sidecar: {e}")),
96    }
97}
98
99/// Parse an **explicitly** passed `--cra-product-class` value strictly.
100///
101/// A typo'd class used to be silently dropped (`parse_cli` → `None`, scored
102/// as `Default` class), flipping CRA verdicts with zero diagnostics. An
103/// unrecognized value is now a hard error listing the valid spellings —
104/// matching the config-file validator. Sidecar-derived and auto-discovered
105/// classes are unaffected (they never route through this helper).
106pub(crate) fn parse_cra_product_class(
107    value: Option<&str>,
108) -> anyhow::Result<Option<crate::model::CraProductClass>> {
109    value
110        .map(|s| {
111            crate::model::CraProductClass::parse_cli_strict(s)
112                .map_err(|e| anyhow::anyhow!("invalid --cra-product-class: {e}"))
113        })
114        .transpose()
115}
116
117/// Parse an `--as-of` evaluation-clock value (shared by `validate` and
118/// `quality` so both commands accept the exact same spellings).
119///
120/// Accepted forms:
121/// - RFC 3339 datetime with offset (`2027-01-01T00:00:00Z`, `…+02:00`)
122/// - offset-less datetime, taken as UTC (`2027-01-01T00:00:00`)
123/// - bare date, meaning midnight UTC (`2027-01-01`)
124pub(crate) fn parse_as_of(raw: &str) -> anyhow::Result<chrono::DateTime<chrono::Utc>> {
125    if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(raw) {
126        return Ok(dt.with_timezone(&chrono::Utc));
127    }
128    if let Ok(ndt) = raw.parse::<chrono::NaiveDateTime>() {
129        return Ok(ndt.and_utc());
130    }
131    if let Ok(d) = raw.parse::<chrono::NaiveDate>() {
132        return Ok(d.and_hms_opt(0, 0, 0).expect("midnight is valid").and_utc());
133    }
134    anyhow::bail!(
135        "invalid --as-of {raw:?}: expected an RFC 3339 datetime \
136         (e.g. 2027-01-01T00:00:00Z), an offset-less datetime taken as UTC \
137         (e.g. 2027-01-01T00:00:00), or a date YYYY-MM-DD (midnight UTC)"
138    )
139}
140
141#[cfg(test)]
142mod tests {
143    use chrono::{TimeZone, Utc};
144
145    #[test]
146    fn parse_as_of_accepts_rfc3339_with_offset() {
147        let t = super::parse_as_of("2027-01-01T02:00:00+02:00").unwrap();
148        assert_eq!(t, Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap());
149    }
150
151    #[test]
152    fn parse_as_of_accepts_offsetless_datetime_as_utc() {
153        // Regression: this spelling used to fail with a misleading
154        // "trailing input" error from the bare-date fallback.
155        let t = super::parse_as_of("2027-01-01T12:30:00").unwrap();
156        assert_eq!(t, Utc.with_ymd_and_hms(2027, 1, 1, 12, 30, 0).unwrap());
157    }
158
159    #[test]
160    fn parse_as_of_accepts_bare_date_as_midnight_utc() {
161        let t = super::parse_as_of("2027-01-01").unwrap();
162        assert_eq!(t, Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap());
163    }
164
165    #[test]
166    fn parse_as_of_error_names_every_accepted_form() {
167        let err = super::parse_as_of("not-a-date").unwrap_err().to_string();
168        assert!(err.contains("not-a-date"));
169        assert!(err.contains("RFC 3339"), "must name RFC 3339: {err}");
170        assert!(
171            err.contains("offset-less") && err.contains("UTC"),
172            "must explain the offset-less form: {err}"
173        );
174        assert!(err.contains("YYYY-MM-DD"), "must name the date form: {err}");
175    }
176
177    #[test]
178    fn parse_cra_product_class_is_strict_and_lists_valid_values() {
179        assert_eq!(
180            super::parse_cra_product_class(Some("critical")).unwrap(),
181            Some(crate::model::CraProductClass::Critical)
182        );
183        assert_eq!(super::parse_cra_product_class(None).unwrap(), None);
184        let err = super::parse_cra_product_class(Some("critcal"))
185            .unwrap_err()
186            .to_string();
187        assert!(err.contains("critcal"), "must name the bad value: {err}");
188        for valid in [
189            "default",
190            "important-class-1",
191            "important-class-2",
192            "critical",
193        ] {
194            assert!(err.contains(valid), "must list '{valid}': {err}");
195        }
196    }
197}