Skip to main content

sbom_tools/model/
sbom.rs

1//! Core SBOM and Component data structures.
2
3use super::{
4    CanonicalId, ComponentExtensions, ComponentIdentifiers, ComponentType, CryptoProperties,
5    DependencyScope, DependencyType, DocumentMetadata, Ecosystem, ExternalReference,
6    FormatExtensions, Hash, LicenseInfo, Organization, VexStatus, VulnerabilityRef,
7};
8use indexmap::IndexMap;
9use serde::{Deserialize, Serialize};
10use xxhash_rust::xxh3::xxh3_64;
11
12const CANONICAL_NAN_BITS: u64 = 0x7ff8_0000_0000_0000;
13
14/// Normalized SBOM document - the canonical intermediate representation.
15///
16/// This structure represents an SBOM in a format-agnostic way, allowing
17/// comparison between `CycloneDX` and SPDX documents.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct NormalizedSbom {
20    /// Document-level metadata
21    pub document: DocumentMetadata,
22    /// Components indexed by canonical ID
23    pub components: IndexMap<CanonicalId, Component>,
24    /// Dependency edges
25    pub edges: Vec<DependencyEdge>,
26    /// Format-specific extensions
27    pub extensions: FormatExtensions,
28    /// Content hash for quick equality checks
29    pub content_hash: u64,
30    /// Primary/root product component (`CycloneDX` metadata.component or SPDX documentDescribes)
31    /// This identifies the main product that this SBOM describes, important for CRA compliance.
32    pub primary_component_id: Option<CanonicalId>,
33    /// Number of canonical ID collisions encountered during parsing
34    #[serde(skip)]
35    pub collision_count: usize,
36}
37
38impl NormalizedSbom {
39    /// Create a new empty normalized SBOM
40    #[must_use]
41    pub fn new(document: DocumentMetadata) -> Self {
42        Self {
43            document,
44            components: IndexMap::new(),
45            edges: Vec::new(),
46            extensions: FormatExtensions::default(),
47            content_hash: 0,
48            primary_component_id: None,
49            collision_count: 0,
50        }
51    }
52
53    /// Return the canonical IDs of *direct* dependencies (1 hop from the
54    /// primary component along the dependency graph).
55    ///
56    /// When no `primary_component_id` is set, all components reachable from
57    /// any node with no incoming edges are treated as direct (best-effort
58    /// approximation for SBOMs that don't declare a root).
59    ///
60    /// Used by CRA prEN 40000-1-3 `[PRE-7-RQ-03]` enforcement, which makes
61    /// direct dependencies *mandatory* and transitive *recommended*.
62    #[must_use]
63    pub fn direct_dependency_ids(&self) -> std::collections::HashSet<CanonicalId> {
64        use std::collections::HashSet;
65        if let Some(root) = &self.primary_component_id {
66            return self
67                .edges
68                .iter()
69                .filter(|e| &e.from == root)
70                .map(|e| e.to.clone())
71                .collect();
72        }
73        // Fallback: find roots = nodes with no incoming edges, then take their direct children.
74        let incoming: HashSet<&CanonicalId> = self.edges.iter().map(|e| &e.to).collect();
75        let roots: HashSet<&CanonicalId> = self
76            .components
77            .keys()
78            .filter(|id| !incoming.contains(id))
79            .collect();
80        self.edges
81            .iter()
82            .filter(|e| roots.contains(&e.from))
83            .map(|e| e.to.clone())
84            .collect()
85    }
86
87    /// Add a component to the SBOM.
88    ///
89    /// Returns `true` if a collision occurred (a component with the same canonical ID
90    /// was already present and has been overwritten). Collisions are logged as warnings.
91    pub fn add_component(&mut self, component: Component) -> bool {
92        let id = component.canonical_id.clone();
93        if let Some(existing) = self.components.get(&id) {
94            // Count genuinely different components that collide on canonical ID
95            if existing.identifiers.format_id != component.identifiers.format_id
96                || existing.name != component.name
97            {
98                self.collision_count += 1;
99            }
100            self.components.insert(id, component);
101            true
102        } else {
103            self.components.insert(id, component);
104            false
105        }
106    }
107
108    /// Log a single summary line if any canonical ID collisions occurred during parsing.
109    pub fn log_collision_summary(&self) {
110        if self.collision_count > 0 {
111            tracing::info!(
112                collision_count = self.collision_count,
113                "Canonical ID collisions: {} distinct components resolved to the same ID \
114                 and were overwritten. Consider adding PURL identifiers to disambiguate.",
115                self.collision_count
116            );
117        }
118    }
119
120    /// Add a dependency edge
121    pub fn add_edge(&mut self, edge: DependencyEdge) {
122        self.edges.push(edge);
123    }
124
125    /// Get a component by canonical ID
126    #[must_use]
127    pub fn get_component(&self, id: &CanonicalId) -> Option<&Component> {
128        self.components.get(id)
129    }
130
131    /// Get dependencies of a component
132    #[must_use]
133    pub fn get_dependencies(&self, id: &CanonicalId) -> Vec<&DependencyEdge> {
134        self.edges.iter().filter(|e| &e.from == id).collect()
135    }
136
137    /// Get dependents of a component
138    #[must_use]
139    pub fn get_dependents(&self, id: &CanonicalId) -> Vec<&DependencyEdge> {
140        self.edges.iter().filter(|e| &e.to == id).collect()
141    }
142
143    /// Calculate and update the content hash
144    pub fn calculate_content_hash(&mut self) {
145        let mut hasher_input = Vec::new();
146
147        // Hash document metadata
148        if let Ok(meta_json) = serde_json::to_vec(&self.document) {
149            hasher_input.extend(meta_json);
150        }
151
152        // Hash all components (sorted for determinism)
153        let mut component_ids: Vec<_> = self.components.keys().collect();
154        component_ids.sort_by(|a, b| a.value().cmp(b.value()));
155
156        for id in component_ids {
157            if let Some(comp) = self.components.get(id) {
158                hasher_input.extend(comp.content_hash.to_le_bytes());
159            }
160        }
161
162        // Hash edges (sorted for determinism, including relationship and scope)
163        let mut edge_keys: Vec<_> = self
164            .edges
165            .iter()
166            .map(|edge| {
167                (
168                    edge.from.value(),
169                    edge.to.value(),
170                    edge.relationship.to_string(),
171                    edge.scope
172                        .as_ref()
173                        .map_or(String::new(), std::string::ToString::to_string),
174                )
175            })
176            .collect();
177        edge_keys.sort();
178        for (from, to, relationship, scope) in &edge_keys {
179            hasher_input.extend(from.as_bytes());
180            hasher_input.extend(to.as_bytes());
181            hasher_input.extend(relationship.as_bytes());
182            hasher_input.extend(scope.as_bytes());
183        }
184
185        // CDXA declarations (CycloneDX 1.6+): folded in only when present so
186        // documents without declarations hash identically to previous
187        // releases, while attestation-evidence changes stay visible to the
188        // diff's identical-SBOM short-circuit and the incremental cache key.
189        // Tagged + length-prefixed so the block cannot collide with the edge
190        // bytes above.
191        if let Some(declarations) = &self.extensions.declarations
192            && let Ok(json) = serde_json::to_vec(declarations)
193        {
194            hasher_input.extend(b"cdxa-declarations");
195            hasher_input.extend((json.len() as u64).to_le_bytes());
196            hasher_input.extend(json);
197        }
198
199        self.content_hash = xxh3_64(&hasher_input);
200    }
201
202    /// Get total component count
203    #[must_use]
204    pub fn component_count(&self) -> usize {
205        self.components.len()
206    }
207
208    /// CycloneDX 1.6 CDXA declarations parsed from the document, if any.
209    ///
210    /// Stored under [`FormatExtensions::declarations`]; `None` for SPDX,
211    /// pre-1.6 CycloneDX, XML input, and 1.6+ documents without the
212    /// `declarations`/`definitions.standards` sections. Signature objects in
213    /// the returned model record PRESENCE only — no cryptographic
214    /// verification is performed (see [`super::AttestationDeclarations`]).
215    #[must_use]
216    pub fn declarations(&self) -> Option<&super::AttestationDeclarations> {
217        self.extensions.declarations.as_ref()
218    }
219
220    /// Get the primary/root product component if set
221    #[must_use]
222    pub fn primary_component(&self) -> Option<&Component> {
223        self.primary_component_id
224            .as_ref()
225            .and_then(|id| self.components.get(id))
226    }
227
228    /// Set the primary component by its canonical ID
229    pub fn set_primary_component(&mut self, id: CanonicalId) {
230        self.primary_component_id = Some(id);
231    }
232
233    /// Get all unique ecosystems in the SBOM
234    pub fn ecosystems(&self) -> Vec<&Ecosystem> {
235        let mut ecosystems: Vec<_> = self
236            .components
237            .values()
238            .filter_map(|c| c.ecosystem.as_ref())
239            .collect();
240        ecosystems.sort_by_key(std::string::ToString::to_string);
241        ecosystems.dedup();
242        ecosystems
243    }
244
245    /// Get all vulnerabilities across all components
246    #[must_use]
247    pub fn all_vulnerabilities(&self) -> Vec<(&Component, &VulnerabilityRef)> {
248        self.components
249            .values()
250            .flat_map(|c| c.vulnerabilities.iter().map(move |v| (c, v)))
251            .collect()
252    }
253
254    /// Count vulnerabilities by severity
255    #[must_use]
256    pub fn vulnerability_counts(&self) -> VulnerabilityCounts {
257        let mut counts = VulnerabilityCounts::default();
258        for (_, vuln) in self.all_vulnerabilities() {
259            match vuln.severity {
260                Some(super::Severity::Critical) => counts.critical += 1,
261                Some(super::Severity::High) => counts.high += 1,
262                Some(super::Severity::Medium) => counts.medium += 1,
263                Some(super::Severity::Low) => counts.low += 1,
264                _ => counts.unknown += 1,
265            }
266        }
267        counts
268    }
269
270    /// Build an index for this SBOM.
271    ///
272    /// The index provides O(1) lookups for dependencies, dependents,
273    /// and name-based searches. Build once and reuse for multiple operations.
274    ///
275    /// # Example
276    ///
277    /// ```ignore
278    /// let sbom = parse_sbom(&path)?;
279    /// let index = sbom.build_index();
280    ///
281    /// // Fast dependency lookup
282    /// let deps = index.dependencies_of(&component_id, &sbom.edges);
283    /// ```
284    pub fn build_index(&self) -> super::NormalizedSbomIndex {
285        super::NormalizedSbomIndex::build(self)
286    }
287
288    /// Get dependencies using an index (O(k) instead of O(edges)).
289    ///
290    /// Use this when you have a prebuilt index for repeated lookups.
291    #[must_use]
292    pub fn get_dependencies_indexed<'a>(
293        &'a self,
294        id: &CanonicalId,
295        index: &super::NormalizedSbomIndex,
296    ) -> Vec<&'a DependencyEdge> {
297        index.dependencies_of(id, &self.edges)
298    }
299
300    /// Get dependents using an index (O(k) instead of O(edges)).
301    ///
302    /// Use this when you have a prebuilt index for repeated lookups.
303    #[must_use]
304    pub fn get_dependents_indexed<'a>(
305        &'a self,
306        id: &CanonicalId,
307        index: &super::NormalizedSbomIndex,
308    ) -> Vec<&'a DependencyEdge> {
309        index.dependents_of(id, &self.edges)
310    }
311
312    /// Find components by name (case-insensitive) using an index.
313    ///
314    /// Returns components whose lowercased name exactly matches the query.
315    #[must_use]
316    pub fn find_by_name_indexed(
317        &self,
318        name: &str,
319        index: &super::NormalizedSbomIndex,
320    ) -> Vec<&Component> {
321        let name_lower = name.to_lowercase();
322        index
323            .find_by_name_lower(&name_lower)
324            .iter()
325            .filter_map(|id| self.components.get(id))
326            .collect()
327    }
328
329    /// Search components by name (case-insensitive substring) using an index.
330    ///
331    /// Returns components whose name contains the query substring.
332    #[must_use]
333    pub fn search_by_name_indexed(
334        &self,
335        query: &str,
336        index: &super::NormalizedSbomIndex,
337    ) -> Vec<&Component> {
338        let query_lower = query.to_lowercase();
339        index
340            .search_by_name(&query_lower)
341            .iter()
342            .filter_map(|id| self.components.get(id))
343            .collect()
344    }
345
346    /// Apply CRA sidecar metadata to supplement SBOM fields.
347    ///
348    /// Sidecar values only override SBOM fields if the SBOM field is None/empty.
349    /// This ensures SBOM data takes precedence when available.
350    pub fn apply_cra_sidecar(&mut self, sidecar: &super::CraSidecarMetadata) {
351        // Only apply if SBOM doesn't already have the value
352        if self.document.security_contact.is_none() {
353            self.document
354                .security_contact
355                .clone_from(&sidecar.security_contact);
356        }
357
358        if self.document.vulnerability_disclosure_url.is_none() {
359            self.document
360                .vulnerability_disclosure_url
361                .clone_from(&sidecar.vulnerability_disclosure_url);
362        }
363
364        if self.document.support_end_date.is_none() {
365            self.document.support_end_date = sidecar.support_end_date;
366        }
367
368        if self.document.name.is_none() {
369            self.document.name.clone_from(&sidecar.product_name);
370        }
371
372        // Add manufacturer as creator if not present
373        if let Some(manufacturer) = &sidecar.manufacturer_name {
374            let has_org = self
375                .document
376                .creators
377                .iter()
378                .any(|c| c.creator_type == super::CreatorType::Organization);
379
380            if !has_org {
381                self.document.creators.push(super::Creator {
382                    creator_type: super::CreatorType::Organization,
383                    name: manufacturer.clone(),
384                    email: sidecar.manufacturer_email.clone(),
385                });
386            }
387        }
388    }
389}
390
391impl Default for NormalizedSbom {
392    fn default() -> Self {
393        Self::new(DocumentMetadata::default())
394    }
395}
396
397/// Vulnerability counts by severity
398#[derive(Debug, Clone, Default, Serialize, Deserialize)]
399pub struct VulnerabilityCounts {
400    pub critical: usize,
401    pub high: usize,
402    pub medium: usize,
403    pub low: usize,
404    pub unknown: usize,
405}
406
407impl VulnerabilityCounts {
408    #[must_use]
409    pub const fn total(&self) -> usize {
410        self.critical + self.high + self.medium + self.low + self.unknown
411    }
412}
413
414/// Staleness level classification for dependencies
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
416#[non_exhaustive]
417pub enum StalenessLevel {
418    /// Updated within 6 months
419    Fresh,
420    /// 6-12 months since last update
421    Aging,
422    /// 1-2 years since last update
423    Stale,
424    /// More than 2 years since last update
425    Abandoned,
426    /// Explicitly marked as deprecated
427    Deprecated,
428    /// Repository/package archived
429    Archived,
430}
431
432impl StalenessLevel {
433    /// Create from age in days
434    #[must_use]
435    pub const fn from_days(days: u32) -> Self {
436        match days {
437            0..=182 => Self::Fresh,   // ~6 months
438            183..=365 => Self::Aging, // 6-12 months
439            366..=730 => Self::Stale, // 1-2 years
440            _ => Self::Abandoned,     // >2 years
441        }
442    }
443
444    /// Get display label
445    #[must_use]
446    pub const fn label(&self) -> &'static str {
447        match self {
448            Self::Fresh => "Fresh",
449            Self::Aging => "Aging",
450            Self::Stale => "Stale",
451            Self::Abandoned => "Abandoned",
452            Self::Deprecated => "Deprecated",
453            Self::Archived => "Archived",
454        }
455    }
456
457    /// Get icon for TUI display
458    #[must_use]
459    pub const fn icon(&self) -> &'static str {
460        match self {
461            Self::Fresh => "✓",
462            Self::Aging => "⏳",
463            Self::Stale => "⚠",
464            Self::Abandoned => "⛔",
465            Self::Deprecated => "⊘",
466            Self::Archived => "📦",
467        }
468    }
469
470    /// Get severity weight (higher = worse)
471    #[must_use]
472    pub const fn severity(&self) -> u8 {
473        match self {
474            Self::Fresh => 0,
475            Self::Aging => 1,
476            Self::Stale => 2,
477            Self::Abandoned => 3,
478            Self::Deprecated | Self::Archived => 4,
479        }
480    }
481}
482
483impl std::fmt::Display for StalenessLevel {
484    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485        write!(f, "{}", self.label())
486    }
487}
488
489/// Staleness information for a component
490#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct StalenessInfo {
492    /// Staleness classification
493    pub level: StalenessLevel,
494    /// Last publish/release date
495    pub last_published: Option<chrono::DateTime<chrono::Utc>>,
496    /// Whether explicitly deprecated by maintainer
497    pub is_deprecated: bool,
498    /// Whether repository/package is archived
499    pub is_archived: bool,
500    /// Deprecation message if available
501    pub deprecation_message: Option<String>,
502    /// Days since last update
503    pub days_since_update: Option<u32>,
504    /// Latest available version (if different from current)
505    pub latest_version: Option<String>,
506}
507
508impl StalenessInfo {
509    /// Create new staleness info
510    #[must_use]
511    pub const fn new(level: StalenessLevel) -> Self {
512        Self {
513            level,
514            last_published: None,
515            is_deprecated: false,
516            is_archived: false,
517            deprecation_message: None,
518            days_since_update: None,
519            latest_version: None,
520        }
521    }
522
523    /// Create from last published date
524    #[must_use]
525    pub fn from_date(last_published: chrono::DateTime<chrono::Utc>) -> Self {
526        let days = (chrono::Utc::now() - last_published).num_days().max(0) as u32;
527        let level = StalenessLevel::from_days(days);
528        Self {
529            level,
530            last_published: Some(last_published),
531            is_deprecated: false,
532            is_archived: false,
533            deprecation_message: None,
534            days_since_update: Some(days),
535            latest_version: None,
536        }
537    }
538
539    /// Check if component needs attention (stale or worse)
540    #[must_use]
541    pub const fn needs_attention(&self) -> bool {
542        self.level.severity() >= 2
543    }
544}
545
546/// End-of-life status classification for components
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
548#[non_exhaustive]
549pub enum EolStatus {
550    /// Actively receiving updates
551    Supported,
552    /// Active support ended, security patches continue (LTS phase)
553    SecurityOnly,
554    /// Within 6 months of EOL date
555    ApproachingEol,
556    /// Past EOL, no more updates
557    EndOfLife,
558    /// Product found but cycle not matched
559    Unknown,
560}
561
562impl EolStatus {
563    /// Get display label
564    #[must_use]
565    pub const fn label(&self) -> &'static str {
566        match self {
567            Self::Supported => "Supported",
568            Self::SecurityOnly => "Security Only",
569            Self::ApproachingEol => "Approaching EOL",
570            Self::EndOfLife => "End of Life",
571            Self::Unknown => "Unknown",
572        }
573    }
574
575    /// Get icon for TUI display
576    #[must_use]
577    pub const fn icon(&self) -> &'static str {
578        match self {
579            Self::Supported => "✓",
580            Self::SecurityOnly => "🔒",
581            Self::ApproachingEol => "⚠",
582            Self::EndOfLife => "⛔",
583            Self::Unknown => "?",
584        }
585    }
586
587    /// Get severity weight (higher = worse)
588    #[must_use]
589    pub const fn severity(&self) -> u8 {
590        match self {
591            Self::Supported => 0,
592            Self::SecurityOnly => 1,
593            Self::ApproachingEol => 2,
594            Self::EndOfLife => 3,
595            Self::Unknown => 0,
596        }
597    }
598}
599
600impl std::fmt::Display for EolStatus {
601    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
602        write!(f, "{}", self.label())
603    }
604}
605
606/// End-of-life information for a component
607#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct EolInfo {
609    /// EOL status classification
610    pub status: EolStatus,
611    /// Matched endoflife.date product slug
612    pub product: String,
613    /// Matched release cycle (e.g., "3.11")
614    pub cycle: String,
615    /// EOL date if known
616    pub eol_date: Option<chrono::NaiveDate>,
617    /// Active support end date
618    pub support_end_date: Option<chrono::NaiveDate>,
619    /// Whether this is an LTS release
620    pub is_lts: bool,
621    /// Latest patch version in this cycle
622    pub latest_in_cycle: Option<String>,
623    /// Latest release date in this cycle
624    pub latest_release_date: Option<chrono::NaiveDate>,
625    /// Days until EOL (negative = past EOL)
626    pub days_until_eol: Option<i64>,
627}
628
629impl EolInfo {
630    /// Check if the component needs attention (approaching or past EOL)
631    #[must_use]
632    pub const fn needs_attention(&self) -> bool {
633        self.status.severity() >= 2
634    }
635}
636
637/// Component in the normalized SBOM
638#[derive(Debug, Clone, Serialize, Deserialize)]
639pub struct Component {
640    /// Canonical identifier
641    pub canonical_id: CanonicalId,
642    /// Various identifiers (PURL, CPE, etc.)
643    pub identifiers: ComponentIdentifiers,
644    /// Component name
645    pub name: String,
646    /// Version string
647    pub version: Option<String>,
648    /// Parsed semantic version (if valid)
649    pub semver: Option<semver::Version>,
650    /// Component type
651    pub component_type: ComponentType,
652    /// Package ecosystem
653    pub ecosystem: Option<Ecosystem>,
654    /// License information
655    pub licenses: LicenseInfo,
656    /// Supplier/vendor information
657    pub supplier: Option<Organization>,
658    /// Cryptographic hashes
659    pub hashes: Vec<Hash>,
660    /// External references
661    pub external_refs: Vec<ExternalReference>,
662    /// Known vulnerabilities
663    pub vulnerabilities: Vec<VulnerabilityRef>,
664    /// VEX status
665    pub vex_status: Option<VexStatus>,
666    /// Content hash for quick comparison
667    pub content_hash: u64,
668    /// Format-specific extensions
669    pub extensions: ComponentExtensions,
670    /// Description
671    pub description: Option<String>,
672    /// Copyright text
673    pub copyright: Option<String>,
674    /// Author information
675    pub author: Option<String>,
676    /// Group/namespace (e.g., Maven groupId)
677    pub group: Option<String>,
678    /// Whether this component is external (expected from environment, not bundled)
679    pub is_external: bool,
680    /// Package URL Version Range (vers) syntax, only valid when is_external is true
681    pub version_range: Option<String>,
682    /// Staleness information (populated by enrichment)
683    pub staleness: Option<StalenessInfo>,
684    /// End-of-life information (populated by enrichment)
685    pub eol: Option<EolInfo>,
686    /// ML model metadata (populated for MachineLearningModel components)
687    pub ml_model: Option<crate::model::MlModelInfo>,
688    /// Dataset metadata (populated for Data components)
689    pub dataset: Option<crate::model::DatasetInfo>,
690    /// Cryptographic properties (CycloneDX 1.6+ cryptoProperties)
691    #[serde(default, skip_serializing_if = "Option::is_none")]
692    pub crypto_properties: Option<CryptoProperties>,
693}
694
695impl Component {
696    /// Create a new component with minimal required fields
697    #[must_use]
698    pub fn new(name: String, format_id: String) -> Self {
699        let identifiers = ComponentIdentifiers::new(format_id);
700        let canonical_id = identifiers.canonical_id();
701
702        Self {
703            canonical_id,
704            identifiers,
705            name,
706            version: None,
707            semver: None,
708            component_type: ComponentType::Library,
709            ecosystem: None,
710            licenses: LicenseInfo::default(),
711            supplier: None,
712            hashes: Vec::new(),
713            external_refs: Vec::new(),
714            vulnerabilities: Vec::new(),
715            vex_status: None,
716            content_hash: 0,
717            extensions: ComponentExtensions::default(),
718            description: None,
719            copyright: None,
720            author: None,
721            group: None,
722            is_external: false,
723            version_range: None,
724            staleness: None,
725            eol: None,
726            ml_model: None,
727            dataset: None,
728            crypto_properties: None,
729        }
730    }
731
732    /// Set the PURL and update canonical ID
733    #[must_use]
734    pub fn with_purl(mut self, purl: String) -> Self {
735        self.set_purl(purl);
736        self
737    }
738
739    /// Set the PURL in place, refreshing the canonical ID and deriving the
740    /// ecosystem from the PURL type. Mirrors [`Self::with_purl`] for callers
741    /// holding a `&mut Component` (e.g. enrichment that synthesizes a PURL).
742    pub fn set_purl(&mut self, purl: String) {
743        self.identifiers.purl = Some(purl);
744        self.canonical_id = self.identifiers.canonical_id();
745
746        // Try to extract ecosystem from PURL
747        if let Some(purl_str) = &self.identifiers.purl
748            && let Some(purl_type) = purl_str
749                .strip_prefix("pkg:")
750                .and_then(|s| s.split('/').next())
751        {
752            self.ecosystem = Some(Ecosystem::from_purl_type(purl_type));
753        }
754    }
755
756    /// Set the version and try to parse as semver
757    #[must_use]
758    pub fn with_version(mut self, version: String) -> Self {
759        self.semver = semver::Version::parse(&version).ok();
760        self.version = Some(version);
761        self
762    }
763
764    /// Add a Software Heritage persistent identifier (SWHID) from a string.
765    ///
766    /// Invalid SWHIDs are silently dropped (matches the parser-tolerant
767    /// behaviour of `CanonicalId::from_swhid`). Use `with_swhid_object` when
768    /// you already have a `SwhidObject` in hand.
769    ///
770    /// Recognised by CRA prEN 40000-1-3 `[PRE-7-RQ-07]` as one of the three
771    /// named identifier types (alongside PURL and CPE). Multiple SWHIDs can
772    /// be attached to a single component (e.g., a `dir` SWHID for the
773    /// unpacked tree plus `cnt` SWHIDs for individual files).
774    #[must_use]
775    pub fn with_swhid(mut self, swhid: String) -> Self {
776        if let Ok(obj) = crate::model::SwhidObject::parse(&swhid) {
777            self.identifiers.swhid.push(obj);
778            self.canonical_id = self.identifiers.canonical_id();
779        }
780        self
781    }
782
783    /// Add a structured `SwhidObject` SWHID.
784    #[must_use]
785    pub fn with_swhid_object(mut self, swhid: crate::model::SwhidObject) -> Self {
786        self.identifiers.swhid.push(swhid);
787        self.canonical_id = self.identifiers.canonical_id();
788        self
789    }
790
791    /// Attach ML model metadata (CycloneDX modelCard)
792    #[must_use]
793    pub fn with_ml_model(mut self, ml_model: crate::model::MlModelInfo) -> Self {
794        self.ml_model = Some(ml_model);
795        self
796    }
797
798    /// Attach dataset metadata (CycloneDX ML BOM dataset component)
799    #[must_use]
800    pub fn with_dataset(mut self, dataset: crate::model::DatasetInfo) -> Self {
801        self.dataset = Some(dataset);
802        self
803    }
804
805    /// Append a tagged, length-prefixed value. Tags and length prefixes make
806    /// field boundaries unambiguous: raw concatenation let a dropped declared
807    /// license "MIT" plus a gained supplier named "MIT" produce the identical
808    /// byte stream — and therefore an identical content hash — silently
809    /// suppressing both changes from the diff.
810    fn extend_tagged(hasher_input: &mut Vec<u8>, tag: u8, bytes: &[u8]) {
811        hasher_input.push(tag);
812        hasher_input.extend((bytes.len() as u64).to_le_bytes());
813        hasher_input.extend(bytes);
814    }
815
816    fn extend_with_optional_str(hasher_input: &mut Vec<u8>, value: &Option<String>) {
817        match value {
818            Some(value) => {
819                hasher_input.push(1);
820                hasher_input.extend((value.len() as u64).to_le_bytes());
821                hasher_input.extend(value.as_bytes());
822            }
823            None => hasher_input.push(0),
824        }
825    }
826
827    fn extend_with_string_list(hasher_input: &mut Vec<u8>, values: &[String]) {
828        hasher_input.extend((values.len() as u64).to_le_bytes());
829        for value in values {
830            hasher_input.extend((value.len() as u64).to_le_bytes());
831            hasher_input.extend(value.as_bytes());
832        }
833    }
834
835    fn extend_with_optional_f64(hasher_input: &mut Vec<u8>, value: Option<f64>) {
836        match value {
837            Some(value) => {
838                let normalized = if value == 0.0 {
839                    0.0
840                } else if value.is_nan() {
841                    f64::from_bits(CANONICAL_NAN_BITS)
842                } else {
843                    value
844                };
845                hasher_input.push(1);
846                hasher_input.extend(normalized.to_bits().to_le_bytes());
847            }
848            None => hasher_input.push(0),
849        }
850    }
851
852    fn extend_with_ml_model(
853        hasher_input: &mut Vec<u8>,
854        ml_model: &Option<crate::model::MlModelInfo>,
855    ) {
856        if let Some(ml_model) = ml_model {
857            Self::extend_with_optional_str(hasher_input, &ml_model.approach);
858            Self::extend_with_optional_str(hasher_input, &ml_model.architecture_family);
859            Self::extend_with_optional_str(hasher_input, &ml_model.architecture_name);
860            Self::extend_with_optional_str(hasher_input, &ml_model.task);
861            Self::extend_with_optional_str(hasher_input, &ml_model.quantization);
862            Self::extend_with_optional_str(hasher_input, &ml_model.limitations);
863            Self::extend_with_optional_str(hasher_input, &ml_model.model_card_url);
864            Self::extend_with_optional_f64(hasher_input, ml_model.energy_kwh_training);
865
866            // Count-prefix each list: entries of both lists are three
867            // optional strings, so without framing a training dataset and a
868            // performance metric with the same payload are byte-identical
869            // and the two lists collide.
870            hasher_input.extend((ml_model.training_datasets.len() as u64).to_le_bytes());
871            for dataset in &ml_model.training_datasets {
872                Self::extend_with_optional_str(hasher_input, &dataset.reference);
873                Self::extend_with_optional_str(hasher_input, &dataset.name);
874                Self::extend_with_optional_str(hasher_input, &dataset.purl);
875            }
876            hasher_input.extend((ml_model.performance_metrics.len() as u64).to_le_bytes());
877            for metric in &ml_model.performance_metrics {
878                Self::extend_with_optional_str(hasher_input, &metric.metric_type);
879                Self::extend_with_optional_str(hasher_input, &metric.value);
880                Self::extend_with_optional_str(hasher_input, &metric.slice);
881            }
882        }
883    }
884
885    fn extend_with_dataset(
886        hasher_input: &mut Vec<u8>,
887        dataset: &Option<crate::model::DatasetInfo>,
888    ) {
889        if let Some(dataset) = dataset {
890            Self::extend_with_optional_str(hasher_input, &dataset.dataset_type);
891            Self::extend_with_string_list(hasher_input, &dataset.sensitivity_classifications);
892            Self::extend_with_string_list(hasher_input, &dataset.governance_owners);
893        }
894    }
895    /// Calculate and update content hash.
896    ///
897    /// Every field is tagged and length-prefixed (see `extend_tagged`) so a
898    /// value moving between fields always changes the hash. Coverage matters:
899    /// the diff's modified-component gate and the incremental cache key both
900    /// trust this hash, so any semantic field left out produces silently
901    /// empty or stale diffs (vulnerability severity and VEX status were
902    /// previously invisible here).
903    pub fn calculate_content_hash(&mut self) {
904        let mut hasher_input = Vec::new();
905
906        Self::extend_tagged(&mut hasher_input, 1, self.name.as_bytes());
907        hasher_input.push(2);
908        Self::extend_with_optional_str(&mut hasher_input, &self.version);
909        hasher_input.push(3);
910        Self::extend_with_optional_str(&mut hasher_input, &self.identifiers.purl);
911        if let Some(ecosystem) = &self.ecosystem {
912            Self::extend_tagged(&mut hasher_input, 4, ecosystem.to_string().as_bytes());
913        }
914        if let Some(group) = &self.group {
915            // Group/namespace is a fuzzy-matcher scoring input; leaving it
916            // out lets a group-only change slip past the identical-SBOM
917            // short-circuit and the incremental cache key.
918            Self::extend_tagged(&mut hasher_input, 15, group.as_bytes());
919        }
920        for license in &self.licenses.declared {
921            Self::extend_tagged(&mut hasher_input, 5, license.expression.as_bytes());
922        }
923        if let Some(supplier) = &self.supplier {
924            Self::extend_tagged(&mut hasher_input, 6, supplier.name.as_bytes());
925        }
926        for hash in &self.hashes {
927            Self::extend_tagged(&mut hasher_input, 7, hash.value.as_bytes());
928        }
929        for vuln in &self.vulnerabilities {
930            // Cover every VulnerabilityRef field that VulnerabilityDetail
931            // serializes: a field change invisible here short-circuits
932            // DiffEngine::diff as "identical" and collides incremental cache
933            // keys, serving stale details.
934            let mut vuln_buf = Vec::new();
935            vuln_buf.extend((vuln.id.len() as u64).to_le_bytes());
936            vuln_buf.extend(vuln.id.as_bytes());
937            Self::extend_with_optional_str(
938                &mut vuln_buf,
939                &vuln.severity.as_ref().map(std::string::ToString::to_string),
940            );
941            Self::extend_with_optional_str(
942                &mut vuln_buf,
943                &vuln.vex_status.as_ref().map(|v| format!("{v:?}")),
944            );
945            vuln_buf.push(u8::from(vuln.is_kev));
946            Self::extend_with_optional_f64(&mut vuln_buf, vuln.epss_score);
947            Self::extend_with_optional_f64(&mut vuln_buf, vuln.max_cvss_score().map(f64::from));
948            Self::extend_with_optional_str(&mut vuln_buf, &Some(vuln.source.to_string()));
949            Self::extend_with_string_list(&mut vuln_buf, &vuln.cwes);
950            Self::extend_with_optional_str(&mut vuln_buf, &vuln.description);
951            Self::extend_with_optional_str(
952                &mut vuln_buf,
953                &vuln.published.as_ref().map(|d| d.to_rfc3339()),
954            );
955            Self::extend_with_optional_str(
956                &mut vuln_buf,
957                &vuln.kev_info.as_ref().map(|k| k.due_date.to_rfc3339()),
958            );
959            Self::extend_with_optional_str(
960                &mut vuln_buf,
961                &vuln.remediation.as_ref().map(|r| {
962                    format!(
963                        "{}:{}",
964                        r.remediation_type,
965                        r.description.as_deref().unwrap_or("")
966                    )
967                }),
968            );
969            Self::extend_tagged(&mut hasher_input, 8, &vuln_buf);
970        }
971        if let Some(vex) = &self.vex_status {
972            Self::extend_tagged(&mut hasher_input, 9, format!("{vex:?}").as_bytes());
973        }
974        if self.is_external {
975            hasher_input.push(10);
976        }
977        if let Some(vr) = &self.version_range {
978            Self::extend_tagged(&mut hasher_input, 11, vr.as_bytes());
979        }
980        let mut ml_buf = Vec::new();
981        Self::extend_with_ml_model(&mut ml_buf, &self.ml_model);
982        if !ml_buf.is_empty() {
983            Self::extend_tagged(&mut hasher_input, 12, &ml_buf);
984        }
985        let mut dataset_buf = Vec::new();
986        Self::extend_with_dataset(&mut dataset_buf, &self.dataset);
987        if !dataset_buf.is_empty() {
988            Self::extend_tagged(&mut hasher_input, 13, &dataset_buf);
989        }
990
991        // Crypto properties: include fields that affect security semantics
992        if let Some(cp) = &self.crypto_properties {
993            let mut crypto_buf = Vec::new();
994            Self::extend_with_optional_str(&mut crypto_buf, &Some(cp.asset_type.to_string()));
995            Self::extend_with_optional_str(&mut crypto_buf, &cp.oid);
996            let (family, level, classical) =
997                cp.algorithm_properties
998                    .as_ref()
999                    .map_or((None, None, None), |a| {
1000                        (
1001                            a.algorithm_family.clone(),
1002                            a.nist_quantum_security_level,
1003                            a.classical_security_level,
1004                        )
1005                    });
1006            Self::extend_with_optional_str(&mut crypto_buf, &family);
1007            match level {
1008                Some(level) => {
1009                    crypto_buf.push(1);
1010                    crypto_buf.push(level);
1011                }
1012                None => crypto_buf.push(0),
1013            }
1014            // classical_security_level drives the crypto-downgrade detector;
1015            // protocol version drives TLS-version change reporting — both
1016            // must be hash-visible or those diffs are gated away.
1017            match classical {
1018                Some(bits) => {
1019                    crypto_buf.push(1);
1020                    crypto_buf.extend(bits.to_le_bytes());
1021                }
1022                None => crypto_buf.push(0),
1023            }
1024            Self::extend_with_optional_str(
1025                &mut crypto_buf,
1026                &cp.protocol_properties
1027                    .as_ref()
1028                    .and_then(|p| p.version.clone()),
1029            );
1030            Self::extend_with_optional_str(
1031                &mut crypto_buf,
1032                &cp.related_crypto_material_properties
1033                    .as_ref()
1034                    .and_then(|m| m.state.as_ref().map(std::string::ToString::to_string)),
1035            );
1036            Self::extend_with_optional_str(
1037                &mut crypto_buf,
1038                &cp.certificate_properties
1039                    .as_ref()
1040                    .and_then(|c| c.not_valid_after.as_ref().map(|e| e.to_rfc3339())),
1041            );
1042            Self::extend_tagged(&mut hasher_input, 14, &crypto_buf);
1043        }
1044
1045        self.content_hash = xxh3_64(&hasher_input);
1046    }
1047
1048    /// Check if this is an OSS (open source) component
1049    #[must_use]
1050    pub fn is_oss(&self) -> bool {
1051        // Check if any declared license is OSS
1052        self.licenses.declared.iter().any(|l| l.is_valid_spdx) || self.identifiers.purl.is_some()
1053    }
1054
1055    /// Get display name with version
1056    #[must_use]
1057    pub fn display_name(&self) -> String {
1058        self.version
1059            .as_ref()
1060            .map_or_else(|| self.name.clone(), |v| format!("{}@{}", self.name, v))
1061    }
1062}
1063
1064/// Dependency edge between components
1065#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1066pub struct DependencyEdge {
1067    /// Source component
1068    pub from: CanonicalId,
1069    /// Target component
1070    pub to: CanonicalId,
1071    /// Relationship type
1072    pub relationship: DependencyType,
1073    /// Dependency scope
1074    pub scope: Option<DependencyScope>,
1075}
1076
1077impl DependencyEdge {
1078    /// Create a new dependency edge
1079    #[must_use]
1080    pub const fn new(from: CanonicalId, to: CanonicalId, relationship: DependencyType) -> Self {
1081        Self {
1082            from,
1083            to,
1084            relationship,
1085            scope: None,
1086        }
1087    }
1088
1089    /// Set the dependency scope
1090    #[must_use]
1091    pub const fn with_scope(mut self, scope: DependencyScope) -> Self {
1092        self.scope = Some(scope);
1093        self
1094    }
1095
1096    /// Check if this is a direct dependency
1097    #[must_use]
1098    pub const fn is_direct(&self) -> bool {
1099        matches!(
1100            self.relationship,
1101            DependencyType::DependsOn
1102                | DependencyType::DevDependsOn
1103                | DependencyType::BuildDependsOn
1104                | DependencyType::TestDependsOn
1105                | DependencyType::RuntimeDependsOn
1106        )
1107    }
1108}
1109
1110#[cfg(test)]
1111mod tests {
1112    use super::*;
1113    use crate::model::MlModelInfo;
1114
1115    #[test]
1116    fn test_content_hash_normalizes_ml_energy_zero_and_nan() {
1117        let mut positive_zero = Component::new("model".to_string(), "model@1".to_string());
1118        positive_zero.ml_model = Some(MlModelInfo {
1119            energy_kwh_training: Some(0.0),
1120            ..MlModelInfo::default()
1121        });
1122        positive_zero.calculate_content_hash();
1123
1124        let mut negative_zero = Component::new("model".to_string(), "model@1".to_string());
1125        negative_zero.ml_model = Some(MlModelInfo {
1126            energy_kwh_training: Some(-0.0),
1127            ..MlModelInfo::default()
1128        });
1129        negative_zero.calculate_content_hash();
1130
1131        let mut nan_a = Component::new("model".to_string(), "model@1".to_string());
1132        nan_a.ml_model = Some(MlModelInfo {
1133            energy_kwh_training: Some(f64::NAN),
1134            ..MlModelInfo::default()
1135        });
1136        nan_a.calculate_content_hash();
1137
1138        let mut nan_b = Component::new("model".to_string(), "model@1".to_string());
1139        nan_b.ml_model = Some(MlModelInfo {
1140            energy_kwh_training: Some(f64::from_bits(CANONICAL_NAN_BITS + 1)),
1141            ..MlModelInfo::default()
1142        });
1143        nan_b.calculate_content_hash();
1144
1145        assert_eq!(positive_zero.content_hash, negative_zero.content_hash);
1146        assert_eq!(nan_a.content_hash, nan_b.content_hash);
1147    }
1148}