Skip to main content

sbom_tools/quality/compliance/
selector.rs

1//! CLI-facing compliance-standard selector.
2//!
3//! Single source of truth for mapping user-facing standard names (and their
4//! aliases) to [`ComplianceLevel`]. Everything that accepts a standard name —
5//! the clap definition of `validate --standard` (help text, parse errors,
6//! shell completions), the config file's `compliance.standards` section, and
7//! `cli::run_validate` — parses through this type, so the accepted spellings
8//! can never drift between the CLI, the config file, and the docs again.
9
10use super::ComplianceLevel;
11use clap::ValueEnum;
12
13/// A compliance standard as selected on the command line or in the config
14/// file. Each variant corresponds to one canonical `--standard` value; the
15/// historical alias spellings are attached via `#[value(alias = ...)]` so
16/// clap (and [`std::str::FromStr`], which delegates to the same table)
17/// accepts them everywhere.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ValueEnum)]
19#[non_exhaustive]
20pub enum StandardSelector {
21    /// NTIA Minimum Elements for software transparency
22    #[value(name = "ntia")]
23    Ntia,
24    /// FDA premarket submission requirements for medical devices
25    #[value(name = "fda")]
26    Fda,
27    /// EU CRA Phase 2 — full application of the regulation (from 11 Dec 2027).
28    /// `cra` selects Phase 2; use `cra-phase1` for the 2026 reporting phase.
29    #[value(name = "cra", alias = "cra-phase2")]
30    Cra,
31    /// EU CRA Phase 1 — Art. 14 reporting obligations (apply from 11 Sep 2026)
32    #[value(name = "cra-phase1", alias = "cra-2026")]
33    CraPhase1,
34    /// NIST SP 800-218 Secure Software Development Framework
35    #[value(name = "ssdf", alias = "nist-ssdf", alias = "nist_ssdf")]
36    Ssdf,
37    /// Executive Order 14028 Section 4 — software supply chain security
38    #[value(name = "eo14028", alias = "eo-14028", alias = "eo_14028")]
39    Eo14028,
40    /// NSA CNSA 2.0 — Commercial National Security Algorithm Suite 2.0
41    #[value(name = "cnsa2", alias = "cnsa-2", alias = "cnsa_2", alias = "cnsa2.0")]
42    Cnsa2,
43    /// NIST PQC readiness (IR 8547 + FIPS 203/204/205)
44    #[value(name = "pqc", alias = "nist-pqc", alias = "nist_pqc")]
45    Pqc,
46    /// BSI TR-03183-2 v2.1.0 (German national CRA-aligned SBOM guideline)
47    #[value(
48        name = "bsi",
49        alias = "tr-03183",
50        alias = "tr03183",
51        alias = "bsi-tr-03183-2"
52    )]
53    Bsi,
54    /// CRA Article 24 — open-source software steward profile
55    #[value(
56        name = "oss-steward",
57        alias = "cra-oss-steward",
58        alias = "cra-oss",
59        alias = "cra-art24",
60        alias = "art24"
61    )]
62    OssSteward,
63    /// EUCC Substantial assurance level (Reg. (EU) 2024/482), reference-only
64    #[value(name = "eucc", alias = "eucc-substantial", alias = "common-criteria")]
65    Eucc,
66    /// EU AI Act (Reg. (EU) 2024/1689) Annex IV documentation readiness
67    #[value(
68        name = "ai-act",
69        alias = "ai_act",
70        alias = "aiact",
71        alias = "eu-ai-act"
72    )]
73    AiAct,
74    /// BSI/G7 "SBOM for AI — Minimum Elements" readiness
75    #[value(
76        name = "bsi-ai",
77        alias = "bsi_ai",
78        alias = "bsiai",
79        alias = "sbom-for-ai",
80        alias = "ai-bom"
81    )]
82    BsiAi,
83    /// CISA 2026 Minimum Elements for an SBOM (v2.1, July 2026; successor
84    /// to the NTIA 2021 Minimum Elements)
85    #[value(
86        name = "cisa-2026",
87        alias = "cisa",
88        alias = "cisa2026",
89        alias = "minimum-elements-2026"
90    )]
91    Cisa2026,
92    /// PCI DSS v4.0.1 Requirement 6.3.2 software-inventory profile
93    #[value(
94        name = "pci-dss",
95        alias = "pci",
96        alias = "pci-dss-6-3-2",
97        alias = "pci-dss-4"
98    )]
99    PciDss,
100    /// CISA Framing Software Component Transparency, 3rd ed. (2024)
101    #[value(name = "fsct", alias = "fsct-3", alias = "component-transparency")]
102    Fsct,
103}
104
105impl StandardSelector {
106    /// The [`ComplianceLevel`] this selector maps to.
107    #[must_use]
108    pub const fn level(self) -> ComplianceLevel {
109        match self {
110            Self::Ntia => ComplianceLevel::NtiaMinimum,
111            Self::Fda => ComplianceLevel::FdaMedicalDevice,
112            Self::Cra => ComplianceLevel::CraPhase2,
113            Self::CraPhase1 => ComplianceLevel::CraPhase1,
114            Self::Ssdf => ComplianceLevel::NistSsdf,
115            Self::Eo14028 => ComplianceLevel::Eo14028,
116            Self::Cnsa2 => ComplianceLevel::Cnsa2,
117            Self::Pqc => ComplianceLevel::NistPqc,
118            Self::Bsi => ComplianceLevel::BsiTr03183_2,
119            Self::OssSteward => ComplianceLevel::CraOssSteward,
120            Self::Eucc => ComplianceLevel::EuccSubstantial,
121            Self::AiAct => ComplianceLevel::EuAiAct,
122            Self::BsiAi => ComplianceLevel::BsiSbomForAi,
123            Self::Cisa2026 => ComplianceLevel::Cisa2026,
124            Self::PciDss => ComplianceLevel::PciDss632,
125            Self::Fsct => ComplianceLevel::Fsct,
126        }
127    }
128
129    /// The canonical CLI spelling (the `#[value(name = ...)]`).
130    #[must_use]
131    pub fn canonical_name(self) -> &'static str {
132        match self {
133            Self::Ntia => "ntia",
134            Self::Fda => "fda",
135            Self::Cra => "cra",
136            Self::CraPhase1 => "cra-phase1",
137            Self::Ssdf => "ssdf",
138            Self::Eo14028 => "eo14028",
139            Self::Cnsa2 => "cnsa2",
140            Self::Pqc => "pqc",
141            Self::Bsi => "bsi",
142            Self::OssSteward => "oss-steward",
143            Self::Eucc => "eucc",
144            Self::AiAct => "ai-act",
145            Self::BsiAi => "bsi-ai",
146            Self::Cisa2026 => "cisa-2026",
147            Self::PciDss => "pci-dss",
148            Self::Fsct => "fsct",
149        }
150    }
151
152    /// Comma-separated list of every canonical value, for error messages.
153    #[must_use]
154    pub fn valid_values() -> String {
155        Self::value_variants()
156            .iter()
157            .map(|v| v.canonical_name())
158            .collect::<Vec<_>>()
159            .join(", ")
160    }
161}
162
163impl std::fmt::Display for StandardSelector {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.write_str(self.canonical_name())
166    }
167}
168
169impl std::str::FromStr for StandardSelector {
170    type Err = String;
171
172    /// Case-insensitive parse through the same name/alias table clap uses.
173    fn from_str(s: &str) -> Result<Self, Self::Err> {
174        let s = s.trim();
175        for variant in Self::value_variants() {
176            if variant
177                .to_possible_value()
178                .is_some_and(|pv| pv.matches(s, true))
179            {
180                return Ok(*variant);
181            }
182        }
183        Err(format!(
184            "unknown compliance standard '{s}'. Valid values: {} \
185             (aliases such as nist-ssdf, tr-03183, cra-art24 are also accepted; \
186             see `sbom-tools validate --help`)",
187            Self::valid_values()
188        ))
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    /// Contract test: every documented alias parses to the right level.
197    /// This is the complete alias table formerly hand-rolled in
198    /// `cli/validate.rs`; removing or re-mapping any spelling is a breaking
199    /// CLI change and must fail here.
200    #[test]
201    fn every_documented_alias_parses_to_the_right_level() {
202        let table: &[(&str, ComplianceLevel)] = &[
203            ("ntia", ComplianceLevel::NtiaMinimum),
204            ("fda", ComplianceLevel::FdaMedicalDevice),
205            ("cra", ComplianceLevel::CraPhase2),
206            ("cra-phase2", ComplianceLevel::CraPhase2),
207            ("cra-phase1", ComplianceLevel::CraPhase1),
208            ("cra-2026", ComplianceLevel::CraPhase1),
209            ("ssdf", ComplianceLevel::NistSsdf),
210            ("nist-ssdf", ComplianceLevel::NistSsdf),
211            ("nist_ssdf", ComplianceLevel::NistSsdf),
212            ("eo14028", ComplianceLevel::Eo14028),
213            ("eo-14028", ComplianceLevel::Eo14028),
214            ("eo_14028", ComplianceLevel::Eo14028),
215            ("cnsa2", ComplianceLevel::Cnsa2),
216            ("cnsa-2", ComplianceLevel::Cnsa2),
217            ("cnsa_2", ComplianceLevel::Cnsa2),
218            ("cnsa2.0", ComplianceLevel::Cnsa2),
219            ("pqc", ComplianceLevel::NistPqc),
220            ("nist-pqc", ComplianceLevel::NistPqc),
221            ("nist_pqc", ComplianceLevel::NistPqc),
222            ("bsi", ComplianceLevel::BsiTr03183_2),
223            ("tr-03183", ComplianceLevel::BsiTr03183_2),
224            ("tr03183", ComplianceLevel::BsiTr03183_2),
225            ("bsi-tr-03183-2", ComplianceLevel::BsiTr03183_2),
226            ("oss-steward", ComplianceLevel::CraOssSteward),
227            ("cra-oss-steward", ComplianceLevel::CraOssSteward),
228            ("cra-oss", ComplianceLevel::CraOssSteward),
229            ("cra-art24", ComplianceLevel::CraOssSteward),
230            ("art24", ComplianceLevel::CraOssSteward),
231            ("eucc", ComplianceLevel::EuccSubstantial),
232            ("eucc-substantial", ComplianceLevel::EuccSubstantial),
233            ("common-criteria", ComplianceLevel::EuccSubstantial),
234            ("ai-act", ComplianceLevel::EuAiAct),
235            ("ai_act", ComplianceLevel::EuAiAct),
236            ("aiact", ComplianceLevel::EuAiAct),
237            ("eu-ai-act", ComplianceLevel::EuAiAct),
238            ("bsi-ai", ComplianceLevel::BsiSbomForAi),
239            ("bsi_ai", ComplianceLevel::BsiSbomForAi),
240            ("bsiai", ComplianceLevel::BsiSbomForAi),
241            ("sbom-for-ai", ComplianceLevel::BsiSbomForAi),
242            ("ai-bom", ComplianceLevel::BsiSbomForAi),
243            ("cisa-2026", ComplianceLevel::Cisa2026),
244            ("cisa", ComplianceLevel::Cisa2026),
245            ("cisa2026", ComplianceLevel::Cisa2026),
246            ("minimum-elements-2026", ComplianceLevel::Cisa2026),
247            ("pci-dss", ComplianceLevel::PciDss632),
248            ("pci", ComplianceLevel::PciDss632),
249            ("pci-dss-6-3-2", ComplianceLevel::PciDss632),
250            ("pci-dss-4", ComplianceLevel::PciDss632),
251            ("fsct", ComplianceLevel::Fsct),
252            ("fsct-3", ComplianceLevel::Fsct),
253            ("component-transparency", ComplianceLevel::Fsct),
254        ];
255        for (spelling, expected) in table {
256            let parsed: StandardSelector = spelling
257                .parse()
258                .unwrap_or_else(|e| panic!("'{spelling}' must parse: {e}"));
259            assert_eq!(
260                parsed.level(),
261                *expected,
262                "'{spelling}' mapped to the wrong compliance level"
263            );
264        }
265    }
266
267    #[test]
268    fn parse_is_case_insensitive_and_trims() {
269        assert_eq!(
270            "NTIA".parse::<StandardSelector>().unwrap(),
271            StandardSelector::Ntia
272        );
273        assert_eq!(
274            " Cra-Phase1 ".parse::<StandardSelector>().unwrap(),
275            StandardSelector::CraPhase1
276        );
277        assert_eq!(
278            "CNSA2.0".parse::<StandardSelector>().unwrap(),
279            StandardSelector::Cnsa2
280        );
281    }
282
283    #[test]
284    fn unknown_standard_error_lists_valid_values() {
285        let err = "not-a-standard".parse::<StandardSelector>().unwrap_err();
286        assert!(err.contains("not-a-standard"));
287        for canonical in [
288            "ntia",
289            "fda",
290            "cra",
291            "cra-phase1",
292            "ssdf",
293            "eo14028",
294            "cnsa2",
295            "pqc",
296            "bsi",
297            "oss-steward",
298            "eucc",
299            "ai-act",
300            "bsi-ai",
301            "cisa-2026",
302            "pci-dss",
303            "fsct",
304        ] {
305            assert!(err.contains(canonical), "error must list '{canonical}'");
306        }
307    }
308
309    #[test]
310    fn every_variant_has_a_level_and_canonical_name_roundtrip() {
311        for variant in StandardSelector::value_variants() {
312            let name = variant.canonical_name();
313            let reparsed: StandardSelector = name.parse().unwrap();
314            assert_eq!(reparsed, *variant, "canonical '{name}' must round-trip");
315            // level() must not panic and must be a real checkable level
316            let _ = variant.level().name();
317        }
318    }
319
320    #[test]
321    fn cra_selects_phase2_and_phase1_is_reachable() {
322        assert_eq!(
323            "cra".parse::<StandardSelector>().unwrap().level(),
324            ComplianceLevel::CraPhase2
325        );
326        assert_eq!(
327            "cra-phase1".parse::<StandardSelector>().unwrap().level(),
328            ComplianceLevel::CraPhase1
329        );
330    }
331}