1use std::collections::{BTreeMap, HashMap, HashSet};
6
7use crate::model::{
8 CompletenessDeclaration, ComponentType, CreatorType, CryptoAssetType, CryptoMaterialState,
9 CryptoPrimitive, EolStatus, ExternalRefType, HashAlgorithm, NormalizedSbom, StalenessLevel,
10};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct CompletenessMetrics {
16 pub components_with_version: f32,
18 pub components_with_purl: f32,
20 pub components_with_cpe: f32,
22 pub components_with_supplier: f32,
24 pub components_with_hashes: f32,
26 pub components_with_licenses: f32,
28 pub components_with_description: f32,
30 pub has_creator_info: bool,
32 pub has_timestamp: bool,
34 pub has_serial_number: bool,
36 pub total_components: usize,
38}
39
40impl CompletenessMetrics {
41 #[must_use]
43 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
44 let total = sbom.components.len();
45 if total == 0 {
46 return Self::empty();
47 }
48
49 let mut with_version = 0;
50 let mut with_purl = 0;
51 let mut with_cpe = 0;
52 let mut with_supplier = 0;
53 let mut with_hashes = 0;
54 let mut with_licenses = 0;
55 let mut with_description = 0;
56
57 let mut countable = 0;
63
64 for comp in sbom.components.values() {
65 if matches!(comp.component_type, crate::model::ComponentType::File) {
66 continue;
67 }
68 countable += 1;
69 if comp.version.is_some() {
70 with_version += 1;
71 }
72 if comp.identifiers.purl.is_some() {
73 with_purl += 1;
74 }
75 if !comp.identifiers.cpe.is_empty() {
76 with_cpe += 1;
77 }
78 if comp.supplier.is_some() {
79 with_supplier += 1;
80 }
81 if !comp.hashes.is_empty() {
82 with_hashes += 1;
83 }
84 let has_license_info = comp
90 .licenses
91 .declared
92 .iter()
93 .any(|l| l.expression != "NOASSERTION")
94 || comp
95 .licenses
96 .concluded
97 .as_ref()
98 .is_some_and(|c| c.expression != "NOASSERTION");
99 if has_license_info {
100 with_licenses += 1;
101 }
102 if comp.description.is_some() {
103 with_description += 1;
104 }
105 }
106
107 let pct = |count: usize| {
108 if countable == 0 {
109 0.0
110 } else {
111 (count as f32 / countable as f32) * 100.0
112 }
113 };
114
115 Self {
116 components_with_version: pct(with_version),
117 components_with_purl: pct(with_purl),
118 components_with_cpe: pct(with_cpe),
119 components_with_supplier: pct(with_supplier),
120 components_with_hashes: pct(with_hashes),
121 components_with_licenses: pct(with_licenses),
122 components_with_description: pct(with_description),
123 has_creator_info: !sbom.document.creators.is_empty(),
124 has_timestamp: sbom.document.has_known_timestamp(),
127 has_serial_number: sbom.document.serial_number.is_some(),
128 total_components: total,
129 }
130 }
131
132 #[must_use]
134 pub const fn empty() -> Self {
135 Self {
136 components_with_version: 0.0,
137 components_with_purl: 0.0,
138 components_with_cpe: 0.0,
139 components_with_supplier: 0.0,
140 components_with_hashes: 0.0,
141 components_with_licenses: 0.0,
142 components_with_description: 0.0,
143 has_creator_info: false,
144 has_timestamp: false,
145 has_serial_number: false,
146 total_components: 0,
147 }
148 }
149
150 #[must_use]
152 pub fn overall_score(&self, weights: &CompletenessWeights) -> f32 {
153 let mut score = 0.0;
154 let mut total_weight = 0.0;
155
156 score += self.components_with_version * weights.version;
158 total_weight += weights.version * 100.0;
159
160 score += self.components_with_purl * weights.purl;
161 total_weight += weights.purl * 100.0;
162
163 score += self.components_with_cpe * weights.cpe;
164 total_weight += weights.cpe * 100.0;
165
166 score += self.components_with_supplier * weights.supplier;
167 total_weight += weights.supplier * 100.0;
168
169 score += self.components_with_hashes * weights.hashes;
170 total_weight += weights.hashes * 100.0;
171
172 score += self.components_with_licenses * weights.licenses;
173 total_weight += weights.licenses * 100.0;
174
175 if self.has_creator_info {
177 score += 100.0 * weights.creator_info;
178 }
179 total_weight += weights.creator_info * 100.0;
180
181 if self.has_serial_number {
182 score += 100.0 * weights.serial_number;
183 }
184 total_weight += weights.serial_number * 100.0;
185
186 if total_weight > 0.0 {
187 (score / total_weight) * 100.0
188 } else {
189 0.0
190 }
191 }
192}
193
194#[derive(Debug, Clone)]
196pub struct CompletenessWeights {
197 pub version: f32,
198 pub purl: f32,
199 pub cpe: f32,
200 pub supplier: f32,
201 pub hashes: f32,
202 pub licenses: f32,
203 pub creator_info: f32,
204 pub serial_number: f32,
205}
206
207impl Default for CompletenessWeights {
208 fn default() -> Self {
209 Self {
210 version: 1.0,
211 purl: 1.5, cpe: 0.5, supplier: 1.0,
214 hashes: 1.0,
215 licenses: 1.2, creator_info: 0.3,
217 serial_number: 0.2,
218 }
219 }
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct HashQualityMetrics {
229 pub components_with_any_hash: usize,
231 pub components_with_strong_hash: usize,
233 pub components_with_weak_only: usize,
235 pub algorithm_distribution: BTreeMap<String, usize>,
237 pub total_hashes: usize,
239 pub vendor_components_total: usize,
244 pub vendor_components_with_hash: usize,
246 pub vendor_components_with_strong_hash: usize,
248}
249
250impl HashQualityMetrics {
251 #[must_use]
253 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
254 let mut with_any = 0;
255 let mut with_strong = 0;
256 let mut with_weak_only = 0;
257 let mut distribution: BTreeMap<String, usize> = BTreeMap::new();
258 let mut total_hashes = 0;
259 let mut vendor_total = 0;
260 let mut vendor_with_hash = 0;
261 let mut vendor_with_strong = 0;
262
263 for comp in sbom.components.values() {
264 let is_vendor = (comp.supplier.is_some() || comp.author.is_some())
266 && !matches!(
267 comp.canonical_id.source(),
268 crate::model::IdSource::Synthetic
269 | crate::model::IdSource::FormatSpecific
270 | crate::model::IdSource::NameVersion
271 );
272 if is_vendor {
273 vendor_total += 1;
274 }
275
276 if comp.hashes.is_empty() {
277 continue;
278 }
279 with_any += 1;
280 total_hashes += comp.hashes.len();
281
282 let mut has_strong = false;
283 let mut has_weak = false;
284
285 for hash in &comp.hashes {
286 let label = hash_algorithm_label(&hash.algorithm);
287 *distribution.entry(label).or_insert(0) += 1;
288
289 if is_strong_hash(&hash.algorithm) {
290 has_strong = true;
291 } else {
292 has_weak = true;
293 }
294 }
295
296 if has_strong {
297 with_strong += 1;
298 } else if has_weak {
299 with_weak_only += 1;
300 }
301
302 if is_vendor {
303 vendor_with_hash += 1;
304 if has_strong {
305 vendor_with_strong += 1;
306 }
307 }
308 }
309
310 Self {
311 components_with_any_hash: with_any,
312 components_with_strong_hash: with_strong,
313 components_with_weak_only: with_weak_only,
314 algorithm_distribution: distribution,
315 total_hashes,
316 vendor_components_total: vendor_total,
317 vendor_components_with_hash: vendor_with_hash,
318 vendor_components_with_strong_hash: vendor_with_strong,
319 }
320 }
321
322 #[must_use]
326 pub fn vendor_hash_coverage(&self) -> Option<f64> {
327 if self.vendor_components_total == 0 {
328 None
329 } else {
330 #[allow(clippy::cast_precision_loss)]
331 Some(self.vendor_components_with_hash as f64 / self.vendor_components_total as f64)
332 }
333 }
334
335 #[must_use]
337 pub fn vendor_strong_hash_coverage(&self) -> Option<f64> {
338 if self.vendor_components_total == 0 {
339 None
340 } else {
341 #[allow(clippy::cast_precision_loss)]
342 Some(
343 self.vendor_components_with_strong_hash as f64
344 / self.vendor_components_total as f64,
345 )
346 }
347 }
348
349 #[must_use]
354 pub fn quality_score(&self, total_components: usize) -> f32 {
355 if total_components == 0 {
356 return 0.0;
357 }
358
359 let any_coverage = self.components_with_any_hash as f32 / total_components as f32;
360 let strong_coverage = self.components_with_strong_hash as f32 / total_components as f32;
361 let weak_only_ratio = self.components_with_weak_only as f32 / total_components as f32;
362
363 let base = any_coverage * 60.0;
364 let strong_bonus = strong_coverage * 40.0;
365 let weak_penalty = weak_only_ratio * 10.0;
366
367 (base + strong_bonus - weak_penalty).clamp(0.0, 100.0)
368 }
369}
370
371fn is_strong_hash(algo: &HashAlgorithm) -> bool {
373 matches!(
374 algo,
375 HashAlgorithm::Sha256
376 | HashAlgorithm::Sha384
377 | HashAlgorithm::Sha512
378 | HashAlgorithm::Sha3_256
379 | HashAlgorithm::Sha3_384
380 | HashAlgorithm::Sha3_512
381 | HashAlgorithm::Blake2b256
382 | HashAlgorithm::Blake2b384
383 | HashAlgorithm::Blake2b512
384 | HashAlgorithm::Blake3
385 | HashAlgorithm::Streebog256
386 | HashAlgorithm::Streebog512
387 )
388}
389
390fn hash_algorithm_label(algo: &HashAlgorithm) -> String {
392 match algo {
393 HashAlgorithm::Md5 => "MD5".to_string(),
394 HashAlgorithm::Sha1 => "SHA-1".to_string(),
395 HashAlgorithm::Sha256 => "SHA-256".to_string(),
396 HashAlgorithm::Sha384 => "SHA-384".to_string(),
397 HashAlgorithm::Sha512 => "SHA-512".to_string(),
398 HashAlgorithm::Sha3_256 => "SHA3-256".to_string(),
399 HashAlgorithm::Sha3_384 => "SHA3-384".to_string(),
400 HashAlgorithm::Sha3_512 => "SHA3-512".to_string(),
401 HashAlgorithm::Blake2b256 => "BLAKE2b-256".to_string(),
402 HashAlgorithm::Blake2b384 => "BLAKE2b-384".to_string(),
403 HashAlgorithm::Blake2b512 => "BLAKE2b-512".to_string(),
404 HashAlgorithm::Blake3 => "BLAKE3".to_string(),
405 HashAlgorithm::Streebog256 => "Streebog-256".to_string(),
406 HashAlgorithm::Streebog512 => "Streebog-512".to_string(),
407 HashAlgorithm::Other(s) => s.clone(),
408 }
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct IdentifierMetrics {
418 pub valid_purls: usize,
420 pub invalid_purls: usize,
422 pub valid_cpes: usize,
424 pub invalid_cpes: usize,
426 pub with_swid: usize,
428 #[serde(default)]
432 pub components_with_valid_id: usize,
433 pub ecosystems: Vec<String>,
435 pub missing_all_identifiers: usize,
437 #[serde(skip)]
440 pub file_components: usize,
441}
442
443impl IdentifierMetrics {
444 #[must_use]
446 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
447 let mut valid_purls = 0;
448 let mut invalid_purls = 0;
449 let mut valid_cpes = 0;
450 let mut invalid_cpes = 0;
451 let mut with_swid = 0;
452 let mut with_valid_id = 0;
453 let mut missing_all = 0;
454 let mut file_components = 0;
455 let mut ecosystems = std::collections::HashSet::new();
456
457 for comp in sbom.components.values() {
458 if matches!(comp.component_type, ComponentType::File) {
463 file_components += 1;
464 continue;
465 }
466
467 let has_purl = comp.identifiers.purl.is_some();
468 let has_cpe = !comp.identifiers.cpe.is_empty();
469 let has_swid = comp.identifiers.swid.is_some();
470
471 let mut purl_valid = false;
472 if let Some(ref purl) = comp.identifiers.purl {
473 if is_valid_purl(purl) {
474 purl_valid = true;
475 valid_purls += 1;
476 if let Some(eco) = extract_ecosystem_from_purl(purl) {
478 ecosystems.insert(eco);
479 }
480 } else {
481 invalid_purls += 1;
482 }
483 }
484
485 let any_cpe_valid = comp.identifiers.cpe.iter().any(|c| is_valid_cpe(c));
488 let any_cpe_invalid = comp.identifiers.cpe.iter().any(|c| !is_valid_cpe(c));
489 if any_cpe_valid {
490 valid_cpes += 1;
491 }
492 if any_cpe_invalid {
493 invalid_cpes += 1;
494 }
495
496 if has_swid {
497 with_swid += 1;
498 }
499
500 if purl_valid || any_cpe_valid || has_swid {
501 with_valid_id += 1;
502 }
503
504 if !has_purl && !has_cpe && !has_swid {
505 missing_all += 1;
506 }
507 }
508
509 let mut ecosystem_list: Vec<String> = ecosystems.into_iter().collect();
510 ecosystem_list.sort();
511
512 Self {
513 valid_purls,
514 invalid_purls,
515 valid_cpes,
516 invalid_cpes,
517 with_swid,
518 components_with_valid_id: with_valid_id,
519 ecosystems: ecosystem_list,
520 missing_all_identifiers: missing_all,
521 file_components,
522 }
523 }
524
525 #[must_use]
527 pub fn quality_score(&self, total_components: usize) -> f32 {
528 let countable = total_components.saturating_sub(self.file_components);
532 if countable == 0 {
533 return 0.0;
534 }
535
536 let coverage =
540 (self.components_with_valid_id.min(countable) as f32 / countable as f32) * 100.0;
541
542 let invalid_count = self.invalid_purls + self.invalid_cpes;
544 let penalty = (invalid_count as f32 / countable as f32) * 20.0;
545
546 (coverage - penalty).clamp(0.0, 100.0)
547 }
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct LicenseMetrics {
553 pub with_declared: usize,
555 pub with_concluded: usize,
557 pub valid_spdx_expressions: usize,
560 pub non_standard_licenses: usize,
563 pub noassertion_count: usize,
565 pub deprecated_licenses: usize,
567 pub restrictive_licenses: usize,
569 pub copyleft_license_ids: Vec<String>,
571 pub unique_licenses: Vec<String>,
573 #[serde(skip)]
576 pub file_components: usize,
577}
578
579impl LicenseMetrics {
580 #[must_use]
582 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
583 let mut with_declared = 0;
584 let mut with_concluded = 0;
585 let mut valid_spdx = 0;
586 let mut non_standard = 0;
587 let mut noassertion = 0;
588 let mut deprecated = 0;
589 let mut restrictive = 0;
590 let mut file_components = 0;
591 let mut licenses = HashSet::new();
592 let mut copyleft_ids = HashSet::new();
593
594 for comp in sbom.components.values() {
600 let is_file = matches!(comp.component_type, ComponentType::File);
606 if is_file {
607 file_components += 1;
608 }
609
610 let mut has_real_entry = false;
611 let mut has_noassertion = false;
612 let mut all_valid = true;
613 let mut any_deprecated = false;
614 let mut any_restrictive = false;
615
616 for lic in &comp.licenses.declared {
617 let expr = &lic.expression;
618 licenses.insert(expr.clone());
619
620 if expr == "NOASSERTION" {
621 has_noassertion = true;
622 continue;
623 }
624 has_real_entry = true;
625
626 if !lic.is_valid_spdx {
630 all_valid = false;
631 }
632 if is_deprecated_spdx_license(expr) {
633 any_deprecated = true;
634 }
635 if is_restrictive_license(expr) {
636 any_restrictive = true;
637 copyleft_ids.insert(expr.clone());
638 }
639 }
640
641 if is_file {
642 continue;
645 }
646
647 if has_noassertion {
648 noassertion += 1;
649 }
650 if has_real_entry {
651 with_declared += 1;
652 if all_valid {
653 valid_spdx += 1;
654 } else {
655 non_standard += 1;
656 }
657 if any_deprecated {
658 deprecated += 1;
659 }
660 if any_restrictive {
661 restrictive += 1;
662 }
663 }
664
665 if comp.licenses.concluded.is_some() {
666 with_concluded += 1;
667 }
668 }
669
670 let mut license_list: Vec<String> = licenses.into_iter().collect();
671 license_list.sort();
672
673 let mut copyleft_list: Vec<String> = copyleft_ids.into_iter().collect();
674 copyleft_list.sort();
675
676 Self {
677 with_declared,
678 with_concluded,
679 valid_spdx_expressions: valid_spdx,
680 non_standard_licenses: non_standard,
681 noassertion_count: noassertion,
682 deprecated_licenses: deprecated,
683 restrictive_licenses: restrictive,
684 copyleft_license_ids: copyleft_list,
685 unique_licenses: license_list,
686 file_components,
687 }
688 }
689
690 #[must_use]
692 pub fn quality_score(&self, total_components: usize) -> f32 {
693 let countable = total_components.saturating_sub(self.file_components);
697 if countable == 0 {
698 return 0.0;
699 }
700
701 let coverage = (self.with_declared as f32 / countable as f32) * 60.0;
702
703 let spdx_ratio = if self.with_declared > 0 {
705 self.valid_spdx_expressions as f32 / self.with_declared as f32
706 } else {
707 0.0
708 };
709 let spdx_bonus = spdx_ratio * 30.0;
710
711 let noassertion_penalty = (self.noassertion_count as f32 / countable as f32) * 10.0;
713
714 let deprecated_penalty = (self.deprecated_licenses as f32 * 2.0).min(10.0);
716
717 (coverage + spdx_bonus - noassertion_penalty - deprecated_penalty).clamp(0.0, 100.0)
718 }
719}
720
721#[derive(Debug, Clone, Serialize, Deserialize)]
723pub struct VulnerabilityMetrics {
724 pub components_with_vulns: usize,
726 pub total_vulnerabilities: usize,
728 pub with_cvss: usize,
730 pub with_cwe: usize,
732 pub with_remediation: usize,
734 pub with_vex_status: usize,
736}
737
738impl VulnerabilityMetrics {
739 #[must_use]
741 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
742 let mut components_with_vulns = 0;
743 let mut total_vulns = 0;
744 let mut with_cvss = 0;
745 let mut with_cwe = 0;
746 let mut with_remediation = 0;
747 let mut with_vex = 0;
748
749 for comp in sbom.components.values() {
750 if !comp.vulnerabilities.is_empty() {
751 components_with_vulns += 1;
752 }
753
754 for vuln in &comp.vulnerabilities {
755 total_vulns += 1;
756
757 if !vuln.cvss.is_empty() {
758 with_cvss += 1;
759 }
760 if !vuln.cwes.is_empty() {
761 with_cwe += 1;
762 }
763 if vuln.remediation.is_some() {
764 with_remediation += 1;
765 }
766 }
767
768 if comp.vex_status.is_some()
769 || comp.vulnerabilities.iter().any(|v| v.vex_status.is_some())
770 {
771 with_vex += 1;
772 }
773 }
774
775 Self {
776 components_with_vulns,
777 total_vulnerabilities: total_vulns,
778 with_cvss,
779 with_cwe,
780 with_remediation,
781 with_vex_status: with_vex,
782 }
783 }
784
785 #[must_use]
799 pub fn documentation_score(&self) -> Option<f32> {
800 if self.total_vulnerabilities == 0 {
801 return None; }
803
804 let cvss_ratio = self.with_cvss as f32 / self.total_vulnerabilities as f32;
805 let cwe_ratio = self.with_cwe as f32 / self.total_vulnerabilities as f32;
806 let remediation_ratio = self.with_remediation as f32 / self.total_vulnerabilities as f32;
807
808 let quality = remediation_ratio.mul_add(18.0, cvss_ratio.mul_add(24.0, cwe_ratio * 18.0));
809 Some((40.0 + quality).min(100.0))
810 }
811}
812
813const MAX_EDGES_FOR_GRAPH_ANALYSIS: usize = 1_000_000;
819
820#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
826#[non_exhaustive]
827pub enum ComplexityLevel {
828 Low,
830 Moderate,
832 High,
834 VeryHigh,
836}
837
838impl ComplexityLevel {
839 #[must_use]
841 pub const fn from_score(simplicity: f32) -> Self {
842 match simplicity as u32 {
843 75..=100 => Self::Low,
844 50..=74 => Self::Moderate,
845 25..=49 => Self::High,
846 _ => Self::VeryHigh,
847 }
848 }
849
850 #[must_use]
852 pub const fn label(&self) -> &'static str {
853 match self {
854 Self::Low => "Low",
855 Self::Moderate => "Moderate",
856 Self::High => "High",
857 Self::VeryHigh => "Very High",
858 }
859 }
860}
861
862impl std::fmt::Display for ComplexityLevel {
863 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
864 f.write_str(self.label())
865 }
866}
867
868#[derive(Debug, Clone, Serialize, Deserialize)]
871pub struct ComplexityFactors {
872 pub dependency_volume: f32,
874 pub normalized_depth: f32,
876 pub fanout_concentration: f32,
878 pub cycle_ratio: f32,
880 pub fragmentation: f32,
882}
883
884#[derive(Debug, Clone, Serialize, Deserialize)]
886pub struct DependencyMetrics {
887 pub total_dependencies: usize,
889 pub components_with_deps: usize,
891 pub max_depth: Option<usize>,
893 pub avg_depth: Option<f32>,
895 pub orphan_components: usize,
897 pub root_components: usize,
899 pub cycle_count: usize,
901 pub island_count: usize,
903 pub graph_analysis_skipped: bool,
905 pub max_out_degree: usize,
907 pub software_complexity_index: Option<f32>,
909 pub complexity_level: Option<ComplexityLevel>,
911 pub complexity_factors: Option<ComplexityFactors>,
913 #[serde(skip)]
916 pub file_components: usize,
917}
918
919impl DependencyMetrics {
920 #[must_use]
922 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
923 use crate::model::CanonicalId;
924
925 let total_deps = sbom.edges.len();
926
927 let file_components = sbom
933 .components
934 .values()
935 .filter(|c| matches!(c.component_type, ComponentType::File))
936 .count();
937
938 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
940 let mut has_outgoing: HashSet<&str> = HashSet::new();
941 let mut has_incoming: HashSet<&str> = HashSet::new();
942
943 for edge in &sbom.edges {
944 children
945 .entry(edge.from.value())
946 .or_default()
947 .push(edge.to.value());
948 has_outgoing.insert(edge.from.value());
949 has_incoming.insert(edge.to.value());
950 }
951
952 let all_ids: Vec<&str> = sbom.components.keys().map(CanonicalId::value).collect();
953
954 let orphans = all_ids
955 .iter()
956 .filter(|c| !has_outgoing.contains(*c) && !has_incoming.contains(*c))
957 .count();
958
959 let roots: Vec<&str> = has_outgoing
960 .iter()
961 .filter(|c| !has_incoming.contains(*c))
962 .copied()
963 .collect();
964 let root_count = roots.len();
965
966 let max_out_degree = children.values().map(Vec::len).max().unwrap_or(0);
968
969 if total_deps > MAX_EDGES_FOR_GRAPH_ANALYSIS {
971 return Self {
972 total_dependencies: total_deps,
973 components_with_deps: has_outgoing.len(),
974 max_depth: None,
975 avg_depth: None,
976 orphan_components: orphans,
977 root_components: root_count,
978 cycle_count: 0,
979 island_count: 0,
980 graph_analysis_skipped: true,
981 max_out_degree,
982 software_complexity_index: None,
983 complexity_level: None,
984 complexity_factors: None,
985 file_components,
986 };
987 }
988
989 let (max_depth, avg_depth) = compute_depth(&roots, &children);
991
992 let cycle_count = detect_cycles(&all_ids, &children);
994
995 let island_count = count_islands(&all_ids, &sbom.edges);
997
998 let component_count = all_ids.len();
1000 let (complexity_index, complexity_lvl, factors) = compute_complexity(
1001 total_deps,
1002 component_count,
1003 max_depth.unwrap_or(0),
1004 max_out_degree,
1005 cycle_count,
1006 orphans,
1007 island_count,
1008 );
1009
1010 Self {
1011 total_dependencies: total_deps,
1012 components_with_deps: has_outgoing.len(),
1013 max_depth,
1014 avg_depth,
1015 orphan_components: orphans,
1016 root_components: root_count,
1017 cycle_count,
1018 island_count,
1019 graph_analysis_skipped: false,
1020 max_out_degree,
1021 software_complexity_index: Some(complexity_index),
1022 complexity_level: Some(complexity_lvl),
1023 complexity_factors: Some(factors),
1024 file_components,
1025 }
1026 }
1027
1028 #[must_use]
1030 pub fn quality_score(&self, total_components: usize) -> f32 {
1031 if total_components == 0 {
1032 return 0.0;
1033 }
1034
1035 let countable = total_components.saturating_sub(self.file_components);
1039
1040 let coverage = if countable > 1 {
1045 ((self.components_with_deps as f32 / (countable - 1) as f32) * 100.0).min(100.0)
1046 } else {
1047 100.0 };
1049
1050 let orphan_ratio = self.orphan_components as f32 / total_components as f32;
1052 let orphan_penalty = orphan_ratio * 10.0;
1053
1054 let cycle_penalty = (self.cycle_count as f32 * 5.0).min(20.0);
1056
1057 let island_penalty = if total_components > 5 && self.island_count > 3 {
1059 ((self.island_count - 3) as f32 * 3.0).min(15.0)
1060 } else {
1061 0.0
1062 };
1063
1064 (coverage - orphan_penalty - cycle_penalty - island_penalty).clamp(0.0, 100.0)
1065 }
1066}
1067
1068fn compute_depth(
1070 roots: &[&str],
1071 children: &HashMap<&str, Vec<&str>>,
1072) -> (Option<usize>, Option<f32>) {
1073 use std::collections::VecDeque;
1074
1075 if roots.is_empty() {
1076 return (None, None);
1077 }
1078
1079 let mut visited: HashSet<&str> = HashSet::new();
1080 let mut queue: VecDeque<(&str, usize)> = VecDeque::new();
1081 let mut max_d: usize = 0;
1082 let mut total_depth: usize = 0;
1083 let mut count: usize = 0;
1084
1085 for &root in roots {
1086 if visited.insert(root) {
1087 queue.push_back((root, 0));
1088 }
1089 }
1090
1091 while let Some((node, depth)) = queue.pop_front() {
1092 max_d = max_d.max(depth);
1093 total_depth += depth;
1094 count += 1;
1095
1096 if let Some(kids) = children.get(node) {
1097 for &kid in kids {
1098 if visited.insert(kid) {
1099 queue.push_back((kid, depth + 1));
1100 }
1101 }
1102 }
1103 }
1104
1105 let avg = if count > 0 {
1106 Some(total_depth as f32 / count as f32)
1107 } else {
1108 None
1109 };
1110
1111 (Some(max_d), avg)
1112}
1113
1114fn detect_cycles(all_nodes: &[&str], children: &HashMap<&str, Vec<&str>>) -> usize {
1121 let mut index_of: HashMap<&str, usize> = HashMap::with_capacity(all_nodes.len());
1122 for &node in all_nodes {
1123 let next = index_of.len();
1124 index_of.entry(node).or_insert(next);
1125 }
1126 for (&from, kids) in children {
1127 let next = index_of.len();
1128 index_of.entry(from).or_insert(next);
1129 for &kid in kids {
1130 let next = index_of.len();
1131 index_of.entry(kid).or_insert(next);
1132 }
1133 }
1134
1135 let node_count = index_of.len();
1136 let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); node_count];
1137 let mut has_self_loop = vec![false; node_count];
1138 for (from, kids) in children {
1139 let from_idx = index_of[from];
1140 for kid in kids {
1141 let kid_idx = index_of[kid];
1142 if from_idx == kid_idx {
1143 has_self_loop[from_idx] = true;
1144 }
1145 adjacency[from_idx].push(kid_idx);
1146 }
1147 }
1148
1149 const UNVISITED: usize = usize::MAX;
1150 let mut order = vec![UNVISITED; node_count];
1151 let mut lowlink = vec![0usize; node_count];
1152 let mut on_stack = vec![false; node_count];
1153 let mut scc_stack: Vec<usize> = Vec::new();
1154 let mut call_stack: Vec<(usize, usize)> = Vec::new();
1155 let mut next_order = 0usize;
1156 let mut cycles = 0usize;
1157
1158 for start in 0..node_count {
1159 if order[start] != UNVISITED {
1160 continue;
1161 }
1162 call_stack.push((start, 0));
1163 while let Some(frame) = call_stack.last_mut() {
1164 let node = frame.0;
1165 if frame.1 == 0 {
1166 order[node] = next_order;
1167 lowlink[node] = next_order;
1168 next_order += 1;
1169 scc_stack.push(node);
1170 on_stack[node] = true;
1171 }
1172 if let Some(&target) = adjacency[node].get(frame.1) {
1173 frame.1 += 1;
1174 if order[target] == UNVISITED {
1175 call_stack.push((target, 0));
1176 } else if on_stack[target] {
1177 lowlink[node] = lowlink[node].min(order[target]);
1178 }
1179 } else {
1180 call_stack.pop();
1181 if let Some(&(parent, _)) = call_stack.last() {
1182 lowlink[parent] = lowlink[parent].min(lowlink[node]);
1183 }
1184 if lowlink[node] == order[node] {
1185 let mut scc_size = 0usize;
1186 while let Some(member) = scc_stack.pop() {
1187 on_stack[member] = false;
1188 scc_size += 1;
1189 if member == node {
1190 break;
1191 }
1192 }
1193 if scc_size > 1 || has_self_loop[node] {
1194 cycles += 1;
1195 }
1196 }
1197 }
1198 }
1199 }
1200
1201 cycles
1202}
1203
1204fn count_islands(all_nodes: &[&str], edges: &[crate::model::DependencyEdge]) -> usize {
1206 if all_nodes.is_empty() {
1207 return 0;
1208 }
1209
1210 let node_idx: HashMap<&str, usize> =
1212 all_nodes.iter().enumerate().map(|(i, &n)| (n, i)).collect();
1213
1214 let mut parent: Vec<usize> = (0..all_nodes.len()).collect();
1215 let mut rank: Vec<u8> = vec![0; all_nodes.len()];
1216
1217 fn find(parent: &mut Vec<usize>, x: usize) -> usize {
1218 if parent[x] != x {
1219 parent[x] = find(parent, parent[x]); }
1221 parent[x]
1222 }
1223
1224 fn union(parent: &mut Vec<usize>, rank: &mut [u8], a: usize, b: usize) {
1225 let ra = find(parent, a);
1226 let rb = find(parent, b);
1227 if ra != rb {
1228 if rank[ra] < rank[rb] {
1229 parent[ra] = rb;
1230 } else if rank[ra] > rank[rb] {
1231 parent[rb] = ra;
1232 } else {
1233 parent[rb] = ra;
1234 rank[ra] += 1;
1235 }
1236 }
1237 }
1238
1239 for edge in edges {
1240 if let (Some(&a), Some(&b)) = (
1241 node_idx.get(edge.from.value()),
1242 node_idx.get(edge.to.value()),
1243 ) {
1244 union(&mut parent, &mut rank, a, b);
1245 }
1246 }
1247
1248 let mut roots = HashSet::new();
1250 for i in 0..all_nodes.len() {
1251 roots.insert(find(&mut parent, i));
1252 }
1253
1254 roots.len()
1255}
1256
1257fn compute_complexity(
1262 edges: usize,
1263 components: usize,
1264 max_depth: usize,
1265 max_out_degree: usize,
1266 cycle_count: usize,
1267 _orphans: usize,
1268 islands: usize,
1269) -> (f32, ComplexityLevel, ComplexityFactors) {
1270 if components == 0 {
1271 let factors = ComplexityFactors {
1272 dependency_volume: 0.0,
1273 normalized_depth: 0.0,
1274 fanout_concentration: 0.0,
1275 cycle_ratio: 0.0,
1276 fragmentation: 0.0,
1277 };
1278 return (100.0, ComplexityLevel::Low, factors);
1279 }
1280
1281 let edge_ratio = edges as f64 / components as f64;
1283 let dependency_volume = ((1.0 + edge_ratio).ln() / 20.0_f64.ln()).min(1.0) as f32;
1284
1285 let normalized_depth = (max_depth as f32 / 15.0).min(1.0);
1287
1288 let fanout_denom = (components as f32 * 0.25).max(4.0);
1291 let fanout_concentration = (max_out_degree as f32 / fanout_denom).min(1.0);
1292
1293 let cycle_threshold = (components as f32 * 0.05).max(1.0);
1295 let cycle_ratio = (cycle_count as f32 / cycle_threshold).min(1.0);
1296
1297 let extra_islands = islands.saturating_sub(1);
1300 let fragmentation = if components > 1 {
1301 (extra_islands as f32 / (components - 1) as f32).min(1.0)
1302 } else {
1303 0.0
1304 };
1305
1306 let factors = ComplexityFactors {
1307 dependency_volume,
1308 normalized_depth,
1309 fanout_concentration,
1310 cycle_ratio,
1311 fragmentation,
1312 };
1313
1314 let raw_complexity = 0.30 * dependency_volume
1315 + 0.20 * normalized_depth
1316 + 0.20 * fanout_concentration
1317 + 0.20 * cycle_ratio
1318 + 0.10 * fragmentation;
1319
1320 let simplicity_index = (100.0 - raw_complexity * 100.0).clamp(0.0, 100.0);
1321 let level = ComplexityLevel::from_score(simplicity_index);
1322
1323 (simplicity_index, level, factors)
1324}
1325
1326#[derive(Debug, Clone, Serialize, Deserialize)]
1332pub struct ProvenanceMetrics {
1333 pub has_tool_creator: bool,
1335 pub has_tool_version: bool,
1337 pub has_org_creator: bool,
1339 pub has_contact_email: bool,
1341 pub has_serial_number: bool,
1343 pub has_document_name: bool,
1345 pub timestamp_age_days: u32,
1348 #[serde(default = "default_timestamp_known")]
1351 pub timestamp_known: bool,
1352 pub is_fresh: bool,
1355 pub has_primary_component: bool,
1357 pub lifecycle_phase: Option<String>,
1359 pub completeness_declaration: CompletenessDeclaration,
1361 pub has_signature: bool,
1363 pub has_citations: bool,
1365 pub citations_count: usize,
1367}
1368
1369const FRESHNESS_THRESHOLD_DAYS: u32 = 90;
1371
1372const fn default_timestamp_known() -> bool {
1376 true
1377}
1378
1379impl ProvenanceMetrics {
1380 #[must_use]
1382 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1383 let doc = &sbom.document;
1384
1385 let has_tool_creator = doc
1386 .creators
1387 .iter()
1388 .any(|c| c.creator_type == CreatorType::Tool);
1389 let has_tool_version = doc.creators.iter().any(|c| {
1390 c.creator_type == CreatorType::Tool
1391 && (c.name.contains(' ')
1392 || c.name.contains('/')
1393 || c.name.contains('@')
1394 || c.name
1398 .match_indices('-')
1399 .any(|(i, _)| c.name[i + 1..].starts_with(|ch: char| ch.is_ascii_digit())))
1400 });
1401 let has_org_creator = doc
1402 .creators
1403 .iter()
1404 .any(|c| c.creator_type == CreatorType::Organization);
1405 let has_contact_email = doc.creators.iter().any(|c| c.email.is_some());
1406
1407 let timestamp_known = doc.has_known_timestamp();
1408 let age_days_signed = (chrono::Utc::now() - doc.created).num_days();
1410 let age_days = age_days_signed.max(0) as u32;
1411
1412 Self {
1413 has_tool_creator,
1414 has_tool_version,
1415 has_org_creator,
1416 has_contact_email,
1417 has_serial_number: doc.serial_number.is_some(),
1418 has_document_name: doc.name.is_some(),
1419 timestamp_age_days: if timestamp_known { age_days } else { 0 },
1422 timestamp_known,
1423 is_fresh: timestamp_known
1428 && (0..i64::from(FRESHNESS_THRESHOLD_DAYS)).contains(&age_days_signed),
1429 has_primary_component: sbom.primary_component_id.is_some(),
1430 lifecycle_phase: doc.lifecycle_phase.clone(),
1431 completeness_declaration: doc.completeness_declaration.clone(),
1432 has_signature: doc.signature.is_some(),
1433 has_citations: doc.citations_count > 0,
1434 citations_count: doc.citations_count,
1435 }
1436 }
1437
1438 #[must_use]
1450 pub fn quality_score(&self, is_cyclonedx: bool) -> f32 {
1451 let mut score = 0.0;
1452 let mut total_weight = 0.0;
1453
1454 let completeness_declared =
1455 self.completeness_declaration != CompletenessDeclaration::Unknown;
1456
1457 let checks: &[(bool, f32)] = &[
1458 (self.has_tool_creator, 15.0),
1459 (self.has_tool_version, 5.0),
1460 (self.has_org_creator, 12.0),
1461 (self.has_contact_email, 8.0),
1462 (self.has_serial_number, 8.0),
1463 (self.has_document_name, 5.0),
1464 (self.has_primary_component, 12.0),
1465 (completeness_declared, 8.0),
1466 (self.has_signature, 5.0),
1467 ];
1468
1469 for &(present, weight) in checks {
1470 if present {
1471 score += weight;
1472 }
1473 total_weight += weight;
1474 }
1475
1476 if is_cyclonedx {
1478 let weight = 10.0;
1479 if self.lifecycle_phase.is_some() {
1480 score += weight;
1481 }
1482 total_weight += weight;
1483
1484 let citations_weight = 5.0;
1486 if self.has_citations {
1487 score += citations_weight;
1488 }
1489 total_weight += citations_weight;
1490 }
1491
1492 if total_weight > 0.0 {
1493 (score / total_weight) * 100.0
1494 } else {
1495 0.0
1496 }
1497 }
1498}
1499
1500#[derive(Debug, Clone, Serialize, Deserialize)]
1506pub struct AuditabilityMetrics {
1507 pub components_with_vcs: usize,
1509 pub components_with_website: usize,
1511 pub components_with_advisories: usize,
1513 pub components_with_any_external_ref: usize,
1515 pub has_security_contact: bool,
1517 pub has_vuln_disclosure_url: bool,
1519}
1520
1521impl AuditabilityMetrics {
1522 #[must_use]
1524 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1525 let mut with_vcs = 0;
1526 let mut with_website = 0;
1527 let mut with_advisories = 0;
1528 let mut with_any = 0;
1529
1530 for comp in sbom.components.values() {
1531 if comp.external_refs.is_empty() {
1532 continue;
1533 }
1534 with_any += 1;
1535
1536 let has_vcs = comp
1537 .external_refs
1538 .iter()
1539 .any(|r| r.ref_type == ExternalRefType::Vcs);
1540 let has_website = comp
1541 .external_refs
1542 .iter()
1543 .any(|r| r.ref_type == ExternalRefType::Website);
1544 let has_advisories = comp
1545 .external_refs
1546 .iter()
1547 .any(|r| r.ref_type == ExternalRefType::Advisories);
1548
1549 if has_vcs {
1550 with_vcs += 1;
1551 }
1552 if has_website {
1553 with_website += 1;
1554 }
1555 if has_advisories {
1556 with_advisories += 1;
1557 }
1558 }
1559
1560 Self {
1561 components_with_vcs: with_vcs,
1562 components_with_website: with_website,
1563 components_with_advisories: with_advisories,
1564 components_with_any_external_ref: with_any,
1565 has_security_contact: sbom.document.security_contact.is_some(),
1566 has_vuln_disclosure_url: sbom.document.vulnerability_disclosure_url.is_some(),
1567 }
1568 }
1569
1570 #[must_use]
1574 pub fn quality_score(&self, total_components: usize) -> f32 {
1575 if total_components == 0 {
1576 return 0.0;
1577 }
1578
1579 let ref_coverage =
1581 (self.components_with_any_external_ref as f32 / total_components as f32) * 40.0;
1582 let vcs_coverage = (self.components_with_vcs as f32 / total_components as f32) * 20.0;
1583
1584 let security_contact_score = if self.has_security_contact { 20.0 } else { 0.0 };
1586 let disclosure_score = if self.has_vuln_disclosure_url {
1587 20.0
1588 } else {
1589 0.0
1590 };
1591
1592 (ref_coverage + vcs_coverage + security_contact_score + disclosure_score).min(100.0)
1593 }
1594}
1595
1596#[derive(Debug, Clone, Serialize, Deserialize)]
1602pub struct LifecycleMetrics {
1603 pub eol_components: usize,
1605 pub stale_components: usize,
1607 pub deprecated_components: usize,
1609 pub archived_components: usize,
1611 pub outdated_components: usize,
1613 pub enriched_components: usize,
1615 pub enrichment_coverage: f32,
1617}
1618
1619impl LifecycleMetrics {
1620 #[must_use]
1626 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1627 let total = sbom.components.len();
1628 let mut eol = 0;
1629 let mut stale = 0;
1630 let mut deprecated = 0;
1631 let mut archived = 0;
1632 let mut outdated = 0;
1633 let mut enriched = 0;
1634
1635 for comp in sbom.components.values() {
1636 let has_lifecycle_data = comp.eol.is_some() || comp.staleness.is_some();
1637 if has_lifecycle_data {
1638 enriched += 1;
1639 }
1640
1641 if let Some(ref eol_info) = comp.eol
1642 && eol_info.status == EolStatus::EndOfLife
1643 {
1644 eol += 1;
1645 }
1646
1647 if let Some(ref stale_info) = comp.staleness {
1648 if matches!(
1649 stale_info.level,
1650 StalenessLevel::Stale | StalenessLevel::Abandoned
1651 ) {
1652 stale += 1;
1653 }
1654 if stale_info.level == StalenessLevel::Deprecated || stale_info.is_deprecated {
1659 deprecated += 1;
1660 }
1661 if stale_info.level == StalenessLevel::Archived || stale_info.is_archived {
1662 archived += 1;
1663 }
1664 if stale_info.latest_version.is_some() {
1665 outdated += 1;
1666 }
1667 }
1668 }
1669
1670 let coverage = if total > 0 {
1671 (enriched as f32 / total as f32) * 100.0
1672 } else {
1673 0.0
1674 };
1675
1676 Self {
1677 eol_components: eol,
1678 stale_components: stale,
1679 deprecated_components: deprecated,
1680 archived_components: archived,
1681 outdated_components: outdated,
1682 enriched_components: enriched,
1683 enrichment_coverage: coverage,
1684 }
1685 }
1686
1687 #[must_use]
1689 pub fn has_data(&self) -> bool {
1690 self.enriched_components > 0
1691 }
1692
1693 #[must_use]
1698 pub fn quality_score(&self) -> Option<f32> {
1699 if !self.has_data() {
1700 return None;
1701 }
1702
1703 let mut score = 100.0_f32;
1704
1705 score -= (self.eol_components as f32 * 15.0).min(60.0);
1707 score -= (self.stale_components as f32 * 5.0).min(30.0);
1709 score -= ((self.deprecated_components + self.archived_components) as f32 * 3.0).min(20.0);
1711 score -= (self.outdated_components as f32 * 1.0).min(10.0);
1713
1714 Some(score.clamp(0.0, 100.0))
1715 }
1716}
1717
1718#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1728pub struct CryptographyMetrics {
1729 pub total_crypto_components: usize,
1731 pub algorithms_count: usize,
1733 pub certificates_count: usize,
1735 pub keys_count: usize,
1737 pub protocols_count: usize,
1739 pub quantum_safe_count: usize,
1741 pub quantum_vulnerable_count: usize,
1743 pub weak_algorithm_count: usize,
1745 pub hybrid_pqc_count: usize,
1747 pub expired_certificates: usize,
1749 pub expiring_soon_certificates: usize,
1751 pub compromised_keys: usize,
1753 pub inadequate_key_sizes: usize,
1755 pub weak_algorithm_names: Vec<String>,
1757
1758 pub algorithms_with_oid: usize,
1761 pub algorithms_with_family: usize,
1763 pub algorithms_with_primitive: usize,
1765 pub algorithms_with_security_level: usize,
1767
1768 pub certs_with_signature_algo_ref: usize,
1771 pub keys_with_algorithm_ref: usize,
1773 pub protocols_with_cipher_suites: usize,
1775
1776 pub keys_with_state: usize,
1779 pub keys_with_protection: usize,
1781 pub keys_with_lifecycle_dates: usize,
1783
1784 pub certs_with_validity_dates: usize,
1787}
1788
1789impl CryptographyMetrics {
1790 #[must_use]
1792 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1793 let mut m = Self::default();
1794
1795 for comp in sbom.components.values() {
1796 if comp.component_type != ComponentType::Cryptographic {
1797 continue;
1798 }
1799 let Some(cp) = &comp.crypto_properties else {
1800 continue;
1806 };
1807 m.total_crypto_components += 1;
1808
1809 match cp.asset_type {
1810 CryptoAssetType::Algorithm => {
1811 m.algorithms_count += 1;
1812 if cp.oid.is_some() {
1813 m.algorithms_with_oid += 1;
1814 }
1815 if let Some(algo) = &cp.algorithm_properties {
1816 if algo.algorithm_family.is_some() {
1817 m.algorithms_with_family += 1;
1818 }
1819 if !matches!(algo.primitive, CryptoPrimitive::Other(_)) {
1820 m.algorithms_with_primitive += 1;
1821 }
1822 if algo.classical_security_level.is_some()
1823 || algo.nist_quantum_security_level.is_some()
1824 {
1825 m.algorithms_with_security_level += 1;
1826 }
1827 if algo.is_classical_quantum_vulnerable()
1837 || algo.nist_quantum_security_level == Some(0)
1838 {
1839 m.quantum_vulnerable_count += 1;
1840 } else if algo.is_quantum_safe() {
1841 m.quantum_safe_count += 1;
1842 }
1843 if algo.is_weak_by_name(&comp.name) {
1844 m.weak_algorithm_count += 1;
1845 m.weak_algorithm_names.push(comp.name.clone());
1846 }
1847 if algo.is_hybrid_pqc() {
1848 m.hybrid_pqc_count += 1;
1849 }
1850 }
1851 }
1852 CryptoAssetType::Certificate => {
1853 m.certificates_count += 1;
1854 if let Some(cert) = &cp.certificate_properties {
1855 if cert.not_valid_before.is_some() && cert.not_valid_after.is_some() {
1856 m.certs_with_validity_dates += 1;
1857 }
1858 if cert.signature_algorithm_ref.is_some() {
1859 m.certs_with_signature_algo_ref += 1;
1860 }
1861 if cert.is_expired() {
1862 m.expired_certificates += 1;
1863 } else if cert.is_expiring_soon(90) {
1864 m.expiring_soon_certificates += 1;
1865 }
1866 }
1867 }
1868 CryptoAssetType::RelatedCryptoMaterial => {
1869 m.keys_count += 1;
1870 if let Some(mat) = &cp.related_crypto_material_properties {
1871 if mat.state.is_some() {
1872 m.keys_with_state += 1;
1873 }
1874 if mat.secured_by.is_some() {
1875 m.keys_with_protection += 1;
1876 }
1877 if mat.creation_date.is_some() || mat.activation_date.is_some() {
1878 m.keys_with_lifecycle_dates += 1;
1879 }
1880 if mat.algorithm_ref.is_some() {
1881 m.keys_with_algorithm_ref += 1;
1882 }
1883 if mat.state == Some(CryptoMaterialState::Compromised) {
1884 m.compromised_keys += 1;
1885 }
1886 if let Some(size) = mat.size {
1894 let is_symmetric = matches!(
1895 mat.material_type,
1896 crate::model::CryptoMaterialType::SymmetricKey
1897 | crate::model::CryptoMaterialType::SecretKey
1898 );
1899 const STRONG_ECC_SIZES: &[u32] = &[255, 256, 384, 448, 521];
1907 let inadequate = if is_symmetric {
1908 size < 128
1909 } else if STRONG_ECC_SIZES.contains(&size) {
1910 false
1911 } else {
1912 size < 2048
1913 };
1914 if inadequate {
1915 m.inadequate_key_sizes += 1;
1916 }
1917 }
1918 }
1919 }
1920 CryptoAssetType::Protocol => {
1921 m.protocols_count += 1;
1922 if let Some(proto) = &cp.protocol_properties
1923 && !proto.cipher_suites.is_empty()
1924 {
1925 m.protocols_with_cipher_suites += 1;
1926 }
1927 }
1928 _ => {}
1929 }
1930 }
1931
1932 m
1933 }
1934
1935 #[must_use]
1937 pub fn has_data(&self) -> bool {
1938 self.total_crypto_components > 0
1939 }
1940
1941 #[must_use]
1946 pub fn quantum_readiness_score(&self) -> Option<f32> {
1947 if self.algorithms_count == 0 {
1948 return None;
1949 }
1950 Some((self.quantum_safe_count as f32 / self.algorithms_count as f32) * 100.0)
1951 }
1952
1953 #[must_use]
1955 pub fn quality_score(&self) -> Option<f32> {
1956 if !self.has_data() {
1957 return None;
1958 }
1959
1960 let classified = self.algorithms_count
1967 + self.certificates_count
1968 + self.keys_count
1969 + self.protocols_count;
1970 if classified == 0 {
1971 return Some(0.0);
1972 }
1973
1974 let mut score = 100.0_f32;
1975
1976 score -= (self.weak_algorithm_count as f32 * 15.0).min(50.0);
1978 score -= (self.quantum_vulnerable_count as f32 * 8.0).min(40.0);
1980 score -= (self.expired_certificates as f32 * 10.0).min(30.0);
1982 score -= (self.compromised_keys as f32 * 20.0).min(40.0);
1984 score -= (self.inadequate_key_sizes as f32 * 5.0).min(20.0);
1986 score -= (self.expiring_soon_certificates as f32 * 3.0).min(15.0);
1988 let linkable = self.certificates_count + self.keys_count + self.protocols_count;
1995 if linkable > 0 {
1996 let resolved = self.certs_with_signature_algo_ref
1997 + self.keys_with_algorithm_ref
1998 + self.protocols_with_cipher_suites;
1999 let unresolved_pct = 1.0 - (resolved as f32 / linkable as f32);
2000 score -= unresolved_pct * 30.0;
2001 }
2002 score += (self.hybrid_pqc_count as f32 * 2.0).min(10.0);
2004
2005 Some(score.clamp(0.0, 100.0))
2006 }
2007
2008 #[must_use]
2012 pub fn crypto_completeness_score(&self) -> f32 {
2013 if self.algorithms_count == 0 {
2014 return 100.0;
2015 }
2016 let family_pct = self.algorithms_with_family as f32 / self.algorithms_count as f32;
2017 let primitive_pct = self.algorithms_with_primitive as f32 / self.algorithms_count as f32;
2018 let level_pct = self.algorithms_with_security_level as f32 / self.algorithms_count as f32;
2019 (family_pct * 40.0 + primitive_pct * 30.0 + level_pct * 30.0).clamp(0.0, 100.0)
2020 }
2021
2022 #[must_use]
2024 pub fn crypto_identifier_score(&self) -> f32 {
2025 if self.algorithms_count == 0 {
2026 return 100.0;
2027 }
2028 let oid_pct = self.algorithms_with_oid as f32 / self.algorithms_count as f32;
2029 (oid_pct * 100.0).clamp(0.0, 100.0)
2030 }
2031
2032 #[must_use]
2034 pub fn algorithm_strength_score(&self) -> f32 {
2035 if self.algorithms_count == 0 {
2036 return 100.0;
2037 }
2038 let mut score = 100.0_f32;
2039 score -= (self.weak_algorithm_count as f32 * 15.0).min(60.0);
2040 score -= (self.inadequate_key_sizes as f32 * 8.0).min(30.0);
2041 if self.algorithms_count > 0 {
2042 let vuln_pct = self.quantum_vulnerable_count as f32 / self.algorithms_count as f32;
2043 score -= vuln_pct * 30.0;
2044 }
2045 score.clamp(0.0, 100.0)
2046 }
2047
2048 #[must_use]
2050 pub fn crypto_dependency_score(&self) -> f32 {
2051 let linkable = self.certificates_count + self.keys_count + self.protocols_count;
2052 if linkable == 0 {
2053 return 100.0;
2054 }
2055 let resolved = self.certs_with_signature_algo_ref
2056 + self.keys_with_algorithm_ref
2057 + self.protocols_with_cipher_suites;
2058 let pct = resolved as f32 / linkable as f32;
2059 (pct * 100.0).clamp(0.0, 100.0)
2060 }
2061
2062 #[must_use]
2064 pub fn crypto_lifecycle_score(&self) -> f32 {
2065 let mut score = 100.0_f32;
2066
2067 if self.keys_count > 0 {
2068 let state_pct = self.keys_with_state as f32 / self.keys_count as f32;
2069 let protection_pct = self.keys_with_protection as f32 / self.keys_count as f32;
2070 let lifecycle_pct = self.keys_with_lifecycle_dates as f32 / self.keys_count as f32;
2071 let key_completeness =
2072 (state_pct * 0.4 + protection_pct * 0.3 + lifecycle_pct * 0.3) * 100.0;
2073 score = score * 0.5 + key_completeness * 0.5;
2074 score -= (self.compromised_keys as f32 * 20.0).min(40.0);
2075 score -= (self.inadequate_key_sizes as f32 * 5.0).min(20.0);
2076 }
2077
2078 if self.certificates_count > 0 {
2079 let validity_pct =
2080 self.certs_with_validity_dates as f32 / self.certificates_count as f32;
2081 score -= (1.0 - validity_pct) * 15.0;
2082 score -= (self.expired_certificates as f32 * 15.0).min(45.0);
2083 score -= (self.expiring_soon_certificates as f32 * 5.0).min(20.0);
2084 }
2085
2086 score.clamp(0.0, 100.0)
2087 }
2088
2089 #[must_use]
2094 pub fn pqc_readiness_score(&self) -> Option<f32> {
2095 if self.algorithms_count == 0 {
2096 return None;
2097 }
2098 let mut score = 0.0_f32;
2099 let qs_pct = self.quantum_safe_count as f32 / self.algorithms_count as f32;
2100 score += qs_pct * 60.0;
2101 if self.hybrid_pqc_count > 0 {
2102 score += 15.0;
2103 }
2104 if self.weak_algorithm_count == 0 {
2105 score += 25.0;
2106 } else {
2107 score += (25.0 - self.weak_algorithm_count as f32 * 5.0).max(0.0);
2108 }
2109 Some(score.clamp(0.0, 100.0))
2110 }
2111
2112 #[must_use]
2114 pub fn quantum_readiness_pct(&self) -> f32 {
2115 if self.algorithms_count == 0 {
2116 return 0.0;
2117 }
2118 (self.quantum_safe_count as f32 / self.algorithms_count as f32) * 100.0
2119 }
2120
2121 #[must_use]
2123 pub const fn cbom_category_labels() -> [&'static str; 8] {
2124 ["Crpt", "OIDs", "Algo", "Refs", "Life", "PQC", "Prov", "Lic"]
2125 }
2126
2127 #[must_use]
2133 pub const fn cbom_category_names() -> [&'static str; 8] {
2134 [
2135 "Crypto Compl",
2136 "OIDs",
2137 "Algo Strength",
2138 "Crypto Refs",
2139 "Crypto Life",
2140 "PQC Readiness",
2141 "Provenance",
2142 "Licenses",
2143 ]
2144 }
2145}
2146
2147fn is_valid_purl(purl: &str) -> bool {
2152 purl.starts_with("pkg:") && purl.contains('/')
2154}
2155
2156fn extract_ecosystem_from_purl(purl: &str) -> Option<String> {
2157 if let Some(rest) = purl.strip_prefix("pkg:")
2159 && let Some(slash_idx) = rest.find('/')
2160 {
2161 return Some(rest[..slash_idx].to_string());
2162 }
2163 None
2164}
2165
2166fn is_valid_cpe(cpe: &str) -> bool {
2167 cpe.starts_with("cpe:2.3:") || cpe.starts_with("cpe:/")
2169}
2170
2171fn is_deprecated_spdx_license(expr: &str) -> bool {
2176 const DEPRECATED: &[&str] = &[
2177 "GPL-2.0",
2178 "GPL-2.0+",
2179 "GPL-3.0",
2180 "GPL-3.0+",
2181 "LGPL-2.0",
2182 "LGPL-2.0+",
2183 "LGPL-2.1",
2184 "LGPL-2.1+",
2185 "LGPL-3.0",
2186 "LGPL-3.0+",
2187 "AGPL-1.0",
2188 "AGPL-3.0",
2189 "GFDL-1.1",
2190 "GFDL-1.2",
2191 "GFDL-1.3",
2192 "BSD-2-Clause-FreeBSD",
2193 "BSD-2-Clause-NetBSD",
2194 "eCos-2.0",
2195 "Nunit",
2196 "StandardML-NJ",
2197 "wxWindows",
2198 ];
2199 let trimmed = expr.trim();
2200 DEPRECATED.contains(&trimmed)
2201}
2202
2203fn is_restrictive_license(expr: &str) -> bool {
2208 let trimmed = expr.trim().to_uppercase();
2209 trimmed.starts_with("GPL")
2210 || trimmed.starts_with("LGPL")
2211 || trimmed.starts_with("AGPL")
2212 || trimmed.starts_with("EUPL")
2213 || trimmed.starts_with("SSPL")
2214 || trimmed.starts_with("OSL")
2215 || trimmed.starts_with("CPAL")
2216 || trimmed.starts_with("CC-BY-SA")
2217 || trimmed.starts_with("CC-BY-NC")
2218}
2219
2220#[cfg(test)]
2221mod tests {
2222 use super::*;
2223
2224 #[test]
2230 fn file_components_do_not_dilute_completeness() {
2231 use crate::model::{Component, ComponentType, NormalizedSbom};
2232 let mut sbom = NormalizedSbom::default();
2233 let pkg =
2234 Component::new("app".to_string(), "app@1".to_string()).with_version("1.0".to_string());
2235 sbom.add_component(pkg);
2236 for i in 0..10 {
2237 let mut f = Component::new(format!("file-{i}"), format!("file-{i}@x"));
2238 f.component_type = ComponentType::File;
2239 sbom.add_component(f);
2240 }
2241
2242 let m = CompletenessMetrics::from_sbom(&sbom);
2243 assert!(
2244 (m.components_with_version - 100.0).abs() < f32::EPSILON,
2245 "10 files must not dilute the package's 100% version coverage, got {}",
2246 m.components_with_version
2247 );
2248 }
2249
2250 #[test]
2254 fn file_components_do_not_dilute_identifier_score() {
2255 use crate::model::{Component, ComponentType, NormalizedSbom};
2256 let mut sbom = NormalizedSbom::default();
2257 let mut pkg = Component::new("app".to_string(), "app@1".to_string());
2258 pkg.identifiers.purl = Some("pkg:cargo/app@1.0.0".to_string());
2259 sbom.add_component(pkg);
2260 for i in 0..30 {
2261 let mut f = Component::new(format!("file-{i}"), format!("file-{i}@x"));
2262 f.component_type = ComponentType::File;
2263 sbom.add_component(f);
2264 }
2265
2266 let im = IdentifierMetrics::from_sbom(&sbom);
2267 assert_eq!(im.file_components, 30);
2268 assert_eq!(
2269 im.missing_all_identifiers, 0,
2270 "exempt files must not count as identifier-less"
2271 );
2272 let score = im.quality_score(sbom.components.len());
2273 assert!(
2274 (score - 100.0).abs() < 0.01,
2275 "30 files must not dilute the package's identifier coverage, got {score}"
2276 );
2277 }
2278
2279 #[test]
2283 fn file_components_do_not_dilute_license_score() {
2284 use crate::model::{Component, ComponentType, LicenseExpression, NormalizedSbom};
2285 let mut sbom = NormalizedSbom::default();
2286 let mut pkg = Component::new("app".to_string(), "app@1".to_string());
2287 pkg.licenses
2288 .add_declared(LicenseExpression::new("MIT".to_string()));
2289 sbom.add_component(pkg);
2290 for i in 0..30 {
2291 let mut f = Component::new(format!("file-{i}"), format!("file-{i}@x"));
2292 f.component_type = ComponentType::File;
2293 f.licenses
2294 .add_declared(LicenseExpression::new("GPL-2.0-only".to_string()));
2295 sbom.add_component(f);
2296 }
2297
2298 let lm = LicenseMetrics::from_sbom(&sbom);
2299 assert_eq!(lm.file_components, 30);
2300 assert_eq!(lm.with_declared, 1, "files are exempt from counters");
2301 assert!(
2302 lm.unique_licenses.contains(&"GPL-2.0-only".to_string()),
2303 "file license strings still feed the informational lists"
2304 );
2305 let score = lm.quality_score(sbom.components.len());
2307 assert!(
2308 (score - 90.0).abs() < 0.01,
2309 "30 files must not dilute the package's license coverage, got {score}"
2310 );
2311 }
2312
2313 #[test]
2316 fn file_components_do_not_dilute_dependency_coverage() {
2317 use crate::model::{
2318 Component, ComponentType, DependencyEdge, DependencyType, NormalizedSbom,
2319 };
2320 let mut sbom = NormalizedSbom::default();
2321 let app = Component::new("app".to_string(), "app@1".to_string());
2322 let lib = Component::new("lib".to_string(), "lib@1".to_string());
2323 let app_id = app.canonical_id.clone();
2324 let lib_id = lib.canonical_id.clone();
2325 sbom.add_component(app);
2326 sbom.add_component(lib);
2327 sbom.add_edge(DependencyEdge::new(
2328 app_id.clone(),
2329 lib_id,
2330 DependencyType::DependsOn,
2331 ));
2332 for i in 0..30 {
2333 let mut f = Component::new(format!("file-{i}"), format!("file-{i}@x"));
2334 f.component_type = ComponentType::File;
2335 let file_id = f.canonical_id.clone();
2336 sbom.add_component(f);
2337 sbom.add_edge(DependencyEdge::new(
2339 app_id.clone(),
2340 file_id,
2341 DependencyType::Contains,
2342 ));
2343 }
2344
2345 let dm = DependencyMetrics::from_sbom(&sbom);
2346 assert_eq!(dm.file_components, 30);
2347 let score = dm.quality_score(sbom.components.len());
2348 assert!(
2349 (score - 100.0).abs() < 0.01,
2350 "30 contained files must not dilute dependency coverage, got {score}"
2351 );
2352 }
2353
2354 #[test]
2358 fn property_less_crypto_component_is_not_crypto_inventory() {
2359 use crate::model::{Component, ComponentType, NormalizedSbom};
2360 let mut sbom = NormalizedSbom::default();
2361 let mut c = Component::new("mystery-crypto".to_string(), "mc@1".to_string());
2362 c.component_type = ComponentType::Cryptographic;
2363 sbom.add_component(c);
2365
2366 let m = CryptographyMetrics::from_sbom(&sbom);
2367 assert_eq!(
2368 m.total_crypto_components, 0,
2369 "undocumented crypto is not inventory"
2370 );
2371 assert!(
2372 !m.has_data(),
2373 "has_data must be false → CBOM scores are N/A, not 100"
2374 );
2375 }
2376
2377 #[test]
2383 fn unclassifiable_crypto_inventory_scores_degraded_not_perfect() {
2384 use crate::model::{
2385 Component, ComponentType, CryptoAssetType, CryptoProperties, NormalizedSbom,
2386 };
2387 let mut sbom = NormalizedSbom::default();
2388 let mut c = Component::new("mystery-asset".to_string(), "ma@1".to_string());
2389 c.component_type = ComponentType::Cryptographic;
2390 c.crypto_properties = Some(CryptoProperties::new(CryptoAssetType::Other(
2391 "unknown".to_string(),
2392 )));
2393 sbom.add_component(c);
2394
2395 let m = CryptographyMetrics::from_sbom(&sbom);
2396 assert!(m.has_data(), "documented crypto assets are inventory");
2397 assert_eq!(m.algorithms_count, 0);
2398 assert_eq!(
2399 m.quality_score(),
2400 Some(0.0),
2401 "all-unclassifiable inventory must read degraded, not 100"
2402 );
2403 }
2404
2405 #[test]
2411 fn unresolved_crypto_references_degrade_the_quality_score() {
2412 use crate::model::{
2413 CertificateProperties, Component, ComponentType, CryptoAssetType, CryptoProperties,
2414 NormalizedSbom,
2415 };
2416 let mut sbom = NormalizedSbom::default();
2417 let mut c = Component::new("opaque-cert".to_string(), "oc@1".to_string());
2418 c.component_type = ComponentType::Cryptographic;
2419 c.crypto_properties = Some(
2421 CryptoProperties::new(CryptoAssetType::Certificate)
2422 .with_certificate_properties(CertificateProperties::new()),
2423 );
2424 sbom.add_component(c);
2425
2426 let m = CryptographyMetrics::from_sbom(&sbom);
2427 assert_eq!(m.certificates_count, 1);
2428 assert_eq!(m.certs_with_signature_algo_ref, 0);
2429 let score = m.quality_score().expect("has data");
2430 assert!(
2431 score <= 70.0,
2432 "fully-unresolved references must degrade the score, got {score}"
2433 );
2434 }
2435
2436 #[test]
2440 fn ecc_key_sizes_are_not_falsely_inadequate() {
2441 use crate::model::{
2442 Component, ComponentType, CryptoAssetType, CryptoMaterialType, CryptoProperties,
2443 NormalizedSbom, RelatedCryptoMaterialProperties,
2444 };
2445 let key = |name: &str, mtype: CryptoMaterialType, size: u32| {
2446 let mut c = Component::new(name.to_string(), format!("{name}@1"));
2447 c.component_type = ComponentType::Cryptographic;
2448 let mat = RelatedCryptoMaterialProperties::new(mtype).with_size(size);
2449 c.crypto_properties = Some(
2450 CryptoProperties::new(CryptoAssetType::RelatedCryptoMaterial)
2451 .with_related_crypto_material_properties(mat),
2452 );
2453 c
2454 };
2455 let mut sbom = NormalizedSbom::default();
2456 sbom.add_component(key("ecc-p256", CryptoMaterialType::PublicKey, 256));
2457 sbom.add_component(key("ecc-p384", CryptoMaterialType::PublicKey, 384));
2458 sbom.add_component(key("ecc-p521", CryptoMaterialType::PublicKey, 521));
2459 sbom.add_component(key("rsa-1024", CryptoMaterialType::PublicKey, 1024));
2460 sbom.add_component(key("weak-512", CryptoMaterialType::PublicKey, 512));
2463 sbom.add_component(key("aes-256", CryptoMaterialType::SymmetricKey, 256));
2464
2465 let m = CryptographyMetrics::from_sbom(&sbom);
2466 assert_eq!(
2467 m.inadequate_key_sizes, 2,
2468 "RSA-1024 and the 512-bit key are inadequate; strong ECC (256/384/521) is not"
2469 );
2470 }
2471
2472 #[test]
2476 fn noassertion_only_component_is_not_licensed() {
2477 use crate::model::{Component, LicenseExpression, NormalizedSbom};
2478 let mut sbom = NormalizedSbom::default();
2479 let mut c = Component::new("no-info".to_string(), "ni@1".to_string());
2480 c.licenses
2481 .add_declared(LicenseExpression::new("NOASSERTION".to_string()));
2482 sbom.add_component(c);
2483 let mut licensed = Component::new("real".to_string(), "real@1".to_string());
2484 licensed
2485 .licenses
2486 .add_declared(LicenseExpression::new("MIT".to_string()));
2487 sbom.add_component(licensed);
2488
2489 let completeness = CompletenessMetrics::from_sbom(&sbom);
2490 assert!(
2491 (completeness.components_with_licenses - 50.0).abs() < 0.01,
2492 "1 of 2 components has real license info, got {}",
2493 completeness.components_with_licenses
2494 );
2495
2496 let lm = LicenseMetrics::from_sbom(&sbom);
2497 assert_eq!(
2498 lm.with_declared, 1,
2499 "NOASSERTION-only must not count as declared"
2500 );
2501 assert_eq!(lm.noassertion_count, 1);
2502 assert_eq!(lm.valid_spdx_expressions, 1);
2503 }
2504
2505 #[test]
2509 fn multi_license_component_does_not_inflate_spdx_bonus() {
2510 use crate::model::{Component, LicenseExpression, NormalizedSbom};
2511 let mut sbom = NormalizedSbom::default();
2512 let mut multi = Component::new("multi".to_string(), "m@1".to_string());
2513 for id in ["MIT", "Apache-2.0", "BSD-3-Clause"] {
2514 multi
2515 .licenses
2516 .add_declared(LicenseExpression::new(id.to_string()));
2517 }
2518 sbom.add_component(multi);
2519 sbom.add_component(Component::new("bare".to_string(), "b@1".to_string()));
2521
2522 let lm = LicenseMetrics::from_sbom(&sbom);
2523 assert_eq!(lm.with_declared, 1);
2524 assert_eq!(lm.valid_spdx_expressions, 1, "per-component, not per-entry");
2525 assert!(
2526 lm.valid_spdx_expressions <= lm.with_declared,
2527 "spdx ratio numerator must not exceed its denominator"
2528 );
2529 let score = lm.quality_score(2);
2532 assert!(
2533 (score - 60.0).abs() < 0.01,
2534 "expected 60 (half coverage + full SPDX bonus), got {score}"
2535 );
2536 }
2537
2538 #[test]
2541 fn multi_cpe_component_does_not_mask_identifierless_ones() {
2542 use crate::model::{Component, NormalizedSbom};
2543 let mut sbom = NormalizedSbom::default();
2544 let mut multi = Component::new("multi-cpe".to_string(), "mc@1".to_string());
2545 for i in 0..3 {
2546 multi
2547 .identifiers
2548 .cpe
2549 .push(format!("cpe:2.3:a:vendor:product{i}:1.0:*:*:*:*:*:*:*"));
2550 }
2551 sbom.add_component(multi);
2552 sbom.add_component(Component::new("bare-1".to_string(), "b1@1".to_string()));
2553 sbom.add_component(Component::new("bare-2".to_string(), "b2@1".to_string()));
2554
2555 let im = IdentifierMetrics::from_sbom(&sbom);
2556 assert_eq!(im.components_with_valid_id, 1);
2557 assert_eq!(im.valid_cpes, 1, "per-component CPE count");
2558 assert_eq!(im.missing_all_identifiers, 2);
2559 let score = im.quality_score(3);
2560 assert!(
2561 (score - 33.33).abs() < 0.1,
2562 "1/3 coverage expected, got {score} (old per-entry count gave 100)"
2563 );
2564 }
2565
2566 #[test]
2569 fn lifecycle_does_not_double_count_deprecated() {
2570 use crate::model::{Component, NormalizedSbom, StalenessInfo, StalenessLevel};
2571 let mut sbom = NormalizedSbom::default();
2572 let mut c = Component::new("old-pkg".to_string(), "op@1".to_string());
2573 c.staleness = Some(StalenessInfo {
2574 level: StalenessLevel::Deprecated,
2575 last_published: None,
2576 is_deprecated: true, is_archived: false,
2578 deprecation_message: None,
2579 days_since_update: None,
2580 latest_version: None,
2581 });
2582 sbom.add_component(c);
2583
2584 let lm = LifecycleMetrics::from_sbom(&sbom);
2585 assert_eq!(
2586 lm.deprecated_components, 1,
2587 "one deprecated component must count once, not twice"
2588 );
2589 }
2590
2591 #[test]
2595 fn bare_vuln_disclosure_earns_baseline_credit() {
2596 use crate::model::{Component, NormalizedSbom, VulnerabilityRef, VulnerabilitySource};
2597 let mut sbom = NormalizedSbom::default();
2598 let mut c = Component::new("app".to_string(), "app@1".to_string());
2599 c.vulnerabilities.push(VulnerabilityRef::new(
2600 "CVE-2024-0001".to_string(),
2601 VulnerabilitySource::Osv,
2602 ));
2603 sbom.add_component(c);
2604
2605 let vm = VulnerabilityMetrics::from_sbom(&sbom);
2606 let score = vm.documentation_score().expect("vuln data present");
2607 assert!(
2608 (score - 40.0).abs() < 0.01,
2609 "bare disclosure must earn the 40-pt baseline, got {score}"
2610 );
2611
2612 let empty = NormalizedSbom::default();
2614 assert!(
2615 VulnerabilityMetrics::from_sbom(&empty)
2616 .documentation_score()
2617 .is_none()
2618 );
2619 }
2620
2621 #[test]
2624 fn provenance_score_has_no_wall_clock_term() {
2625 let fresh = ProvenanceMetrics {
2626 is_fresh: true,
2627 ..base_provenance()
2628 };
2629 let stale = ProvenanceMetrics {
2630 is_fresh: false,
2631 ..base_provenance()
2632 };
2633 assert!(
2634 (fresh.quality_score(true) - stale.quality_score(true)).abs() < f32::EPSILON,
2635 "is_fresh must not affect the score"
2636 );
2637 }
2638
2639 fn base_provenance() -> ProvenanceMetrics {
2640 ProvenanceMetrics {
2641 has_tool_creator: true,
2642 has_tool_version: false,
2643 has_org_creator: false,
2644 has_contact_email: false,
2645 has_serial_number: false,
2646 has_document_name: false,
2647 timestamp_age_days: 0,
2648 timestamp_known: true,
2649 is_fresh: false,
2650 has_primary_component: false,
2651 lifecycle_phase: None,
2652 completeness_declaration: CompletenessDeclaration::Unknown,
2653 has_signature: false,
2654 has_citations: false,
2655 citations_count: 0,
2656 }
2657 }
2658
2659 #[test]
2663 fn cyclic_graph_coverage_does_not_absorb_cycle_penalty() {
2664 use crate::model::{Component, DependencyEdge, DependencyType, NormalizedSbom};
2665 let mut sbom = NormalizedSbom::default();
2666 let n = 5;
2667 let mut ids = Vec::new();
2668 for i in 0..n {
2669 let c = Component::new(format!("c{i}"), format!("c{i}@1"));
2670 ids.push(c.canonical_id.clone());
2671 sbom.add_component(c);
2672 }
2673 for i in 0..n {
2675 sbom.add_edge(DependencyEdge::new(
2676 ids[i].clone(),
2677 ids[(i + 1) % n].clone(),
2678 DependencyType::DependsOn,
2679 ));
2680 }
2681 let dm = DependencyMetrics::from_sbom(&sbom);
2682 assert!(dm.cycle_count >= 1, "ring must be detected as a cycle");
2683 let score = dm.quality_score(n);
2684 assert!(
2685 score <= 95.0,
2686 "cycle penalty must survive the clamp (raw coverage 125% \
2687 previously absorbed it), got {score}"
2688 );
2689 }
2690
2691 #[test]
2692 fn test_purl_validation() {
2693 assert!(is_valid_purl("pkg:npm/@scope/name@1.0.0"));
2694 assert!(is_valid_purl("pkg:maven/group/artifact@1.0"));
2695 assert!(!is_valid_purl("npm:something"));
2696 assert!(!is_valid_purl("invalid"));
2697 }
2698
2699 #[test]
2700 fn test_cpe_validation() {
2701 assert!(is_valid_cpe("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"));
2702 assert!(is_valid_cpe("cpe:/a:vendor:product:1.0"));
2703 assert!(!is_valid_cpe("something:else"));
2704 }
2705
2706 #[test]
2710 fn test_spdx_license_validation() {
2711 use crate::model::LicenseExpression;
2712 let valid = |e: &str| LicenseExpression::new(e.to_string()).is_valid_spdx;
2713 assert!(valid("MIT"));
2714 assert!(valid("Apache-2.0"));
2715 assert!(valid("MIT AND Apache-2.0"));
2716 assert!(valid("GPL-2.0 OR MIT"));
2717 assert!(valid("GPL-2.0-only WITH Classpath-exception-2.0"));
2718 assert!(valid("Zlib"));
2719 assert!(!valid("GARBAGE OR NONSENSE"));
2721 assert!(!valid("foo AND bar"));
2722 assert!(!valid("NOASSERTION"));
2723 }
2724
2725 #[test]
2726 fn test_strong_hash_classification() {
2727 assert!(is_strong_hash(&HashAlgorithm::Sha256));
2728 assert!(is_strong_hash(&HashAlgorithm::Sha3_256));
2729 assert!(is_strong_hash(&HashAlgorithm::Blake3));
2730 assert!(!is_strong_hash(&HashAlgorithm::Md5));
2731 assert!(!is_strong_hash(&HashAlgorithm::Sha1));
2732 assert!(!is_strong_hash(&HashAlgorithm::Other("custom".to_string())));
2733 }
2734
2735 #[test]
2736 fn test_deprecated_license_detection() {
2737 assert!(is_deprecated_spdx_license("GPL-2.0"));
2738 assert!(is_deprecated_spdx_license("LGPL-2.1"));
2739 assert!(is_deprecated_spdx_license("AGPL-3.0"));
2740 assert!(!is_deprecated_spdx_license("GPL-2.0-only"));
2741 assert!(!is_deprecated_spdx_license("MIT"));
2742 assert!(!is_deprecated_spdx_license("Apache-2.0"));
2743 }
2744
2745 #[test]
2746 fn test_restrictive_license_detection() {
2747 assert!(is_restrictive_license("GPL-3.0-only"));
2748 assert!(is_restrictive_license("LGPL-2.1-or-later"));
2749 assert!(is_restrictive_license("AGPL-3.0-only"));
2750 assert!(is_restrictive_license("EUPL-1.2"));
2751 assert!(is_restrictive_license("CC-BY-SA-4.0"));
2752 assert!(!is_restrictive_license("MIT"));
2753 assert!(!is_restrictive_license("Apache-2.0"));
2754 assert!(!is_restrictive_license("BSD-3-Clause"));
2755 }
2756
2757 #[test]
2758 fn test_hash_quality_score_no_components() {
2759 let metrics = HashQualityMetrics {
2760 components_with_any_hash: 0,
2761 components_with_strong_hash: 0,
2762 components_with_weak_only: 0,
2763 algorithm_distribution: BTreeMap::new(),
2764 total_hashes: 0,
2765 vendor_components_total: 0,
2766 vendor_components_with_hash: 0,
2767 vendor_components_with_strong_hash: 0,
2768 };
2769 assert_eq!(metrics.quality_score(0), 0.0);
2770 }
2771
2772 #[test]
2773 fn test_hash_quality_score_all_strong() {
2774 let metrics = HashQualityMetrics {
2775 components_with_any_hash: 10,
2776 components_with_strong_hash: 10,
2777 components_with_weak_only: 0,
2778 algorithm_distribution: BTreeMap::new(),
2779 total_hashes: 10,
2780 vendor_components_total: 0,
2781 vendor_components_with_hash: 0,
2782 vendor_components_with_strong_hash: 0,
2783 };
2784 assert_eq!(metrics.quality_score(10), 100.0);
2785 }
2786
2787 #[test]
2788 fn test_hash_quality_score_weak_only_penalty() {
2789 let metrics = HashQualityMetrics {
2790 components_with_any_hash: 10,
2791 components_with_strong_hash: 0,
2792 components_with_weak_only: 10,
2793 algorithm_distribution: BTreeMap::new(),
2794 total_hashes: 10,
2795 vendor_components_total: 0,
2796 vendor_components_with_hash: 0,
2797 vendor_components_with_strong_hash: 0,
2798 };
2799 assert_eq!(metrics.quality_score(10), 50.0);
2801 }
2802
2803 #[test]
2804 fn test_lifecycle_no_enrichment_returns_none() {
2805 let metrics = LifecycleMetrics {
2806 eol_components: 0,
2807 stale_components: 0,
2808 deprecated_components: 0,
2809 archived_components: 0,
2810 outdated_components: 0,
2811 enriched_components: 0,
2812 enrichment_coverage: 0.0,
2813 };
2814 assert!(!metrics.has_data());
2815 assert!(metrics.quality_score().is_none());
2816 }
2817
2818 #[test]
2819 fn test_lifecycle_with_eol_penalty() {
2820 let metrics = LifecycleMetrics {
2821 eol_components: 2,
2822 stale_components: 0,
2823 deprecated_components: 0,
2824 archived_components: 0,
2825 outdated_components: 0,
2826 enriched_components: 10,
2827 enrichment_coverage: 100.0,
2828 };
2829 assert_eq!(metrics.quality_score(), Some(70.0));
2831 }
2832
2833 #[test]
2834 fn test_cycle_detection_no_cycles() {
2835 let children: HashMap<&str, Vec<&str>> =
2836 HashMap::from([("a", vec!["b"]), ("b", vec!["c"])]);
2837 let all_nodes = vec!["a", "b", "c"];
2838 assert_eq!(detect_cycles(&all_nodes, &children), 0);
2840 }
2841
2842 #[test]
2843 fn test_cycle_detection_with_cycle() {
2844 let children: HashMap<&str, Vec<&str>> =
2845 HashMap::from([("a", vec!["b"]), ("b", vec!["c"]), ("c", vec!["a"])]);
2846 let all_nodes = vec!["a", "b", "c"];
2847 assert_eq!(detect_cycles(&all_nodes, &children), 1);
2849 }
2850
2851 #[test]
2852 fn test_cycle_detection_deep_linear_chain_no_overflow() {
2853 let n = 40_000usize;
2854 let names: Vec<String> = (0..n).map(|i| format!("node-{i}")).collect();
2855 let all_nodes: Vec<&str> = names.iter().map(String::as_str).collect();
2856 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
2857 for w in all_nodes.windows(2) {
2858 children.entry(w[0]).or_default().push(w[1]);
2859 }
2860 assert_eq!(detect_cycles(&all_nodes, &children), 0);
2861 }
2862
2863 #[test]
2864 fn test_cycle_detection_diamond_dag() {
2865 let children: HashMap<&str, Vec<&str>> =
2866 HashMap::from([("a", vec!["b", "c"]), ("b", vec!["d"]), ("c", vec!["d"])]);
2867 let all_nodes = vec!["a", "b", "c", "d"];
2868 assert_eq!(detect_cycles(&all_nodes, &children), 0);
2869 }
2870
2871 #[test]
2872 fn test_cycle_detection_multi_back_edge_scc_counts_once() {
2873 let children: HashMap<&str, Vec<&str>> = HashMap::from([
2874 ("a", vec!["b", "c"]),
2875 ("b", vec!["a", "c"]),
2876 ("c", vec!["a", "b"]),
2877 ]);
2878 let all_nodes = vec!["a", "b", "c"];
2879 assert_eq!(detect_cycles(&all_nodes, &children), 1);
2881 }
2882
2883 #[test]
2884 fn test_cycle_detection_self_loop_counts_once() {
2885 let children: HashMap<&str, Vec<&str>> =
2886 HashMap::from([("a", vec!["a", "b"]), ("b", vec!["c"])]);
2887 let all_nodes = vec!["a", "b", "c"];
2888 assert_eq!(detect_cycles(&all_nodes, &children), 1);
2889 }
2890
2891 #[test]
2892 fn test_quality_scorer_deep_chain_end_to_end() {
2893 use crate::model::{Component, DependencyEdge, DependencyType};
2894 use crate::quality::{QualityScorer, ScoringProfile};
2895
2896 let n = 40_000usize;
2897 let mut sbom = NormalizedSbom::default();
2898 let mut ids = Vec::with_capacity(n);
2899 for i in 0..n {
2900 let component = Component::new(format!("node-{i}"), format!("ref-{i}"));
2901 ids.push(component.canonical_id.clone());
2902 sbom.add_component(component);
2903 }
2904 for w in ids.windows(2) {
2905 sbom.add_edge(DependencyEdge::new(
2906 w[0].clone(),
2907 w[1].clone(),
2908 DependencyType::DependsOn,
2909 ));
2910 }
2911
2912 let report = QualityScorer::new(ScoringProfile::Standard).score(&sbom);
2913 assert!(!report.dependency_metrics.graph_analysis_skipped);
2914 assert_eq!(report.dependency_metrics.cycle_count, 0);
2915 assert_eq!(report.dependency_metrics.max_depth, Some(n - 1));
2916 }
2917
2918 #[test]
2919 fn test_depth_computation() {
2920 let children: HashMap<&str, Vec<&str>> =
2921 HashMap::from([("root", vec!["a", "b"]), ("a", vec!["c"])]);
2922 let roots = vec!["root"];
2923 let (max_d, avg_d) = compute_depth(&roots, &children);
2924 assert_eq!(max_d, Some(2)); assert!(avg_d.is_some());
2926 }
2927
2928 #[test]
2929 fn test_depth_empty_roots() {
2930 let children: HashMap<&str, Vec<&str>> = HashMap::new();
2931 let roots: Vec<&str> = vec![];
2932 let (max_d, avg_d) = compute_depth(&roots, &children);
2933 assert_eq!(max_d, None);
2934 assert_eq!(avg_d, None);
2935 }
2936
2937 #[test]
2938 fn test_provenance_quality_score() {
2939 let metrics = ProvenanceMetrics {
2940 has_tool_creator: true,
2941 has_tool_version: true,
2942 has_org_creator: true,
2943 has_contact_email: true,
2944 has_serial_number: true,
2945 has_document_name: true,
2946 timestamp_age_days: 10,
2947 timestamp_known: true,
2948 is_fresh: true,
2949 has_primary_component: true,
2950 lifecycle_phase: Some("build".to_string()),
2951 completeness_declaration: CompletenessDeclaration::Complete,
2952 has_signature: true,
2953 has_citations: true,
2954 citations_count: 3,
2955 };
2956 assert_eq!(metrics.quality_score(true), 100.0);
2958 }
2959
2960 #[test]
2961 fn test_provenance_score_without_cyclonedx() {
2962 let metrics = ProvenanceMetrics {
2963 has_tool_creator: true,
2964 has_tool_version: true,
2965 has_org_creator: true,
2966 has_contact_email: true,
2967 has_serial_number: true,
2968 has_document_name: true,
2969 timestamp_age_days: 10,
2970 timestamp_known: true,
2971 is_fresh: true,
2972 has_primary_component: true,
2973 lifecycle_phase: None,
2974 completeness_declaration: CompletenessDeclaration::Complete,
2975 has_signature: true,
2976 has_citations: false,
2977 citations_count: 0,
2978 };
2979 assert_eq!(metrics.quality_score(false), 100.0);
2981 }
2982
2983 #[test]
2984 fn test_complexity_empty_graph() {
2985 let (simplicity, level, factors) = compute_complexity(0, 0, 0, 0, 0, 0, 0);
2986 assert_eq!(simplicity, 100.0);
2987 assert_eq!(level, ComplexityLevel::Low);
2988 assert_eq!(factors.dependency_volume, 0.0);
2989 }
2990
2991 #[test]
2992 fn test_complexity_single_node() {
2993 let (simplicity, level, _) = compute_complexity(0, 1, 0, 0, 0, 1, 1);
2995 assert!(
2996 simplicity >= 80.0,
2997 "Single node simplicity {simplicity} should be >= 80"
2998 );
2999 assert_eq!(level, ComplexityLevel::Low);
3000 }
3001
3002 #[test]
3003 fn test_complexity_monotonic_edges() {
3004 let (s1, _, _) = compute_complexity(5, 10, 2, 3, 0, 1, 1);
3006 let (s2, _, _) = compute_complexity(20, 10, 2, 3, 0, 1, 1);
3007 assert!(
3008 s2 <= s1,
3009 "More edges should not increase simplicity: {s2} vs {s1}"
3010 );
3011 }
3012
3013 #[test]
3014 fn test_complexity_monotonic_cycles() {
3015 let (s1, _, _) = compute_complexity(10, 10, 2, 3, 0, 1, 1);
3016 let (s2, _, _) = compute_complexity(10, 10, 2, 3, 3, 1, 1);
3017 assert!(
3018 s2 <= s1,
3019 "More cycles should not increase simplicity: {s2} vs {s1}"
3020 );
3021 }
3022
3023 #[test]
3024 fn test_complexity_monotonic_depth() {
3025 let (s1, _, _) = compute_complexity(10, 10, 2, 3, 0, 1, 1);
3026 let (s2, _, _) = compute_complexity(10, 10, 10, 3, 0, 1, 1);
3027 assert!(
3028 s2 <= s1,
3029 "More depth should not increase simplicity: {s2} vs {s1}"
3030 );
3031 }
3032
3033 #[test]
3034 fn test_complexity_graph_skipped() {
3035 let (simplicity, _, _) = compute_complexity(100, 50, 5, 10, 2, 5, 3);
3038 assert!(simplicity >= 0.0 && simplicity <= 100.0);
3039 }
3040
3041 #[test]
3042 fn test_complexity_level_bands() {
3043 assert_eq!(ComplexityLevel::from_score(100.0), ComplexityLevel::Low);
3044 assert_eq!(ComplexityLevel::from_score(75.0), ComplexityLevel::Low);
3045 assert_eq!(ComplexityLevel::from_score(74.0), ComplexityLevel::Moderate);
3046 assert_eq!(ComplexityLevel::from_score(50.0), ComplexityLevel::Moderate);
3047 assert_eq!(ComplexityLevel::from_score(49.0), ComplexityLevel::High);
3048 assert_eq!(ComplexityLevel::from_score(25.0), ComplexityLevel::High);
3049 assert_eq!(ComplexityLevel::from_score(24.0), ComplexityLevel::VeryHigh);
3050 assert_eq!(ComplexityLevel::from_score(0.0), ComplexityLevel::VeryHigh);
3051 }
3052
3053 #[test]
3054 fn test_completeness_declaration_display() {
3055 assert_eq!(CompletenessDeclaration::Complete.to_string(), "complete");
3056 assert_eq!(
3057 CompletenessDeclaration::IncompleteFirstPartyOnly.to_string(),
3058 "incomplete (first-party only)"
3059 );
3060 assert_eq!(CompletenessDeclaration::Unknown.to_string(), "unknown");
3061 }
3062
3063 #[test]
3066 fn crypto_completeness_all_documented() {
3067 let m = CryptographyMetrics {
3068 algorithms_count: 4,
3069 algorithms_with_family: 4,
3070 algorithms_with_primitive: 4,
3071 algorithms_with_security_level: 4,
3072 ..Default::default()
3073 };
3074 let score = m.crypto_completeness_score();
3075 assert!(
3076 (score - 100.0).abs() < 0.1,
3077 "fully documented → 100, got {score}"
3078 );
3079 }
3080
3081 #[test]
3082 fn crypto_completeness_partial() {
3083 let m = CryptographyMetrics {
3084 algorithms_count: 4,
3085 algorithms_with_family: 2, algorithms_with_primitive: 4, algorithms_with_security_level: 0, ..Default::default()
3089 };
3090 let score = m.crypto_completeness_score();
3092 assert!((score - 50.0).abs() < 0.1, "partial → 50, got {score}");
3093 }
3094
3095 #[test]
3096 fn crypto_identifier_full_oid_coverage() {
3097 let m = CryptographyMetrics {
3098 algorithms_count: 5,
3099 algorithms_with_oid: 5,
3100 ..Default::default()
3101 };
3102 assert!((m.crypto_identifier_score() - 100.0).abs() < 0.1);
3103 }
3104
3105 #[test]
3106 fn crypto_identifier_no_oids() {
3107 let m = CryptographyMetrics {
3108 algorithms_count: 5,
3109 algorithms_with_oid: 0,
3110 ..Default::default()
3111 };
3112 assert!((m.crypto_identifier_score() - 0.0).abs() < 0.1);
3113 }
3114
3115 #[test]
3116 fn algorithm_strength_weak_penalty() {
3117 let m = CryptographyMetrics {
3118 algorithms_count: 5,
3119 weak_algorithm_count: 2,
3120 ..Default::default()
3121 };
3122 let score = m.algorithm_strength_score();
3124 assert!((score - 70.0).abs() < 0.1, "2 weak → 70, got {score}");
3125 }
3126
3127 #[test]
3128 fn algorithm_strength_quantum_vulnerable() {
3129 let m = CryptographyMetrics {
3130 algorithms_count: 10,
3131 quantum_vulnerable_count: 10,
3132 ..Default::default()
3133 };
3134 let score = m.algorithm_strength_score();
3136 assert!(
3137 (score - 70.0).abs() < 0.1,
3138 "all quantum vuln → 70, got {score}"
3139 );
3140 }
3141
3142 #[test]
3143 fn crypto_lifecycle_compromised_keys() {
3144 let m = CryptographyMetrics {
3145 keys_count: 3,
3146 keys_with_state: 3,
3147 keys_with_protection: 3,
3148 keys_with_lifecycle_dates: 3,
3149 compromised_keys: 1,
3150 ..Default::default()
3151 };
3152 let score = m.crypto_lifecycle_score();
3153 assert!(score < 85.0);
3155 assert!(score > 50.0);
3156 }
3157
3158 #[test]
3159 fn crypto_lifecycle_expired_certs() {
3160 let m = CryptographyMetrics {
3161 certificates_count: 4,
3162 certs_with_validity_dates: 4,
3163 expired_certificates: 2,
3164 expiring_soon_certificates: 1,
3165 ..Default::default()
3166 };
3167 let score = m.crypto_lifecycle_score();
3168 assert!(score < 70.0);
3170 }
3171
3172 #[test]
3173 fn pqc_readiness_all_quantum_safe() {
3174 let m = CryptographyMetrics {
3175 algorithms_count: 5,
3176 quantum_safe_count: 5,
3177 hybrid_pqc_count: 2,
3178 weak_algorithm_count: 0,
3179 ..Default::default()
3180 };
3181 let score = m.pqc_readiness_score().expect("algorithms present");
3183 assert!(
3184 (score - 100.0).abs() < 0.1,
3185 "all safe + hybrid → 100, got {score}"
3186 );
3187 }
3188
3189 #[test]
3190 fn pqc_readiness_no_quantum_safe() {
3191 let m = CryptographyMetrics {
3192 algorithms_count: 5,
3193 quantum_safe_count: 0,
3194 hybrid_pqc_count: 0,
3195 weak_algorithm_count: 0,
3196 ..Default::default()
3197 };
3198 let score = m.pqc_readiness_score().expect("algorithms present");
3200 assert!(
3201 (score - 25.0).abs() < 0.1,
3202 "no safe, no weak → 25, got {score}"
3203 );
3204 }
3205
3206 #[test]
3210 fn readiness_scores_none_with_zero_algorithms() {
3211 let m = CryptographyMetrics {
3212 algorithms_count: 0,
3213 certificates_count: 2, ..Default::default()
3215 };
3216 assert!(m.quantum_readiness_score().is_none());
3217 assert!(m.pqc_readiness_score().is_none());
3218 }
3219
3220 #[test]
3221 fn crypto_dependency_all_resolved() {
3222 let m = CryptographyMetrics {
3223 certificates_count: 2,
3224 keys_count: 3,
3225 protocols_count: 1,
3226 certs_with_signature_algo_ref: 2,
3227 keys_with_algorithm_ref: 3,
3228 protocols_with_cipher_suites: 1,
3229 ..Default::default()
3230 };
3231 assert!((m.crypto_dependency_score() - 100.0).abs() < 0.1);
3232 }
3233
3234 #[test]
3235 fn crypto_dependency_none_resolved() {
3236 let m = CryptographyMetrics {
3237 certificates_count: 2,
3238 keys_count: 3,
3239 protocols_count: 1,
3240 ..Default::default()
3241 };
3242 assert!((m.crypto_dependency_score() - 0.0).abs() < 0.1);
3243 }
3244
3245 #[test]
3246 fn quality_score_none_when_no_crypto() {
3247 let m = CryptographyMetrics::default();
3248 assert!(m.quality_score().is_none());
3249 }
3250
3251 #[test]
3252 fn quantum_readiness_pct_zero_algorithms() {
3253 let m = CryptographyMetrics::default();
3254 assert!((m.quantum_readiness_pct() - 0.0).abs() < 0.01);
3255 }
3256
3257 #[test]
3261 fn provenance_missing_timestamp_is_not_fresh() {
3262 let cdx = r#"{"bomFormat":"CycloneDX","specVersion":"1.5",
3263 "components":[{"type":"library","name":"a","version":"1.0"}]}"#;
3264 let sbom = crate::parsers::parse_sbom_str(cdx).expect("parse");
3265 assert!(
3266 !sbom.document.has_known_timestamp(),
3267 "fixture must have no timestamp"
3268 );
3269 let prov = ProvenanceMetrics::from_sbom(&sbom);
3270 assert!(!prov.timestamp_known);
3271 assert!(!prov.is_fresh, "a timestamp-less SBOM must not be fresh");
3272 assert_eq!(
3273 prov.timestamp_age_days, 0,
3274 "unknown age must not leak a huge number"
3275 );
3276
3277 let now = chrono::Utc::now().to_rfc3339();
3279 let cdx_ts = format!(
3280 r#"{{"bomFormat":"CycloneDX","specVersion":"1.5",
3281 "metadata":{{"timestamp":"{now}"}},
3282 "components":[{{"type":"library","name":"a","version":"1.0"}}]}}"#
3283 );
3284 let sbom_ts = crate::parsers::parse_sbom_str(&cdx_ts).expect("parse");
3285 let prov_ts = ProvenanceMetrics::from_sbom(&sbom_ts);
3286 assert!(prov_ts.timestamp_known && prov_ts.is_fresh);
3287
3288 let cdx_future = r#"{"bomFormat":"CycloneDX","specVersion":"1.5",
3291 "metadata":{"timestamp":"3999-01-01T00:00:00Z"},
3292 "components":[{"type":"library","name":"a","version":"1.0"}]}"#;
3293 let sbom_future = crate::parsers::parse_sbom_str(cdx_future).expect("parse");
3294 let prov_future = ProvenanceMetrics::from_sbom(&sbom_future);
3295 assert!(prov_future.timestamp_known);
3296 assert!(!prov_future.is_fresh, "future-dated must not be fresh");
3297 }
3298}