Skip to main content

sbom_tools/model/
bom_profile.rs

1//! BOM profile detection and configuration.
2//!
3//! Determines the type of Bill of Materials (SBOM, CBOM, etc.) and provides
4//! profile-specific defaults for quality scoring, compliance standards, and
5//! TUI tab selection.
6
7use super::metadata::ComponentType;
8use super::sbom::NormalizedSbom;
9use serde::{Deserialize, Serialize};
10
11/// BOM profile — determines mode-specific behavior across TUI and CLI.
12///
13/// Auto-detected from SBOM content or overridden via `--bom-type`.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
15#[non_exhaustive]
16pub enum BomProfile {
17    /// Standard Software Bill of Materials
18    #[default]
19    Sbom,
20    /// Cryptographic Bill of Materials (CycloneDX 1.6+ cryptoProperties)
21    Cbom,
22    /// AI/ML Bill of Materials (CycloneDX 1.5+ ML model + dataset components)
23    AiBom,
24    // Future: Hbom
25}
26
27impl BomProfile {
28    /// Auto-detect the BOM profile from SBOM content.
29    ///
30    /// Classifies as:
31    /// - `AiBom` when the SBOM is ML-centric: any `MachineLearningModel`
32    ///   component is present, AND the AI-relevant components
33    ///   (`MachineLearningModel` + `Data`) either form a majority (>50%) or
34    ///   number at least 3.
35    /// - `Cbom` when >50% of components are `ComponentType::Cryptographic`
36    ///   and there are at least 3 crypto components.
37    /// - `Sbom` otherwise.
38    ///
39    /// Precedence when both crypto and ML are significant: the larger
40    /// component count wins (AI-relevant vs. crypto). On a tie, AI-BOM is
41    /// preferred because a model-bearing SBOM is the rarer, more specific
42    /// classification.
43    #[must_use]
44    pub fn detect(sbom: &NormalizedSbom) -> Self {
45        let total = sbom.components.len();
46        if total == 0 {
47            return Self::Sbom;
48        }
49
50        let crypto_count = sbom
51            .components
52            .values()
53            .filter(|c| c.component_type == ComponentType::Cryptographic)
54            .count();
55
56        let ml_count = sbom
57            .components
58            .values()
59            .filter(|c| c.component_type == ComponentType::MachineLearningModel)
60            .count();
61        // AI-relevant components: models plus their training/eval datasets.
62        let ai_count = ml_count
63            + sbom
64                .components
65                .values()
66                .filter(|c| c.component_type == ComponentType::Data)
67                .count();
68
69        // An AI-BOM must contain at least one model; datasets alone are not enough.
70        let is_aibom = ml_count >= 1 && (ai_count * 2 > total || ai_count >= 3);
71        let is_cbom = crypto_count >= 3 && crypto_count * 2 > total;
72
73        match (is_aibom, is_cbom) {
74            (true, true) => {
75                // Both significant: pick by component majority, AI on tie.
76                if crypto_count > ai_count {
77                    Self::Cbom
78                } else {
79                    Self::AiBom
80                }
81            }
82            (true, false) => Self::AiBom,
83            (false, true) => Self::Cbom,
84            (false, false) => Self::Sbom,
85        }
86    }
87
88    /// Human-readable label for display.
89    #[must_use]
90    pub const fn label(&self) -> &'static str {
91        match self {
92            Self::Sbom => "SBOM",
93            Self::Cbom => "CBOM",
94            Self::AiBom => "AI-BOM",
95        }
96    }
97
98    /// Parse from a string (CLI `--bom-type` flag).
99    #[must_use]
100    pub fn from_str_opt(s: &str) -> Option<Self> {
101        match s.to_lowercase().as_str() {
102            "sbom" => Some(Self::Sbom),
103            "cbom" => Some(Self::Cbom),
104            "ai" | "aibom" | "mlbom" => Some(Self::AiBom),
105            _ => None,
106        }
107    }
108}
109
110impl std::fmt::Display for BomProfile {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        write!(f, "{}", self.label())
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use crate::model::Component;
120
121    #[test]
122    fn test_detect_sbom_empty() {
123        let sbom = NormalizedSbom::default();
124        assert_eq!(BomProfile::detect(&sbom), BomProfile::Sbom);
125    }
126
127    #[test]
128    fn test_detect_sbom_no_crypto() {
129        let mut sbom = NormalizedSbom::default();
130        for i in 0..10 {
131            let c = Component::new(format!("lib-{i}"), format!("lib-{i}@1.0"));
132            sbom.add_component(c);
133        }
134        assert_eq!(BomProfile::detect(&sbom), BomProfile::Sbom);
135    }
136
137    #[test]
138    fn test_detect_cbom_majority_crypto() {
139        let mut sbom = NormalizedSbom::default();
140        // 2 software + 5 crypto = 71% crypto → CBOM
141        for i in 0..2 {
142            let c = Component::new(format!("app-{i}"), format!("app-{i}@1.0"));
143            sbom.add_component(c);
144        }
145        for i in 0..5 {
146            let mut c = Component::new(format!("algo-{i}"), format!("algo-{i}@1.0"));
147            c.component_type = ComponentType::Cryptographic;
148            sbom.add_component(c);
149        }
150        assert_eq!(BomProfile::detect(&sbom), BomProfile::Cbom);
151    }
152
153    #[test]
154    fn test_detect_sbom_minority_crypto() {
155        let mut sbom = NormalizedSbom::default();
156        // 8 software + 3 crypto = 27% crypto → SBOM (below 50%)
157        for i in 0..8 {
158            let c = Component::new(format!("lib-{i}"), format!("lib-{i}@1.0"));
159            sbom.add_component(c);
160        }
161        for i in 0..3 {
162            let mut c = Component::new(format!("algo-{i}"), format!("algo-{i}@1.0"));
163            c.component_type = ComponentType::Cryptographic;
164            sbom.add_component(c);
165        }
166        assert_eq!(BomProfile::detect(&sbom), BomProfile::Sbom);
167    }
168
169    #[test]
170    fn test_detect_cbom_needs_minimum_3() {
171        let mut sbom = NormalizedSbom::default();
172        // 2 crypto only but < 3 minimum → SBOM
173        for i in 0..2 {
174            let mut c = Component::new(format!("algo-{i}"), format!("algo-{i}@1.0"));
175            c.component_type = ComponentType::Cryptographic;
176            sbom.add_component(c);
177        }
178        assert_eq!(BomProfile::detect(&sbom), BomProfile::Sbom);
179    }
180
181    fn ml_component(name: &str) -> Component {
182        let mut c = Component::new(name.to_string(), format!("{name}@1.0"));
183        c.component_type = ComponentType::MachineLearningModel;
184        c
185    }
186
187    fn data_component(name: &str) -> Component {
188        let mut c = Component::new(name.to_string(), format!("{name}@1.0"));
189        c.component_type = ComponentType::Data;
190        c
191    }
192
193    #[test]
194    fn test_detect_aibom_ml_majority() {
195        let mut sbom = NormalizedSbom::default();
196        // 1 app + 1 model + 1 dataset = 2/3 AI-relevant (majority) → AI-BOM.
197        sbom.add_component(Component::new("app".to_string(), "app@1.0".to_string()));
198        sbom.add_component(ml_component("classifier"));
199        sbom.add_component(data_component("reviews"));
200        assert_eq!(BomProfile::detect(&sbom), BomProfile::AiBom);
201    }
202
203    #[test]
204    fn test_detect_aibom_min_three_without_majority() {
205        let mut sbom = NormalizedSbom::default();
206        // 5 libs + 2 models + 1 dataset = 3 AI-relevant (37.5%, not a majority)
207        // but >= 3 with at least one model → AI-BOM.
208        for i in 0..5 {
209            sbom.add_component(Component::new(format!("lib-{i}"), format!("lib-{i}@1.0")));
210        }
211        sbom.add_component(ml_component("model-a"));
212        sbom.add_component(ml_component("model-b"));
213        sbom.add_component(data_component("train"));
214        assert_eq!(BomProfile::detect(&sbom), BomProfile::AiBom);
215    }
216
217    #[test]
218    fn test_detect_not_aibom_datasets_only() {
219        let mut sbom = NormalizedSbom::default();
220        // Datasets with no model must not classify as AI-BOM.
221        for i in 0..4 {
222            sbom.add_component(data_component(&format!("data-{i}")));
223        }
224        assert_eq!(BomProfile::detect(&sbom), BomProfile::Sbom);
225    }
226
227    #[test]
228    fn test_detect_aibom_beats_cbom_on_majority() {
229        let mut sbom = NormalizedSbom::default();
230        // 4 ML + 3 crypto: both significant, AI count (4) > crypto (3) → AI-BOM.
231        for i in 0..4 {
232            sbom.add_component(ml_component(&format!("model-{i}")));
233        }
234        for i in 0..3 {
235            let mut c = Component::new(format!("algo-{i}"), format!("algo-{i}@1.0"));
236            c.component_type = ComponentType::Cryptographic;
237            sbom.add_component(c);
238        }
239        assert_eq!(BomProfile::detect(&sbom), BomProfile::AiBom);
240    }
241
242    #[test]
243    fn test_detect_cbom_beats_aibom_when_crypto_dominates() {
244        let mut sbom = NormalizedSbom::default();
245        // 5 crypto + 1 model + 1 dataset: crypto (5) > AI (2) → CBOM.
246        for i in 0..5 {
247            let mut c = Component::new(format!("algo-{i}"), format!("algo-{i}@1.0"));
248            c.component_type = ComponentType::Cryptographic;
249            sbom.add_component(c);
250        }
251        sbom.add_component(ml_component("model"));
252        sbom.add_component(data_component("data"));
253        assert_eq!(BomProfile::detect(&sbom), BomProfile::Cbom);
254    }
255
256    #[test]
257    fn test_from_str_opt() {
258        assert_eq!(BomProfile::from_str_opt("sbom"), Some(BomProfile::Sbom));
259        assert_eq!(BomProfile::from_str_opt("CBOM"), Some(BomProfile::Cbom));
260        assert_eq!(BomProfile::from_str_opt("cbom"), Some(BomProfile::Cbom));
261        assert_eq!(BomProfile::from_str_opt("ai"), Some(BomProfile::AiBom));
262        assert_eq!(BomProfile::from_str_opt("AiBom"), Some(BomProfile::AiBom));
263        assert_eq!(BomProfile::from_str_opt("mlbom"), Some(BomProfile::AiBom));
264        assert_eq!(BomProfile::from_str_opt("hbom"), None);
265    }
266
267    #[test]
268    fn test_label() {
269        assert_eq!(BomProfile::Sbom.label(), "SBOM");
270        assert_eq!(BomProfile::Cbom.label(), "CBOM");
271        assert_eq!(BomProfile::AiBom.label(), "AI-BOM");
272    }
273
274    #[test]
275    fn test_display() {
276        assert_eq!(format!("{}", BomProfile::Sbom), "SBOM");
277        assert_eq!(format!("{}", BomProfile::Cbom), "CBOM");
278        assert_eq!(format!("{}", BomProfile::AiBom), "AI-BOM");
279    }
280}