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
12/// Normalized SBOM document - the canonical intermediate representation.
13///
14/// This structure represents an SBOM in a format-agnostic way, allowing
15/// comparison between `CycloneDX` and SPDX documents.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct NormalizedSbom {
18    /// Document-level metadata
19    pub document: DocumentMetadata,
20    /// Components indexed by canonical ID
21    pub components: IndexMap<CanonicalId, Component>,
22    /// Dependency edges
23    pub edges: Vec<DependencyEdge>,
24    /// Format-specific extensions
25    pub extensions: FormatExtensions,
26    /// Content hash for quick equality checks
27    pub content_hash: u64,
28    /// Primary/root product component (`CycloneDX` metadata.component or SPDX documentDescribes)
29    /// This identifies the main product that this SBOM describes, important for CRA compliance.
30    pub primary_component_id: Option<CanonicalId>,
31    /// Number of canonical ID collisions encountered during parsing
32    #[serde(skip)]
33    pub collision_count: usize,
34}
35
36impl NormalizedSbom {
37    /// Create a new empty normalized SBOM
38    #[must_use]
39    pub fn new(document: DocumentMetadata) -> Self {
40        Self {
41            document,
42            components: IndexMap::new(),
43            edges: Vec::new(),
44            extensions: FormatExtensions::default(),
45            content_hash: 0,
46            primary_component_id: None,
47            collision_count: 0,
48        }
49    }
50
51    /// Return the canonical IDs of *direct* dependencies (1 hop from the
52    /// primary component along the dependency graph).
53    ///
54    /// When no `primary_component_id` is set, all components reachable from
55    /// any node with no incoming edges are treated as direct (best-effort
56    /// approximation for SBOMs that don't declare a root).
57    ///
58    /// Used by CRA prEN 40000-1-3 `[PRE-7-RQ-03]` enforcement, which makes
59    /// direct dependencies *mandatory* and transitive *recommended*.
60    #[must_use]
61    pub fn direct_dependency_ids(&self) -> std::collections::HashSet<CanonicalId> {
62        use std::collections::HashSet;
63        if let Some(root) = &self.primary_component_id {
64            return self
65                .edges
66                .iter()
67                .filter(|e| &e.from == root)
68                .map(|e| e.to.clone())
69                .collect();
70        }
71        // Fallback: find roots = nodes with no incoming edges, then take their direct children.
72        let incoming: HashSet<&CanonicalId> = self.edges.iter().map(|e| &e.to).collect();
73        let roots: HashSet<&CanonicalId> = self
74            .components
75            .keys()
76            .filter(|id| !incoming.contains(id))
77            .collect();
78        self.edges
79            .iter()
80            .filter(|e| roots.contains(&e.from))
81            .map(|e| e.to.clone())
82            .collect()
83    }
84
85    /// Add a component to the SBOM.
86    ///
87    /// Returns `true` if a collision occurred (a component with the same canonical ID
88    /// was already present and has been overwritten). Collisions are logged as warnings.
89    pub fn add_component(&mut self, component: Component) -> bool {
90        let id = component.canonical_id.clone();
91        if let Some(existing) = self.components.get(&id) {
92            // Count genuinely different components that collide on canonical ID
93            if existing.identifiers.format_id != component.identifiers.format_id
94                || existing.name != component.name
95            {
96                self.collision_count += 1;
97            }
98            self.components.insert(id, component);
99            true
100        } else {
101            self.components.insert(id, component);
102            false
103        }
104    }
105
106    /// Log a single summary line if any canonical ID collisions occurred during parsing.
107    pub fn log_collision_summary(&self) {
108        if self.collision_count > 0 {
109            tracing::info!(
110                collision_count = self.collision_count,
111                "Canonical ID collisions: {} distinct components resolved to the same ID \
112                 and were overwritten. Consider adding PURL identifiers to disambiguate.",
113                self.collision_count
114            );
115        }
116    }
117
118    /// Add a dependency edge
119    pub fn add_edge(&mut self, edge: DependencyEdge) {
120        self.edges.push(edge);
121    }
122
123    /// Get a component by canonical ID
124    #[must_use]
125    pub fn get_component(&self, id: &CanonicalId) -> Option<&Component> {
126        self.components.get(id)
127    }
128
129    /// Get dependencies of a component
130    #[must_use]
131    pub fn get_dependencies(&self, id: &CanonicalId) -> Vec<&DependencyEdge> {
132        self.edges.iter().filter(|e| &e.from == id).collect()
133    }
134
135    /// Get dependents of a component
136    #[must_use]
137    pub fn get_dependents(&self, id: &CanonicalId) -> Vec<&DependencyEdge> {
138        self.edges.iter().filter(|e| &e.to == id).collect()
139    }
140
141    /// Calculate and update the content hash
142    pub fn calculate_content_hash(&mut self) {
143        let mut hasher_input = Vec::new();
144
145        // Hash document metadata
146        if let Ok(meta_json) = serde_json::to_vec(&self.document) {
147            hasher_input.extend(meta_json);
148        }
149
150        // Hash all components (sorted for determinism)
151        let mut component_ids: Vec<_> = self.components.keys().collect();
152        component_ids.sort_by(|a, b| a.value().cmp(b.value()));
153
154        for id in component_ids {
155            if let Some(comp) = self.components.get(id) {
156                hasher_input.extend(comp.content_hash.to_le_bytes());
157            }
158        }
159
160        // Hash edges (sorted for determinism, including relationship and scope)
161        let mut edge_keys: Vec<_> = self
162            .edges
163            .iter()
164            .map(|edge| {
165                (
166                    edge.from.value(),
167                    edge.to.value(),
168                    edge.relationship.to_string(),
169                    edge.scope
170                        .as_ref()
171                        .map_or(String::new(), std::string::ToString::to_string),
172                )
173            })
174            .collect();
175        edge_keys.sort();
176        for (from, to, relationship, scope) in &edge_keys {
177            hasher_input.extend(from.as_bytes());
178            hasher_input.extend(to.as_bytes());
179            hasher_input.extend(relationship.as_bytes());
180            hasher_input.extend(scope.as_bytes());
181        }
182
183        self.content_hash = xxh3_64(&hasher_input);
184    }
185
186    /// Get total component count
187    #[must_use]
188    pub fn component_count(&self) -> usize {
189        self.components.len()
190    }
191
192    /// Get the primary/root product component if set
193    #[must_use]
194    pub fn primary_component(&self) -> Option<&Component> {
195        self.primary_component_id
196            .as_ref()
197            .and_then(|id| self.components.get(id))
198    }
199
200    /// Set the primary component by its canonical ID
201    pub fn set_primary_component(&mut self, id: CanonicalId) {
202        self.primary_component_id = Some(id);
203    }
204
205    /// Get all unique ecosystems in the SBOM
206    pub fn ecosystems(&self) -> Vec<&Ecosystem> {
207        let mut ecosystems: Vec<_> = self
208            .components
209            .values()
210            .filter_map(|c| c.ecosystem.as_ref())
211            .collect();
212        ecosystems.sort_by_key(std::string::ToString::to_string);
213        ecosystems.dedup();
214        ecosystems
215    }
216
217    /// Get all vulnerabilities across all components
218    #[must_use]
219    pub fn all_vulnerabilities(&self) -> Vec<(&Component, &VulnerabilityRef)> {
220        self.components
221            .values()
222            .flat_map(|c| c.vulnerabilities.iter().map(move |v| (c, v)))
223            .collect()
224    }
225
226    /// Count vulnerabilities by severity
227    #[must_use]
228    pub fn vulnerability_counts(&self) -> VulnerabilityCounts {
229        let mut counts = VulnerabilityCounts::default();
230        for (_, vuln) in self.all_vulnerabilities() {
231            match vuln.severity {
232                Some(super::Severity::Critical) => counts.critical += 1,
233                Some(super::Severity::High) => counts.high += 1,
234                Some(super::Severity::Medium) => counts.medium += 1,
235                Some(super::Severity::Low) => counts.low += 1,
236                _ => counts.unknown += 1,
237            }
238        }
239        counts
240    }
241
242    /// Build an index for this SBOM.
243    ///
244    /// The index provides O(1) lookups for dependencies, dependents,
245    /// and name-based searches. Build once and reuse for multiple operations.
246    ///
247    /// # Example
248    ///
249    /// ```ignore
250    /// let sbom = parse_sbom(&path)?;
251    /// let index = sbom.build_index();
252    ///
253    /// // Fast dependency lookup
254    /// let deps = index.dependencies_of(&component_id, &sbom.edges);
255    /// ```
256    pub fn build_index(&self) -> super::NormalizedSbomIndex {
257        super::NormalizedSbomIndex::build(self)
258    }
259
260    /// Get dependencies using an index (O(k) instead of O(edges)).
261    ///
262    /// Use this when you have a prebuilt index for repeated lookups.
263    #[must_use]
264    pub fn get_dependencies_indexed<'a>(
265        &'a self,
266        id: &CanonicalId,
267        index: &super::NormalizedSbomIndex,
268    ) -> Vec<&'a DependencyEdge> {
269        index.dependencies_of(id, &self.edges)
270    }
271
272    /// Get dependents using an index (O(k) instead of O(edges)).
273    ///
274    /// Use this when you have a prebuilt index for repeated lookups.
275    #[must_use]
276    pub fn get_dependents_indexed<'a>(
277        &'a self,
278        id: &CanonicalId,
279        index: &super::NormalizedSbomIndex,
280    ) -> Vec<&'a DependencyEdge> {
281        index.dependents_of(id, &self.edges)
282    }
283
284    /// Find components by name (case-insensitive) using an index.
285    ///
286    /// Returns components whose lowercased name exactly matches the query.
287    #[must_use]
288    pub fn find_by_name_indexed(
289        &self,
290        name: &str,
291        index: &super::NormalizedSbomIndex,
292    ) -> Vec<&Component> {
293        let name_lower = name.to_lowercase();
294        index
295            .find_by_name_lower(&name_lower)
296            .iter()
297            .filter_map(|id| self.components.get(id))
298            .collect()
299    }
300
301    /// Search components by name (case-insensitive substring) using an index.
302    ///
303    /// Returns components whose name contains the query substring.
304    #[must_use]
305    pub fn search_by_name_indexed(
306        &self,
307        query: &str,
308        index: &super::NormalizedSbomIndex,
309    ) -> Vec<&Component> {
310        let query_lower = query.to_lowercase();
311        index
312            .search_by_name(&query_lower)
313            .iter()
314            .filter_map(|id| self.components.get(id))
315            .collect()
316    }
317
318    /// Apply CRA sidecar metadata to supplement SBOM fields.
319    ///
320    /// Sidecar values only override SBOM fields if the SBOM field is None/empty.
321    /// This ensures SBOM data takes precedence when available.
322    pub fn apply_cra_sidecar(&mut self, sidecar: &super::CraSidecarMetadata) {
323        // Only apply if SBOM doesn't already have the value
324        if self.document.security_contact.is_none() {
325            self.document
326                .security_contact
327                .clone_from(&sidecar.security_contact);
328        }
329
330        if self.document.vulnerability_disclosure_url.is_none() {
331            self.document
332                .vulnerability_disclosure_url
333                .clone_from(&sidecar.vulnerability_disclosure_url);
334        }
335
336        if self.document.support_end_date.is_none() {
337            self.document.support_end_date = sidecar.support_end_date;
338        }
339
340        if self.document.name.is_none() {
341            self.document.name.clone_from(&sidecar.product_name);
342        }
343
344        // Add manufacturer as creator if not present
345        if let Some(manufacturer) = &sidecar.manufacturer_name {
346            let has_org = self
347                .document
348                .creators
349                .iter()
350                .any(|c| c.creator_type == super::CreatorType::Organization);
351
352            if !has_org {
353                self.document.creators.push(super::Creator {
354                    creator_type: super::CreatorType::Organization,
355                    name: manufacturer.clone(),
356                    email: sidecar.manufacturer_email.clone(),
357                });
358            }
359        }
360    }
361}
362
363impl Default for NormalizedSbom {
364    fn default() -> Self {
365        Self::new(DocumentMetadata::default())
366    }
367}
368
369/// Vulnerability counts by severity
370#[derive(Debug, Clone, Default, Serialize, Deserialize)]
371pub struct VulnerabilityCounts {
372    pub critical: usize,
373    pub high: usize,
374    pub medium: usize,
375    pub low: usize,
376    pub unknown: usize,
377}
378
379impl VulnerabilityCounts {
380    #[must_use]
381    pub const fn total(&self) -> usize {
382        self.critical + self.high + self.medium + self.low + self.unknown
383    }
384}
385
386/// Staleness level classification for dependencies
387#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
388#[non_exhaustive]
389pub enum StalenessLevel {
390    /// Updated within 6 months
391    Fresh,
392    /// 6-12 months since last update
393    Aging,
394    /// 1-2 years since last update
395    Stale,
396    /// More than 2 years since last update
397    Abandoned,
398    /// Explicitly marked as deprecated
399    Deprecated,
400    /// Repository/package archived
401    Archived,
402}
403
404impl StalenessLevel {
405    /// Create from age in days
406    #[must_use]
407    pub const fn from_days(days: u32) -> Self {
408        match days {
409            0..=182 => Self::Fresh,   // ~6 months
410            183..=365 => Self::Aging, // 6-12 months
411            366..=730 => Self::Stale, // 1-2 years
412            _ => Self::Abandoned,     // >2 years
413        }
414    }
415
416    /// Get display label
417    #[must_use]
418    pub const fn label(&self) -> &'static str {
419        match self {
420            Self::Fresh => "Fresh",
421            Self::Aging => "Aging",
422            Self::Stale => "Stale",
423            Self::Abandoned => "Abandoned",
424            Self::Deprecated => "Deprecated",
425            Self::Archived => "Archived",
426        }
427    }
428
429    /// Get icon for TUI display
430    #[must_use]
431    pub const fn icon(&self) -> &'static str {
432        match self {
433            Self::Fresh => "✓",
434            Self::Aging => "⏳",
435            Self::Stale => "⚠",
436            Self::Abandoned => "⛔",
437            Self::Deprecated => "⊘",
438            Self::Archived => "📦",
439        }
440    }
441
442    /// Get severity weight (higher = worse)
443    #[must_use]
444    pub const fn severity(&self) -> u8 {
445        match self {
446            Self::Fresh => 0,
447            Self::Aging => 1,
448            Self::Stale => 2,
449            Self::Abandoned => 3,
450            Self::Deprecated | Self::Archived => 4,
451        }
452    }
453}
454
455impl std::fmt::Display for StalenessLevel {
456    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
457        write!(f, "{}", self.label())
458    }
459}
460
461/// Staleness information for a component
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct StalenessInfo {
464    /// Staleness classification
465    pub level: StalenessLevel,
466    /// Last publish/release date
467    pub last_published: Option<chrono::DateTime<chrono::Utc>>,
468    /// Whether explicitly deprecated by maintainer
469    pub is_deprecated: bool,
470    /// Whether repository/package is archived
471    pub is_archived: bool,
472    /// Deprecation message if available
473    pub deprecation_message: Option<String>,
474    /// Days since last update
475    pub days_since_update: Option<u32>,
476    /// Latest available version (if different from current)
477    pub latest_version: Option<String>,
478}
479
480impl StalenessInfo {
481    /// Create new staleness info
482    #[must_use]
483    pub const fn new(level: StalenessLevel) -> Self {
484        Self {
485            level,
486            last_published: None,
487            is_deprecated: false,
488            is_archived: false,
489            deprecation_message: None,
490            days_since_update: None,
491            latest_version: None,
492        }
493    }
494
495    /// Create from last published date
496    #[must_use]
497    pub fn from_date(last_published: chrono::DateTime<chrono::Utc>) -> Self {
498        let days = (chrono::Utc::now() - last_published).num_days().max(0) as u32;
499        let level = StalenessLevel::from_days(days);
500        Self {
501            level,
502            last_published: Some(last_published),
503            is_deprecated: false,
504            is_archived: false,
505            deprecation_message: None,
506            days_since_update: Some(days),
507            latest_version: None,
508        }
509    }
510
511    /// Check if component needs attention (stale or worse)
512    #[must_use]
513    pub const fn needs_attention(&self) -> bool {
514        self.level.severity() >= 2
515    }
516}
517
518/// End-of-life status classification for components
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
520#[non_exhaustive]
521pub enum EolStatus {
522    /// Actively receiving updates
523    Supported,
524    /// Active support ended, security patches continue (LTS phase)
525    SecurityOnly,
526    /// Within 6 months of EOL date
527    ApproachingEol,
528    /// Past EOL, no more updates
529    EndOfLife,
530    /// Product found but cycle not matched
531    Unknown,
532}
533
534impl EolStatus {
535    /// Get display label
536    #[must_use]
537    pub const fn label(&self) -> &'static str {
538        match self {
539            Self::Supported => "Supported",
540            Self::SecurityOnly => "Security Only",
541            Self::ApproachingEol => "Approaching EOL",
542            Self::EndOfLife => "End of Life",
543            Self::Unknown => "Unknown",
544        }
545    }
546
547    /// Get icon for TUI display
548    #[must_use]
549    pub const fn icon(&self) -> &'static str {
550        match self {
551            Self::Supported => "✓",
552            Self::SecurityOnly => "🔒",
553            Self::ApproachingEol => "⚠",
554            Self::EndOfLife => "⛔",
555            Self::Unknown => "?",
556        }
557    }
558
559    /// Get severity weight (higher = worse)
560    #[must_use]
561    pub const fn severity(&self) -> u8 {
562        match self {
563            Self::Supported => 0,
564            Self::SecurityOnly => 1,
565            Self::ApproachingEol => 2,
566            Self::EndOfLife => 3,
567            Self::Unknown => 0,
568        }
569    }
570}
571
572impl std::fmt::Display for EolStatus {
573    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
574        write!(f, "{}", self.label())
575    }
576}
577
578/// End-of-life information for a component
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct EolInfo {
581    /// EOL status classification
582    pub status: EolStatus,
583    /// Matched endoflife.date product slug
584    pub product: String,
585    /// Matched release cycle (e.g., "3.11")
586    pub cycle: String,
587    /// EOL date if known
588    pub eol_date: Option<chrono::NaiveDate>,
589    /// Active support end date
590    pub support_end_date: Option<chrono::NaiveDate>,
591    /// Whether this is an LTS release
592    pub is_lts: bool,
593    /// Latest patch version in this cycle
594    pub latest_in_cycle: Option<String>,
595    /// Latest release date in this cycle
596    pub latest_release_date: Option<chrono::NaiveDate>,
597    /// Days until EOL (negative = past EOL)
598    pub days_until_eol: Option<i64>,
599}
600
601impl EolInfo {
602    /// Check if the component needs attention (approaching or past EOL)
603    #[must_use]
604    pub const fn needs_attention(&self) -> bool {
605        self.status.severity() >= 2
606    }
607}
608
609/// Component in the normalized SBOM
610#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct Component {
612    /// Canonical identifier
613    pub canonical_id: CanonicalId,
614    /// Various identifiers (PURL, CPE, etc.)
615    pub identifiers: ComponentIdentifiers,
616    /// Component name
617    pub name: String,
618    /// Version string
619    pub version: Option<String>,
620    /// Parsed semantic version (if valid)
621    pub semver: Option<semver::Version>,
622    /// Component type
623    pub component_type: ComponentType,
624    /// Package ecosystem
625    pub ecosystem: Option<Ecosystem>,
626    /// License information
627    pub licenses: LicenseInfo,
628    /// Supplier/vendor information
629    pub supplier: Option<Organization>,
630    /// Cryptographic hashes
631    pub hashes: Vec<Hash>,
632    /// External references
633    pub external_refs: Vec<ExternalReference>,
634    /// Known vulnerabilities
635    pub vulnerabilities: Vec<VulnerabilityRef>,
636    /// VEX status
637    pub vex_status: Option<VexStatus>,
638    /// Content hash for quick comparison
639    pub content_hash: u64,
640    /// Format-specific extensions
641    pub extensions: ComponentExtensions,
642    /// Description
643    pub description: Option<String>,
644    /// Copyright text
645    pub copyright: Option<String>,
646    /// Author information
647    pub author: Option<String>,
648    /// Group/namespace (e.g., Maven groupId)
649    pub group: Option<String>,
650    /// Whether this component is external (expected from environment, not bundled)
651    pub is_external: bool,
652    /// Package URL Version Range (vers) syntax, only valid when is_external is true
653    pub version_range: Option<String>,
654    /// Staleness information (populated by enrichment)
655    pub staleness: Option<StalenessInfo>,
656    /// End-of-life information (populated by enrichment)
657    pub eol: Option<EolInfo>,
658    /// Cryptographic properties (CycloneDX 1.6+ cryptoProperties)
659    #[serde(default, skip_serializing_if = "Option::is_none")]
660    pub crypto_properties: Option<CryptoProperties>,
661}
662
663impl Component {
664    /// Create a new component with minimal required fields
665    #[must_use]
666    pub fn new(name: String, format_id: String) -> Self {
667        let identifiers = ComponentIdentifiers::new(format_id);
668        let canonical_id = identifiers.canonical_id();
669
670        Self {
671            canonical_id,
672            identifiers,
673            name,
674            version: None,
675            semver: None,
676            component_type: ComponentType::Library,
677            ecosystem: None,
678            licenses: LicenseInfo::default(),
679            supplier: None,
680            hashes: Vec::new(),
681            external_refs: Vec::new(),
682            vulnerabilities: Vec::new(),
683            vex_status: None,
684            content_hash: 0,
685            extensions: ComponentExtensions::default(),
686            description: None,
687            copyright: None,
688            author: None,
689            group: None,
690            is_external: false,
691            version_range: None,
692            staleness: None,
693            eol: None,
694            crypto_properties: None,
695        }
696    }
697
698    /// Set the PURL and update canonical ID
699    #[must_use]
700    pub fn with_purl(mut self, purl: String) -> Self {
701        self.identifiers.purl = Some(purl);
702        self.canonical_id = self.identifiers.canonical_id();
703
704        // Try to extract ecosystem from PURL
705        if let Some(purl_str) = &self.identifiers.purl
706            && let Some(purl_type) = purl_str
707                .strip_prefix("pkg:")
708                .and_then(|s| s.split('/').next())
709        {
710            self.ecosystem = Some(Ecosystem::from_purl_type(purl_type));
711        }
712
713        self
714    }
715
716    /// Set the version and try to parse as semver
717    #[must_use]
718    pub fn with_version(mut self, version: String) -> Self {
719        self.semver = semver::Version::parse(&version).ok();
720        self.version = Some(version);
721        self
722    }
723
724    /// Add a Software Heritage persistent identifier (SWHID) from a string.
725    ///
726    /// Invalid SWHIDs are silently dropped (matches the parser-tolerant
727    /// behaviour of `CanonicalId::from_swhid`). Use `with_swhid_object` when
728    /// you already have a `SwhidObject` in hand.
729    ///
730    /// Recognised by CRA prEN 40000-1-3 `[PRE-7-RQ-07]` as one of the three
731    /// named identifier types (alongside PURL and CPE). Multiple SWHIDs can
732    /// be attached to a single component (e.g., a `dir` SWHID for the
733    /// unpacked tree plus `cnt` SWHIDs for individual files).
734    #[must_use]
735    pub fn with_swhid(mut self, swhid: String) -> Self {
736        if let Ok(obj) = crate::model::SwhidObject::parse(&swhid) {
737            self.identifiers.swhid.push(obj);
738            // Recompute canonical ID — SWHID may now be the best fallback
739            self.canonical_id = self.identifiers.canonical_id();
740        }
741        self
742    }
743
744    /// Add a structured `SwhidObject` SWHID.
745    #[must_use]
746    pub fn with_swhid_object(mut self, swhid: crate::model::SwhidObject) -> Self {
747        self.identifiers.swhid.push(swhid);
748        self.canonical_id = self.identifiers.canonical_id();
749        self
750    }
751
752    /// Calculate and update content hash
753    pub fn calculate_content_hash(&mut self) {
754        let mut hasher_input = Vec::new();
755
756        hasher_input.extend(self.name.as_bytes());
757        if let Some(v) = &self.version {
758            hasher_input.extend(v.as_bytes());
759        }
760        if let Some(purl) = &self.identifiers.purl {
761            hasher_input.extend(purl.as_bytes());
762        }
763        for license in &self.licenses.declared {
764            hasher_input.extend(license.expression.as_bytes());
765        }
766        if let Some(supplier) = &self.supplier {
767            hasher_input.extend(supplier.name.as_bytes());
768        }
769        for hash in &self.hashes {
770            hasher_input.extend(hash.value.as_bytes());
771        }
772        for vuln in &self.vulnerabilities {
773            hasher_input.extend(vuln.id.as_bytes());
774        }
775        if self.is_external {
776            hasher_input.push(b'E');
777        }
778        if let Some(vr) = &self.version_range {
779            hasher_input.extend(vr.as_bytes());
780        }
781        // Crypto properties: include fields that affect security semantics
782        if let Some(cp) = &self.crypto_properties {
783            hasher_input.extend(cp.asset_type.to_string().as_bytes());
784            if let Some(oid) = &cp.oid {
785                hasher_input.extend(oid.as_bytes());
786            }
787            if let Some(algo) = &cp.algorithm_properties {
788                if let Some(family) = &algo.algorithm_family {
789                    hasher_input.extend(family.as_bytes());
790                }
791                if let Some(level) = algo.nist_quantum_security_level {
792                    hasher_input.push(level);
793                }
794            }
795            if let Some(mat) = &cp.related_crypto_material_properties
796                && let Some(state) = &mat.state
797            {
798                hasher_input.extend(state.to_string().as_bytes());
799            }
800            if let Some(cert) = &cp.certificate_properties
801                && let Some(expiry) = &cert.not_valid_after
802            {
803                hasher_input.extend(expiry.to_rfc3339().as_bytes());
804            }
805        }
806
807        self.content_hash = xxh3_64(&hasher_input);
808    }
809
810    /// Check if this is an OSS (open source) component
811    #[must_use]
812    pub fn is_oss(&self) -> bool {
813        // Check if any declared license is OSS
814        self.licenses.declared.iter().any(|l| l.is_valid_spdx) || self.identifiers.purl.is_some()
815    }
816
817    /// Get display name with version
818    #[must_use]
819    pub fn display_name(&self) -> String {
820        self.version
821            .as_ref()
822            .map_or_else(|| self.name.clone(), |v| format!("{}@{}", self.name, v))
823    }
824}
825
826/// Dependency edge between components
827#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
828pub struct DependencyEdge {
829    /// Source component
830    pub from: CanonicalId,
831    /// Target component
832    pub to: CanonicalId,
833    /// Relationship type
834    pub relationship: DependencyType,
835    /// Dependency scope
836    pub scope: Option<DependencyScope>,
837}
838
839impl DependencyEdge {
840    /// Create a new dependency edge
841    #[must_use]
842    pub const fn new(from: CanonicalId, to: CanonicalId, relationship: DependencyType) -> Self {
843        Self {
844            from,
845            to,
846            relationship,
847            scope: None,
848        }
849    }
850
851    /// Set the dependency scope
852    #[must_use]
853    pub const fn with_scope(mut self, scope: DependencyScope) -> Self {
854        self.scope = Some(scope);
855        self
856    }
857
858    /// Check if this is a direct dependency
859    #[must_use]
860    pub const fn is_direct(&self) -> bool {
861        matches!(
862            self.relationship,
863            DependencyType::DependsOn
864                | DependencyType::DevDependsOn
865                | DependencyType::BuildDependsOn
866                | DependencyType::TestDependsOn
867                | DependencyType::RuntimeDependsOn
868        )
869    }
870}