use super::{Violation, ViolationCategory, ViolationSeverity, truncate_list};
use crate::model::{Component, ComponentType, ExternalRefType, NormalizedSbom};
pub(crate) struct AiBomScope<'a> {
pub ml_components: Vec<&'a Component>,
pub dataset_components: Vec<&'a Component>,
pub untyped_ml_components: Vec<&'a Component>,
}
impl AiBomScope<'_> {
pub(crate) fn is_applicable(&self) -> bool {
!self.ml_components.is_empty()
|| !self.dataset_components.is_empty()
|| !self.untyped_ml_components.is_empty()
}
}
pub(crate) fn ai_bom_scope(sbom: &NormalizedSbom) -> AiBomScope<'_> {
let mut scope = AiBomScope {
ml_components: Vec::new(),
dataset_components: Vec::new(),
untyped_ml_components: Vec::new(),
};
for c in sbom.components.values() {
let is_ml = c.component_type == ComponentType::MachineLearningModel || c.ml_model.is_some();
if is_ml {
scope.ml_components.push(c);
}
if c.dataset.is_some() {
scope.dataset_components.push(c);
}
if !is_ml && c.dataset.is_none() && looks_like_ml_content(c) {
scope.untyped_ml_components.push(c);
}
}
scope
}
pub(crate) fn has_model_card_ref(c: &Component) -> bool {
c.external_refs
.iter()
.any(|r| r.ref_type == ExternalRefType::ModelCard)
}
fn looks_like_ml_content(c: &Component) -> bool {
let hf_purl = c.identifiers.purl.as_deref().is_some_and(|p| {
p.get(..16)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("pkg:huggingface/"))
});
hf_purl || has_model_card_ref(c)
}
pub(crate) fn push_untyped_ml_warning(
scope: &AiBomScope<'_>,
profile_tag: &str,
requirement: &str,
rule_id: &'static str,
violations: &mut Vec<Violation>,
) {
if scope.untyped_ml_components.is_empty() {
return;
}
let names: Vec<String> = scope
.untyped_ml_components
.iter()
.map(|c| c.name.clone())
.collect();
violations.push(Violation {
severity: ViolationSeverity::Warning,
category: ViolationCategory::ComponentIdentification,
message: format!(
"[{profile_tag}] ML content detected but not typed machine-learning-model: \
{} component(s) carry ML signals (pkg:huggingface PURL or model-card \
reference) without ML-model metadata: {}",
names.len(),
truncate_list(&names, 5)
),
element: names.first().cloned(),
requirement: requirement.to_string(),
rule_id,
component_id: None,
counts: None,
standard_refs: Vec::new(),
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{DatasetInfo, ExternalReference, MlModelInfo};
fn component(name: &str) -> Component {
Component::new(name.to_string(), name.to_string()).with_version("1.0.0".to_string())
}
fn add(sbom: &mut NormalizedSbom, c: Component) {
sbom.components.insert(c.canonical_id.clone(), c);
}
#[test]
fn bare_data_component_is_not_an_ai_dataset() {
let mut sbom = NormalizedSbom::default();
let mut cfg = component("app-config");
cfg.component_type = ComponentType::Data;
add(&mut sbom, cfg);
let scope = ai_bom_scope(&sbom);
assert!(scope.dataset_components.is_empty());
assert!(!scope.is_applicable());
}
#[test]
fn dataset_evidence_counts_regardless_of_type() {
let mut sbom = NormalizedSbom::default();
let mut data = component("training-data");
data.component_type = ComponentType::Data;
data.dataset = Some(DatasetInfo::default());
add(&mut sbom, data);
let scope = ai_bom_scope(&sbom);
assert_eq!(scope.dataset_components.len(), 1);
assert!(scope.is_applicable());
}
#[test]
fn ml_metadata_counts_even_when_mistyped() {
let mut sbom = NormalizedSbom::default();
let mut app = component("sentiment-model");
app.component_type = ComponentType::Application;
app.ml_model = Some(MlModelInfo::default());
add(&mut sbom, app);
let scope = ai_bom_scope(&sbom);
assert_eq!(scope.ml_components.len(), 1);
assert!(scope.untyped_ml_components.is_empty());
assert!(scope.is_applicable());
}
#[test]
fn huggingface_purl_without_ml_metadata_is_an_untyped_suspect() {
let mut sbom = NormalizedSbom::default();
let hf = component("bert-base-uncased")
.with_purl("pkg:huggingface/google-bert/bert-base-uncased@1.0.0".to_string());
add(&mut sbom, hf);
let scope = ai_bom_scope(&sbom);
assert!(scope.ml_components.is_empty());
assert_eq!(scope.untyped_ml_components.len(), 1);
assert!(scope.is_applicable());
}
#[test]
fn huggingface_dataset_with_evidence_is_not_an_untyped_suspect() {
let mut sbom = NormalizedSbom::default();
let mut ds = component("imdb").with_purl("pkg:huggingface/datasets/imdb@1.0.0".to_string());
ds.component_type = ComponentType::Data;
ds.dataset = Some(DatasetInfo::default());
add(&mut sbom, ds);
let scope = ai_bom_scope(&sbom);
assert_eq!(scope.dataset_components.len(), 1);
assert!(
scope.untyped_ml_components.is_empty(),
"dataset evidence must exempt the component from the untyped-ML heuristic"
);
assert!(scope.is_applicable());
}
#[test]
fn huggingface_purl_with_dataset_evidence_but_untyped_is_not_a_suspect() {
let mut sbom = NormalizedSbom::default();
let mut ds =
component("common-voice").with_purl("pkg:huggingface/datasets/cv@2.0.0".to_string());
ds.dataset = Some(DatasetInfo::default());
add(&mut sbom, ds);
let scope = ai_bom_scope(&sbom);
assert_eq!(scope.dataset_components.len(), 1);
assert!(scope.untyped_ml_components.is_empty());
}
#[test]
fn model_card_ref_without_ml_metadata_is_an_untyped_suspect() {
let mut sbom = NormalizedSbom::default();
let mut c = component("mystery-model");
c.external_refs.push(ExternalReference {
ref_type: ExternalRefType::ModelCard,
url: "https://example.test/card".to_string(),
comment: None,
hashes: Vec::new(),
});
add(&mut sbom, c);
let scope = ai_bom_scope(&sbom);
assert!(has_model_card_ref(scope.untyped_ml_components[0]));
assert!(scope.is_applicable());
}
#[test]
fn plain_library_sbom_is_not_applicable() {
let mut sbom = NormalizedSbom::default();
let lib = component("express").with_purl("pkg:npm/express@4.19.2".to_string());
add(&mut sbom, lib);
let scope = ai_bom_scope(&sbom);
assert!(!scope.is_applicable());
}
}