1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct NormalizedSbom {
18 pub document: DocumentMetadata,
20 pub components: IndexMap<CanonicalId, Component>,
22 pub edges: Vec<DependencyEdge>,
24 pub extensions: FormatExtensions,
26 pub content_hash: u64,
28 pub primary_component_id: Option<CanonicalId>,
31 #[serde(skip)]
33 pub collision_count: usize,
34}
35
36impl NormalizedSbom {
37 #[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 #[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 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 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 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 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 pub fn add_edge(&mut self, edge: DependencyEdge) {
120 self.edges.push(edge);
121 }
122
123 #[must_use]
125 pub fn get_component(&self, id: &CanonicalId) -> Option<&Component> {
126 self.components.get(id)
127 }
128
129 #[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 #[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 pub fn calculate_content_hash(&mut self) {
143 let mut hasher_input = Vec::new();
144
145 if let Ok(meta_json) = serde_json::to_vec(&self.document) {
147 hasher_input.extend(meta_json);
148 }
149
150 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 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 #[must_use]
188 pub fn component_count(&self) -> usize {
189 self.components.len()
190 }
191
192 #[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 pub fn set_primary_component(&mut self, id: CanonicalId) {
202 self.primary_component_id = Some(id);
203 }
204
205 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 #[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 #[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 pub fn build_index(&self) -> super::NormalizedSbomIndex {
257 super::NormalizedSbomIndex::build(self)
258 }
259
260 #[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 #[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 #[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 #[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 pub fn apply_cra_sidecar(&mut self, sidecar: &super::CraSidecarMetadata) {
323 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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
388#[non_exhaustive]
389pub enum StalenessLevel {
390 Fresh,
392 Aging,
394 Stale,
396 Abandoned,
398 Deprecated,
400 Archived,
402}
403
404impl StalenessLevel {
405 #[must_use]
407 pub const fn from_days(days: u32) -> Self {
408 match days {
409 0..=182 => Self::Fresh, 183..=365 => Self::Aging, 366..=730 => Self::Stale, _ => Self::Abandoned, }
414 }
415
416 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct StalenessInfo {
464 pub level: StalenessLevel,
466 pub last_published: Option<chrono::DateTime<chrono::Utc>>,
468 pub is_deprecated: bool,
470 pub is_archived: bool,
472 pub deprecation_message: Option<String>,
474 pub days_since_update: Option<u32>,
476 pub latest_version: Option<String>,
478}
479
480impl StalenessInfo {
481 #[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 #[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 #[must_use]
513 pub const fn needs_attention(&self) -> bool {
514 self.level.severity() >= 2
515 }
516}
517
518#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
520#[non_exhaustive]
521pub enum EolStatus {
522 Supported,
524 SecurityOnly,
526 ApproachingEol,
528 EndOfLife,
530 Unknown,
532}
533
534impl EolStatus {
535 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct EolInfo {
581 pub status: EolStatus,
583 pub product: String,
585 pub cycle: String,
587 pub eol_date: Option<chrono::NaiveDate>,
589 pub support_end_date: Option<chrono::NaiveDate>,
591 pub is_lts: bool,
593 pub latest_in_cycle: Option<String>,
595 pub latest_release_date: Option<chrono::NaiveDate>,
597 pub days_until_eol: Option<i64>,
599}
600
601impl EolInfo {
602 #[must_use]
604 pub const fn needs_attention(&self) -> bool {
605 self.status.severity() >= 2
606 }
607}
608
609#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct Component {
612 pub canonical_id: CanonicalId,
614 pub identifiers: ComponentIdentifiers,
616 pub name: String,
618 pub version: Option<String>,
620 pub semver: Option<semver::Version>,
622 pub component_type: ComponentType,
624 pub ecosystem: Option<Ecosystem>,
626 pub licenses: LicenseInfo,
628 pub supplier: Option<Organization>,
630 pub hashes: Vec<Hash>,
632 pub external_refs: Vec<ExternalReference>,
634 pub vulnerabilities: Vec<VulnerabilityRef>,
636 pub vex_status: Option<VexStatus>,
638 pub content_hash: u64,
640 pub extensions: ComponentExtensions,
642 pub description: Option<String>,
644 pub copyright: Option<String>,
646 pub author: Option<String>,
648 pub group: Option<String>,
650 pub is_external: bool,
652 pub version_range: Option<String>,
654 pub staleness: Option<StalenessInfo>,
656 pub eol: Option<EolInfo>,
658 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub crypto_properties: Option<CryptoProperties>,
661}
662
663impl Component {
664 #[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 #[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 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 #[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 #[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 self.canonical_id = self.identifiers.canonical_id();
740 }
741 self
742 }
743
744 #[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 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 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 #[must_use]
812 pub fn is_oss(&self) -> bool {
813 self.licenses.declared.iter().any(|l| l.is_valid_spdx) || self.identifiers.purl.is_some()
815 }
816
817 #[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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
828pub struct DependencyEdge {
829 pub from: CanonicalId,
831 pub to: CanonicalId,
833 pub relationship: DependencyType,
835 pub scope: Option<DependencyScope>,
837}
838
839impl DependencyEdge {
840 #[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 #[must_use]
853 pub const fn with_scope(mut self, scope: DependencyScope) -> Self {
854 self.scope = Some(scope);
855 self
856 }
857
858 #[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}