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
12const CANONICAL_NAN_BITS: u64 = 0x7ff8_0000_0000_0000;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct NormalizedSbom {
20 pub document: DocumentMetadata,
22 pub components: IndexMap<CanonicalId, Component>,
24 pub edges: Vec<DependencyEdge>,
26 pub extensions: FormatExtensions,
28 pub content_hash: u64,
30 pub primary_component_id: Option<CanonicalId>,
33 #[serde(skip)]
35 pub collision_count: usize,
36}
37
38impl NormalizedSbom {
39 #[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 #[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 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 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 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 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 pub fn add_edge(&mut self, edge: DependencyEdge) {
122 self.edges.push(edge);
123 }
124
125 #[must_use]
127 pub fn get_component(&self, id: &CanonicalId) -> Option<&Component> {
128 self.components.get(id)
129 }
130
131 #[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 #[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 pub fn calculate_content_hash(&mut self) {
145 let mut hasher_input = Vec::new();
146
147 if let Ok(meta_json) = serde_json::to_vec(&self.document) {
149 hasher_input.extend(meta_json);
150 }
151
152 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 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 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 #[must_use]
204 pub fn component_count(&self) -> usize {
205 self.components.len()
206 }
207
208 #[must_use]
216 pub fn declarations(&self) -> Option<&super::AttestationDeclarations> {
217 self.extensions.declarations.as_ref()
218 }
219
220 #[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 pub fn set_primary_component(&mut self, id: CanonicalId) {
230 self.primary_component_id = Some(id);
231 }
232
233 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 #[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 #[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 pub fn build_index(&self) -> super::NormalizedSbomIndex {
285 super::NormalizedSbomIndex::build(self)
286 }
287
288 #[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 #[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 #[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 #[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 pub fn apply_cra_sidecar(&mut self, sidecar: &super::CraSidecarMetadata) {
351 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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
416#[non_exhaustive]
417pub enum StalenessLevel {
418 Fresh,
420 Aging,
422 Stale,
424 Abandoned,
426 Deprecated,
428 Archived,
430}
431
432impl StalenessLevel {
433 #[must_use]
435 pub const fn from_days(days: u32) -> Self {
436 match days {
437 0..=182 => Self::Fresh, 183..=365 => Self::Aging, 366..=730 => Self::Stale, _ => Self::Abandoned, }
442 }
443
444 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
491pub struct StalenessInfo {
492 pub level: StalenessLevel,
494 pub last_published: Option<chrono::DateTime<chrono::Utc>>,
496 pub is_deprecated: bool,
498 pub is_archived: bool,
500 pub deprecation_message: Option<String>,
502 pub days_since_update: Option<u32>,
504 pub latest_version: Option<String>,
506}
507
508impl StalenessInfo {
509 #[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 #[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 #[must_use]
541 pub const fn needs_attention(&self) -> bool {
542 self.level.severity() >= 2
543 }
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
548#[non_exhaustive]
549pub enum EolStatus {
550 Supported,
552 SecurityOnly,
554 ApproachingEol,
556 EndOfLife,
558 Unknown,
560}
561
562impl EolStatus {
563 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct EolInfo {
609 pub status: EolStatus,
611 pub product: String,
613 pub cycle: String,
615 pub eol_date: Option<chrono::NaiveDate>,
617 pub support_end_date: Option<chrono::NaiveDate>,
619 pub is_lts: bool,
621 pub latest_in_cycle: Option<String>,
623 pub latest_release_date: Option<chrono::NaiveDate>,
625 pub days_until_eol: Option<i64>,
627}
628
629impl EolInfo {
630 #[must_use]
632 pub const fn needs_attention(&self) -> bool {
633 self.status.severity() >= 2
634 }
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize)]
639pub struct Component {
640 pub canonical_id: CanonicalId,
642 pub identifiers: ComponentIdentifiers,
644 pub name: String,
646 pub version: Option<String>,
648 pub semver: Option<semver::Version>,
650 pub component_type: ComponentType,
652 pub ecosystem: Option<Ecosystem>,
654 pub licenses: LicenseInfo,
656 pub supplier: Option<Organization>,
658 pub hashes: Vec<Hash>,
660 pub external_refs: Vec<ExternalReference>,
662 pub vulnerabilities: Vec<VulnerabilityRef>,
664 pub vex_status: Option<VexStatus>,
666 pub content_hash: u64,
668 pub extensions: ComponentExtensions,
670 pub description: Option<String>,
672 pub copyright: Option<String>,
674 pub author: Option<String>,
676 pub group: Option<String>,
678 pub is_external: bool,
680 pub version_range: Option<String>,
682 pub staleness: Option<StalenessInfo>,
684 pub eol: Option<EolInfo>,
686 pub ml_model: Option<crate::model::MlModelInfo>,
688 pub dataset: Option<crate::model::DatasetInfo>,
690 #[serde(default, skip_serializing_if = "Option::is_none")]
692 pub crypto_properties: Option<CryptoProperties>,
693}
694
695impl Component {
696 #[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 #[must_use]
734 pub fn with_purl(mut self, purl: String) -> Self {
735 self.set_purl(purl);
736 self
737 }
738
739 pub fn set_purl(&mut self, purl: String) {
743 self.identifiers.purl = Some(purl);
744 self.canonical_id = self.identifiers.canonical_id();
745
746 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 #[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 #[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 #[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 #[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 #[must_use]
800 pub fn with_dataset(mut self, dataset: crate::model::DatasetInfo) -> Self {
801 self.dataset = Some(dataset);
802 self
803 }
804
805 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 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 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 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 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 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 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 #[must_use]
1050 pub fn is_oss(&self) -> bool {
1051 self.licenses.declared.iter().any(|l| l.is_valid_spdx) || self.identifiers.purl.is_some()
1053 }
1054
1055 #[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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1066pub struct DependencyEdge {
1067 pub from: CanonicalId,
1069 pub to: CanonicalId,
1071 pub relationship: DependencyType,
1073 pub scope: Option<DependencyScope>,
1075}
1076
1077impl DependencyEdge {
1078 #[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 #[must_use]
1091 pub const fn with_scope(mut self, scope: DependencyScope) -> Self {
1092 self.scope = Some(scope);
1093 self
1094 }
1095
1096 #[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}