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 for comp in sbom.components.values() {
58 if comp.version.is_some() {
59 with_version += 1;
60 }
61 if comp.identifiers.purl.is_some() {
62 with_purl += 1;
63 }
64 if !comp.identifiers.cpe.is_empty() {
65 with_cpe += 1;
66 }
67 if comp.supplier.is_some() {
68 with_supplier += 1;
69 }
70 if !comp.hashes.is_empty() {
71 with_hashes += 1;
72 }
73 if !comp.licenses.declared.is_empty() || comp.licenses.concluded.is_some() {
74 with_licenses += 1;
75 }
76 if comp.description.is_some() {
77 with_description += 1;
78 }
79 }
80
81 let pct = |count: usize| (count as f32 / total as f32) * 100.0;
82
83 Self {
84 components_with_version: pct(with_version),
85 components_with_purl: pct(with_purl),
86 components_with_cpe: pct(with_cpe),
87 components_with_supplier: pct(with_supplier),
88 components_with_hashes: pct(with_hashes),
89 components_with_licenses: pct(with_licenses),
90 components_with_description: pct(with_description),
91 has_creator_info: !sbom.document.creators.is_empty(),
92 has_timestamp: true, has_serial_number: sbom.document.serial_number.is_some(),
94 total_components: total,
95 }
96 }
97
98 #[must_use]
100 pub const fn empty() -> Self {
101 Self {
102 components_with_version: 0.0,
103 components_with_purl: 0.0,
104 components_with_cpe: 0.0,
105 components_with_supplier: 0.0,
106 components_with_hashes: 0.0,
107 components_with_licenses: 0.0,
108 components_with_description: 0.0,
109 has_creator_info: false,
110 has_timestamp: false,
111 has_serial_number: false,
112 total_components: 0,
113 }
114 }
115
116 #[must_use]
118 pub fn overall_score(&self, weights: &CompletenessWeights) -> f32 {
119 let mut score = 0.0;
120 let mut total_weight = 0.0;
121
122 score += self.components_with_version * weights.version;
124 total_weight += weights.version * 100.0;
125
126 score += self.components_with_purl * weights.purl;
127 total_weight += weights.purl * 100.0;
128
129 score += self.components_with_cpe * weights.cpe;
130 total_weight += weights.cpe * 100.0;
131
132 score += self.components_with_supplier * weights.supplier;
133 total_weight += weights.supplier * 100.0;
134
135 score += self.components_with_hashes * weights.hashes;
136 total_weight += weights.hashes * 100.0;
137
138 score += self.components_with_licenses * weights.licenses;
139 total_weight += weights.licenses * 100.0;
140
141 if self.has_creator_info {
143 score += 100.0 * weights.creator_info;
144 }
145 total_weight += weights.creator_info * 100.0;
146
147 if self.has_serial_number {
148 score += 100.0 * weights.serial_number;
149 }
150 total_weight += weights.serial_number * 100.0;
151
152 if total_weight > 0.0 {
153 (score / total_weight) * 100.0
154 } else {
155 0.0
156 }
157 }
158}
159
160#[derive(Debug, Clone)]
162pub struct CompletenessWeights {
163 pub version: f32,
164 pub purl: f32,
165 pub cpe: f32,
166 pub supplier: f32,
167 pub hashes: f32,
168 pub licenses: f32,
169 pub creator_info: f32,
170 pub serial_number: f32,
171}
172
173impl Default for CompletenessWeights {
174 fn default() -> Self {
175 Self {
176 version: 1.0,
177 purl: 1.5, cpe: 0.5, supplier: 1.0,
180 hashes: 1.0,
181 licenses: 1.2, creator_info: 0.3,
183 serial_number: 0.2,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct HashQualityMetrics {
195 pub components_with_any_hash: usize,
197 pub components_with_strong_hash: usize,
199 pub components_with_weak_only: usize,
201 pub algorithm_distribution: BTreeMap<String, usize>,
203 pub total_hashes: usize,
205 pub vendor_components_total: usize,
210 pub vendor_components_with_hash: usize,
212 pub vendor_components_with_strong_hash: usize,
214}
215
216impl HashQualityMetrics {
217 #[must_use]
219 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
220 let mut with_any = 0;
221 let mut with_strong = 0;
222 let mut with_weak_only = 0;
223 let mut distribution: BTreeMap<String, usize> = BTreeMap::new();
224 let mut total_hashes = 0;
225 let mut vendor_total = 0;
226 let mut vendor_with_hash = 0;
227 let mut vendor_with_strong = 0;
228
229 for comp in sbom.components.values() {
230 let is_vendor = (comp.supplier.is_some() || comp.author.is_some())
232 && !matches!(
233 comp.canonical_id.source(),
234 crate::model::IdSource::Synthetic
235 | crate::model::IdSource::FormatSpecific
236 | crate::model::IdSource::NameVersion
237 );
238 if is_vendor {
239 vendor_total += 1;
240 }
241
242 if comp.hashes.is_empty() {
243 continue;
244 }
245 with_any += 1;
246 total_hashes += comp.hashes.len();
247
248 let mut has_strong = false;
249 let mut has_weak = false;
250
251 for hash in &comp.hashes {
252 let label = hash_algorithm_label(&hash.algorithm);
253 *distribution.entry(label).or_insert(0) += 1;
254
255 if is_strong_hash(&hash.algorithm) {
256 has_strong = true;
257 } else {
258 has_weak = true;
259 }
260 }
261
262 if has_strong {
263 with_strong += 1;
264 } else if has_weak {
265 with_weak_only += 1;
266 }
267
268 if is_vendor {
269 vendor_with_hash += 1;
270 if has_strong {
271 vendor_with_strong += 1;
272 }
273 }
274 }
275
276 Self {
277 components_with_any_hash: with_any,
278 components_with_strong_hash: with_strong,
279 components_with_weak_only: with_weak_only,
280 algorithm_distribution: distribution,
281 total_hashes,
282 vendor_components_total: vendor_total,
283 vendor_components_with_hash: vendor_with_hash,
284 vendor_components_with_strong_hash: vendor_with_strong,
285 }
286 }
287
288 #[must_use]
292 pub fn vendor_hash_coverage(&self) -> Option<f64> {
293 if self.vendor_components_total == 0 {
294 None
295 } else {
296 #[allow(clippy::cast_precision_loss)]
297 Some(self.vendor_components_with_hash as f64 / self.vendor_components_total as f64)
298 }
299 }
300
301 #[must_use]
303 pub fn vendor_strong_hash_coverage(&self) -> Option<f64> {
304 if self.vendor_components_total == 0 {
305 None
306 } else {
307 #[allow(clippy::cast_precision_loss)]
308 Some(
309 self.vendor_components_with_strong_hash as f64
310 / self.vendor_components_total as f64,
311 )
312 }
313 }
314
315 #[must_use]
320 pub fn quality_score(&self, total_components: usize) -> f32 {
321 if total_components == 0 {
322 return 0.0;
323 }
324
325 let any_coverage = self.components_with_any_hash as f32 / total_components as f32;
326 let strong_coverage = self.components_with_strong_hash as f32 / total_components as f32;
327 let weak_only_ratio = self.components_with_weak_only as f32 / total_components as f32;
328
329 let base = any_coverage * 60.0;
330 let strong_bonus = strong_coverage * 40.0;
331 let weak_penalty = weak_only_ratio * 10.0;
332
333 (base + strong_bonus - weak_penalty).clamp(0.0, 100.0)
334 }
335}
336
337fn is_strong_hash(algo: &HashAlgorithm) -> bool {
339 matches!(
340 algo,
341 HashAlgorithm::Sha256
342 | HashAlgorithm::Sha384
343 | HashAlgorithm::Sha512
344 | HashAlgorithm::Sha3_256
345 | HashAlgorithm::Sha3_384
346 | HashAlgorithm::Sha3_512
347 | HashAlgorithm::Blake2b256
348 | HashAlgorithm::Blake2b384
349 | HashAlgorithm::Blake2b512
350 | HashAlgorithm::Blake3
351 | HashAlgorithm::Streebog256
352 | HashAlgorithm::Streebog512
353 )
354}
355
356fn hash_algorithm_label(algo: &HashAlgorithm) -> String {
358 match algo {
359 HashAlgorithm::Md5 => "MD5".to_string(),
360 HashAlgorithm::Sha1 => "SHA-1".to_string(),
361 HashAlgorithm::Sha256 => "SHA-256".to_string(),
362 HashAlgorithm::Sha384 => "SHA-384".to_string(),
363 HashAlgorithm::Sha512 => "SHA-512".to_string(),
364 HashAlgorithm::Sha3_256 => "SHA3-256".to_string(),
365 HashAlgorithm::Sha3_384 => "SHA3-384".to_string(),
366 HashAlgorithm::Sha3_512 => "SHA3-512".to_string(),
367 HashAlgorithm::Blake2b256 => "BLAKE2b-256".to_string(),
368 HashAlgorithm::Blake2b384 => "BLAKE2b-384".to_string(),
369 HashAlgorithm::Blake2b512 => "BLAKE2b-512".to_string(),
370 HashAlgorithm::Blake3 => "BLAKE3".to_string(),
371 HashAlgorithm::Streebog256 => "Streebog-256".to_string(),
372 HashAlgorithm::Streebog512 => "Streebog-512".to_string(),
373 HashAlgorithm::Other(s) => s.clone(),
374 }
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct IdentifierMetrics {
384 pub valid_purls: usize,
386 pub invalid_purls: usize,
388 pub valid_cpes: usize,
390 pub invalid_cpes: usize,
392 pub with_swid: usize,
394 pub ecosystems: Vec<String>,
396 pub missing_all_identifiers: usize,
398}
399
400impl IdentifierMetrics {
401 #[must_use]
403 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
404 let mut valid_purls = 0;
405 let mut invalid_purls = 0;
406 let mut valid_cpes = 0;
407 let mut invalid_cpes = 0;
408 let mut with_swid = 0;
409 let mut missing_all = 0;
410 let mut ecosystems = std::collections::HashSet::new();
411
412 for comp in sbom.components.values() {
413 let has_purl = comp.identifiers.purl.is_some();
414 let has_cpe = !comp.identifiers.cpe.is_empty();
415 let has_swid = comp.identifiers.swid.is_some();
416
417 if let Some(ref purl) = comp.identifiers.purl {
418 if is_valid_purl(purl) {
419 valid_purls += 1;
420 if let Some(eco) = extract_ecosystem_from_purl(purl) {
422 ecosystems.insert(eco);
423 }
424 } else {
425 invalid_purls += 1;
426 }
427 }
428
429 for cpe in &comp.identifiers.cpe {
430 if is_valid_cpe(cpe) {
431 valid_cpes += 1;
432 } else {
433 invalid_cpes += 1;
434 }
435 }
436
437 if has_swid {
438 with_swid += 1;
439 }
440
441 if !has_purl && !has_cpe && !has_swid {
442 missing_all += 1;
443 }
444 }
445
446 let mut ecosystem_list: Vec<String> = ecosystems.into_iter().collect();
447 ecosystem_list.sort();
448
449 Self {
450 valid_purls,
451 invalid_purls,
452 valid_cpes,
453 invalid_cpes,
454 with_swid,
455 ecosystems: ecosystem_list,
456 missing_all_identifiers: missing_all,
457 }
458 }
459
460 #[must_use]
462 pub fn quality_score(&self, total_components: usize) -> f32 {
463 if total_components == 0 {
464 return 0.0;
465 }
466
467 let with_valid_id = self.valid_purls + self.valid_cpes + self.with_swid;
468 let coverage =
469 (with_valid_id.min(total_components) as f32 / total_components as f32) * 100.0;
470
471 let invalid_count = self.invalid_purls + self.invalid_cpes;
473 let penalty = (invalid_count as f32 / total_components as f32) * 20.0;
474
475 (coverage - penalty).clamp(0.0, 100.0)
476 }
477}
478
479#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct LicenseMetrics {
482 pub with_declared: usize,
484 pub with_concluded: usize,
486 pub valid_spdx_expressions: usize,
488 pub non_standard_licenses: usize,
490 pub noassertion_count: usize,
492 pub deprecated_licenses: usize,
494 pub restrictive_licenses: usize,
496 pub copyleft_license_ids: Vec<String>,
498 pub unique_licenses: Vec<String>,
500}
501
502impl LicenseMetrics {
503 #[must_use]
505 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
506 let mut with_declared = 0;
507 let mut with_concluded = 0;
508 let mut valid_spdx = 0;
509 let mut non_standard = 0;
510 let mut noassertion = 0;
511 let mut deprecated = 0;
512 let mut restrictive = 0;
513 let mut licenses = HashSet::new();
514 let mut copyleft_ids = HashSet::new();
515
516 for comp in sbom.components.values() {
517 if !comp.licenses.declared.is_empty() {
518 with_declared += 1;
519 for lic in &comp.licenses.declared {
520 let expr = &lic.expression;
521 licenses.insert(expr.clone());
522
523 if expr == "NOASSERTION" {
524 noassertion += 1;
525 } else if is_valid_spdx_license(expr) {
526 valid_spdx += 1;
527 } else {
528 non_standard += 1;
529 }
530
531 if is_deprecated_spdx_license(expr) {
532 deprecated += 1;
533 }
534 if is_restrictive_license(expr) {
535 restrictive += 1;
536 copyleft_ids.insert(expr.clone());
537 }
538 }
539 }
540
541 if comp.licenses.concluded.is_some() {
542 with_concluded += 1;
543 }
544 }
545
546 let mut license_list: Vec<String> = licenses.into_iter().collect();
547 license_list.sort();
548
549 let mut copyleft_list: Vec<String> = copyleft_ids.into_iter().collect();
550 copyleft_list.sort();
551
552 Self {
553 with_declared,
554 with_concluded,
555 valid_spdx_expressions: valid_spdx,
556 non_standard_licenses: non_standard,
557 noassertion_count: noassertion,
558 deprecated_licenses: deprecated,
559 restrictive_licenses: restrictive,
560 copyleft_license_ids: copyleft_list,
561 unique_licenses: license_list,
562 }
563 }
564
565 #[must_use]
567 pub fn quality_score(&self, total_components: usize) -> f32 {
568 if total_components == 0 {
569 return 0.0;
570 }
571
572 let coverage = (self.with_declared as f32 / total_components as f32) * 60.0;
573
574 let spdx_ratio = if self.with_declared > 0 {
576 self.valid_spdx_expressions as f32 / self.with_declared as f32
577 } else {
578 0.0
579 };
580 let spdx_bonus = spdx_ratio * 30.0;
581
582 let noassertion_penalty =
584 (self.noassertion_count as f32 / total_components.max(1) as f32) * 10.0;
585
586 let deprecated_penalty = (self.deprecated_licenses as f32 * 2.0).min(10.0);
588
589 (coverage + spdx_bonus - noassertion_penalty - deprecated_penalty).clamp(0.0, 100.0)
590 }
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
595pub struct VulnerabilityMetrics {
596 pub components_with_vulns: usize,
598 pub total_vulnerabilities: usize,
600 pub with_cvss: usize,
602 pub with_cwe: usize,
604 pub with_remediation: usize,
606 pub with_vex_status: usize,
608}
609
610impl VulnerabilityMetrics {
611 #[must_use]
613 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
614 let mut components_with_vulns = 0;
615 let mut total_vulns = 0;
616 let mut with_cvss = 0;
617 let mut with_cwe = 0;
618 let mut with_remediation = 0;
619 let mut with_vex = 0;
620
621 for comp in sbom.components.values() {
622 if !comp.vulnerabilities.is_empty() {
623 components_with_vulns += 1;
624 }
625
626 for vuln in &comp.vulnerabilities {
627 total_vulns += 1;
628
629 if !vuln.cvss.is_empty() {
630 with_cvss += 1;
631 }
632 if !vuln.cwes.is_empty() {
633 with_cwe += 1;
634 }
635 if vuln.remediation.is_some() {
636 with_remediation += 1;
637 }
638 }
639
640 if comp.vex_status.is_some()
641 || comp.vulnerabilities.iter().any(|v| v.vex_status.is_some())
642 {
643 with_vex += 1;
644 }
645 }
646
647 Self {
648 components_with_vulns,
649 total_vulnerabilities: total_vulns,
650 with_cvss,
651 with_cwe,
652 with_remediation,
653 with_vex_status: with_vex,
654 }
655 }
656
657 #[must_use]
664 pub fn documentation_score(&self) -> Option<f32> {
665 if self.total_vulnerabilities == 0 {
666 return None; }
668
669 let cvss_ratio = self.with_cvss as f32 / self.total_vulnerabilities as f32;
670 let cwe_ratio = self.with_cwe as f32 / self.total_vulnerabilities as f32;
671 let remediation_ratio = self.with_remediation as f32 / self.total_vulnerabilities as f32;
672
673 Some(
674 remediation_ratio
675 .mul_add(30.0, cvss_ratio.mul_add(40.0, cwe_ratio * 30.0))
676 .min(100.0),
677 )
678 }
679}
680
681const MAX_EDGES_FOR_GRAPH_ANALYSIS: usize = 1_000_000;
687
688#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
694#[non_exhaustive]
695pub enum ComplexityLevel {
696 Low,
698 Moderate,
700 High,
702 VeryHigh,
704}
705
706impl ComplexityLevel {
707 #[must_use]
709 pub const fn from_score(simplicity: f32) -> Self {
710 match simplicity as u32 {
711 75..=100 => Self::Low,
712 50..=74 => Self::Moderate,
713 25..=49 => Self::High,
714 _ => Self::VeryHigh,
715 }
716 }
717
718 #[must_use]
720 pub const fn label(&self) -> &'static str {
721 match self {
722 Self::Low => "Low",
723 Self::Moderate => "Moderate",
724 Self::High => "High",
725 Self::VeryHigh => "Very High",
726 }
727 }
728}
729
730impl std::fmt::Display for ComplexityLevel {
731 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
732 f.write_str(self.label())
733 }
734}
735
736#[derive(Debug, Clone, Serialize, Deserialize)]
739pub struct ComplexityFactors {
740 pub dependency_volume: f32,
742 pub normalized_depth: f32,
744 pub fanout_concentration: f32,
746 pub cycle_ratio: f32,
748 pub fragmentation: f32,
750}
751
752#[derive(Debug, Clone, Serialize, Deserialize)]
754pub struct DependencyMetrics {
755 pub total_dependencies: usize,
757 pub components_with_deps: usize,
759 pub max_depth: Option<usize>,
761 pub avg_depth: Option<f32>,
763 pub orphan_components: usize,
765 pub root_components: usize,
767 pub cycle_count: usize,
769 pub island_count: usize,
771 pub graph_analysis_skipped: bool,
773 pub max_out_degree: usize,
775 pub software_complexity_index: Option<f32>,
777 pub complexity_level: Option<ComplexityLevel>,
779 pub complexity_factors: Option<ComplexityFactors>,
781}
782
783impl DependencyMetrics {
784 #[must_use]
786 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
787 use crate::model::CanonicalId;
788
789 let total_deps = sbom.edges.len();
790
791 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
793 let mut has_outgoing: HashSet<&str> = HashSet::new();
794 let mut has_incoming: HashSet<&str> = HashSet::new();
795
796 for edge in &sbom.edges {
797 children
798 .entry(edge.from.value())
799 .or_default()
800 .push(edge.to.value());
801 has_outgoing.insert(edge.from.value());
802 has_incoming.insert(edge.to.value());
803 }
804
805 let all_ids: Vec<&str> = sbom.components.keys().map(CanonicalId::value).collect();
806
807 let orphans = all_ids
808 .iter()
809 .filter(|c| !has_outgoing.contains(*c) && !has_incoming.contains(*c))
810 .count();
811
812 let roots: Vec<&str> = has_outgoing
813 .iter()
814 .filter(|c| !has_incoming.contains(*c))
815 .copied()
816 .collect();
817 let root_count = roots.len();
818
819 let max_out_degree = children.values().map(Vec::len).max().unwrap_or(0);
821
822 if total_deps > MAX_EDGES_FOR_GRAPH_ANALYSIS {
824 return Self {
825 total_dependencies: total_deps,
826 components_with_deps: has_outgoing.len(),
827 max_depth: None,
828 avg_depth: None,
829 orphan_components: orphans,
830 root_components: root_count,
831 cycle_count: 0,
832 island_count: 0,
833 graph_analysis_skipped: true,
834 max_out_degree,
835 software_complexity_index: None,
836 complexity_level: None,
837 complexity_factors: None,
838 };
839 }
840
841 let (max_depth, avg_depth) = compute_depth(&roots, &children);
843
844 let cycle_count = detect_cycles(&all_ids, &children);
846
847 let island_count = count_islands(&all_ids, &sbom.edges);
849
850 let component_count = all_ids.len();
852 let (complexity_index, complexity_lvl, factors) = compute_complexity(
853 total_deps,
854 component_count,
855 max_depth.unwrap_or(0),
856 max_out_degree,
857 cycle_count,
858 orphans,
859 island_count,
860 );
861
862 Self {
863 total_dependencies: total_deps,
864 components_with_deps: has_outgoing.len(),
865 max_depth,
866 avg_depth,
867 orphan_components: orphans,
868 root_components: root_count,
869 cycle_count,
870 island_count,
871 graph_analysis_skipped: false,
872 max_out_degree,
873 software_complexity_index: Some(complexity_index),
874 complexity_level: Some(complexity_lvl),
875 complexity_factors: Some(factors),
876 }
877 }
878
879 #[must_use]
881 pub fn quality_score(&self, total_components: usize) -> f32 {
882 if total_components == 0 {
883 return 0.0;
884 }
885
886 let coverage = if total_components > 1 {
888 (self.components_with_deps as f32 / (total_components - 1) as f32) * 100.0
889 } else {
890 100.0 };
892
893 let orphan_ratio = self.orphan_components as f32 / total_components as f32;
895 let orphan_penalty = orphan_ratio * 10.0;
896
897 let cycle_penalty = (self.cycle_count as f32 * 5.0).min(20.0);
899
900 let island_penalty = if total_components > 5 && self.island_count > 3 {
902 ((self.island_count - 3) as f32 * 3.0).min(15.0)
903 } else {
904 0.0
905 };
906
907 (coverage - orphan_penalty - cycle_penalty - island_penalty).clamp(0.0, 100.0)
908 }
909}
910
911fn compute_depth(
913 roots: &[&str],
914 children: &HashMap<&str, Vec<&str>>,
915) -> (Option<usize>, Option<f32>) {
916 use std::collections::VecDeque;
917
918 if roots.is_empty() {
919 return (None, None);
920 }
921
922 let mut visited: HashSet<&str> = HashSet::new();
923 let mut queue: VecDeque<(&str, usize)> = VecDeque::new();
924 let mut max_d: usize = 0;
925 let mut total_depth: usize = 0;
926 let mut count: usize = 0;
927
928 for &root in roots {
929 if visited.insert(root) {
930 queue.push_back((root, 0));
931 }
932 }
933
934 while let Some((node, depth)) = queue.pop_front() {
935 max_d = max_d.max(depth);
936 total_depth += depth;
937 count += 1;
938
939 if let Some(kids) = children.get(node) {
940 for &kid in kids {
941 if visited.insert(kid) {
942 queue.push_back((kid, depth + 1));
943 }
944 }
945 }
946 }
947
948 let avg = if count > 0 {
949 Some(total_depth as f32 / count as f32)
950 } else {
951 None
952 };
953
954 (Some(max_d), avg)
955}
956
957fn detect_cycles(all_nodes: &[&str], children: &HashMap<&str, Vec<&str>>) -> usize {
964 let mut index_of: HashMap<&str, usize> = HashMap::with_capacity(all_nodes.len());
965 for &node in all_nodes {
966 let next = index_of.len();
967 index_of.entry(node).or_insert(next);
968 }
969 for (&from, kids) in children {
970 let next = index_of.len();
971 index_of.entry(from).or_insert(next);
972 for &kid in kids {
973 let next = index_of.len();
974 index_of.entry(kid).or_insert(next);
975 }
976 }
977
978 let node_count = index_of.len();
979 let mut adjacency: Vec<Vec<usize>> = vec![Vec::new(); node_count];
980 let mut has_self_loop = vec![false; node_count];
981 for (from, kids) in children {
982 let from_idx = index_of[from];
983 for kid in kids {
984 let kid_idx = index_of[kid];
985 if from_idx == kid_idx {
986 has_self_loop[from_idx] = true;
987 }
988 adjacency[from_idx].push(kid_idx);
989 }
990 }
991
992 const UNVISITED: usize = usize::MAX;
993 let mut order = vec![UNVISITED; node_count];
994 let mut lowlink = vec![0usize; node_count];
995 let mut on_stack = vec![false; node_count];
996 let mut scc_stack: Vec<usize> = Vec::new();
997 let mut call_stack: Vec<(usize, usize)> = Vec::new();
998 let mut next_order = 0usize;
999 let mut cycles = 0usize;
1000
1001 for start in 0..node_count {
1002 if order[start] != UNVISITED {
1003 continue;
1004 }
1005 call_stack.push((start, 0));
1006 while let Some(frame) = call_stack.last_mut() {
1007 let node = frame.0;
1008 if frame.1 == 0 {
1009 order[node] = next_order;
1010 lowlink[node] = next_order;
1011 next_order += 1;
1012 scc_stack.push(node);
1013 on_stack[node] = true;
1014 }
1015 if let Some(&target) = adjacency[node].get(frame.1) {
1016 frame.1 += 1;
1017 if order[target] == UNVISITED {
1018 call_stack.push((target, 0));
1019 } else if on_stack[target] {
1020 lowlink[node] = lowlink[node].min(order[target]);
1021 }
1022 } else {
1023 call_stack.pop();
1024 if let Some(&(parent, _)) = call_stack.last() {
1025 lowlink[parent] = lowlink[parent].min(lowlink[node]);
1026 }
1027 if lowlink[node] == order[node] {
1028 let mut scc_size = 0usize;
1029 while let Some(member) = scc_stack.pop() {
1030 on_stack[member] = false;
1031 scc_size += 1;
1032 if member == node {
1033 break;
1034 }
1035 }
1036 if scc_size > 1 || has_self_loop[node] {
1037 cycles += 1;
1038 }
1039 }
1040 }
1041 }
1042 }
1043
1044 cycles
1045}
1046
1047fn count_islands(all_nodes: &[&str], edges: &[crate::model::DependencyEdge]) -> usize {
1049 if all_nodes.is_empty() {
1050 return 0;
1051 }
1052
1053 let node_idx: HashMap<&str, usize> =
1055 all_nodes.iter().enumerate().map(|(i, &n)| (n, i)).collect();
1056
1057 let mut parent: Vec<usize> = (0..all_nodes.len()).collect();
1058 let mut rank: Vec<u8> = vec![0; all_nodes.len()];
1059
1060 fn find(parent: &mut Vec<usize>, x: usize) -> usize {
1061 if parent[x] != x {
1062 parent[x] = find(parent, parent[x]); }
1064 parent[x]
1065 }
1066
1067 fn union(parent: &mut Vec<usize>, rank: &mut [u8], a: usize, b: usize) {
1068 let ra = find(parent, a);
1069 let rb = find(parent, b);
1070 if ra != rb {
1071 if rank[ra] < rank[rb] {
1072 parent[ra] = rb;
1073 } else if rank[ra] > rank[rb] {
1074 parent[rb] = ra;
1075 } else {
1076 parent[rb] = ra;
1077 rank[ra] += 1;
1078 }
1079 }
1080 }
1081
1082 for edge in edges {
1083 if let (Some(&a), Some(&b)) = (
1084 node_idx.get(edge.from.value()),
1085 node_idx.get(edge.to.value()),
1086 ) {
1087 union(&mut parent, &mut rank, a, b);
1088 }
1089 }
1090
1091 let mut roots = HashSet::new();
1093 for i in 0..all_nodes.len() {
1094 roots.insert(find(&mut parent, i));
1095 }
1096
1097 roots.len()
1098}
1099
1100fn compute_complexity(
1105 edges: usize,
1106 components: usize,
1107 max_depth: usize,
1108 max_out_degree: usize,
1109 cycle_count: usize,
1110 _orphans: usize,
1111 islands: usize,
1112) -> (f32, ComplexityLevel, ComplexityFactors) {
1113 if components == 0 {
1114 let factors = ComplexityFactors {
1115 dependency_volume: 0.0,
1116 normalized_depth: 0.0,
1117 fanout_concentration: 0.0,
1118 cycle_ratio: 0.0,
1119 fragmentation: 0.0,
1120 };
1121 return (100.0, ComplexityLevel::Low, factors);
1122 }
1123
1124 let edge_ratio = edges as f64 / components as f64;
1126 let dependency_volume = ((1.0 + edge_ratio).ln() / 20.0_f64.ln()).min(1.0) as f32;
1127
1128 let normalized_depth = (max_depth as f32 / 15.0).min(1.0);
1130
1131 let fanout_denom = (components as f32 * 0.25).max(4.0);
1134 let fanout_concentration = (max_out_degree as f32 / fanout_denom).min(1.0);
1135
1136 let cycle_threshold = (components as f32 * 0.05).max(1.0);
1138 let cycle_ratio = (cycle_count as f32 / cycle_threshold).min(1.0);
1139
1140 let extra_islands = islands.saturating_sub(1);
1143 let fragmentation = if components > 1 {
1144 (extra_islands as f32 / (components - 1) as f32).min(1.0)
1145 } else {
1146 0.0
1147 };
1148
1149 let factors = ComplexityFactors {
1150 dependency_volume,
1151 normalized_depth,
1152 fanout_concentration,
1153 cycle_ratio,
1154 fragmentation,
1155 };
1156
1157 let raw_complexity = 0.30 * dependency_volume
1158 + 0.20 * normalized_depth
1159 + 0.20 * fanout_concentration
1160 + 0.20 * cycle_ratio
1161 + 0.10 * fragmentation;
1162
1163 let simplicity_index = (100.0 - raw_complexity * 100.0).clamp(0.0, 100.0);
1164 let level = ComplexityLevel::from_score(simplicity_index);
1165
1166 (simplicity_index, level, factors)
1167}
1168
1169#[derive(Debug, Clone, Serialize, Deserialize)]
1175pub struct ProvenanceMetrics {
1176 pub has_tool_creator: bool,
1178 pub has_tool_version: bool,
1180 pub has_org_creator: bool,
1182 pub has_contact_email: bool,
1184 pub has_serial_number: bool,
1186 pub has_document_name: bool,
1188 pub timestamp_age_days: u32,
1190 pub is_fresh: bool,
1192 pub has_primary_component: bool,
1194 pub lifecycle_phase: Option<String>,
1196 pub completeness_declaration: CompletenessDeclaration,
1198 pub has_signature: bool,
1200 pub has_citations: bool,
1202 pub citations_count: usize,
1204}
1205
1206const FRESHNESS_THRESHOLD_DAYS: u32 = 90;
1208
1209impl ProvenanceMetrics {
1210 #[must_use]
1212 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1213 let doc = &sbom.document;
1214
1215 let has_tool_creator = doc
1216 .creators
1217 .iter()
1218 .any(|c| c.creator_type == CreatorType::Tool);
1219 let has_tool_version = doc.creators.iter().any(|c| {
1220 c.creator_type == CreatorType::Tool
1221 && (c.name.contains(' ') || c.name.contains('/') || c.name.contains('@'))
1222 });
1223 let has_org_creator = doc
1224 .creators
1225 .iter()
1226 .any(|c| c.creator_type == CreatorType::Organization);
1227 let has_contact_email = doc.creators.iter().any(|c| c.email.is_some());
1228
1229 let age_days = (chrono::Utc::now() - doc.created).num_days().max(0) as u32;
1230
1231 Self {
1232 has_tool_creator,
1233 has_tool_version,
1234 has_org_creator,
1235 has_contact_email,
1236 has_serial_number: doc.serial_number.is_some(),
1237 has_document_name: doc.name.is_some(),
1238 timestamp_age_days: age_days,
1239 is_fresh: age_days < FRESHNESS_THRESHOLD_DAYS,
1240 has_primary_component: sbom.primary_component_id.is_some(),
1241 lifecycle_phase: doc.lifecycle_phase.clone(),
1242 completeness_declaration: doc.completeness_declaration.clone(),
1243 has_signature: doc.signature.is_some(),
1244 has_citations: doc.citations_count > 0,
1245 citations_count: doc.citations_count,
1246 }
1247 }
1248
1249 #[must_use]
1256 pub fn quality_score(&self, is_cyclonedx: bool) -> f32 {
1257 let mut score = 0.0;
1258 let mut total_weight = 0.0;
1259
1260 let completeness_declared =
1261 self.completeness_declaration != CompletenessDeclaration::Unknown;
1262
1263 let checks: &[(bool, f32)] = &[
1264 (self.has_tool_creator, 15.0),
1265 (self.has_tool_version, 5.0),
1266 (self.has_org_creator, 12.0),
1267 (self.has_contact_email, 8.0),
1268 (self.has_serial_number, 8.0),
1269 (self.has_document_name, 5.0),
1270 (self.is_fresh, 12.0),
1271 (self.has_primary_component, 12.0),
1272 (completeness_declared, 8.0),
1273 (self.has_signature, 5.0),
1274 ];
1275
1276 for &(present, weight) in checks {
1277 if present {
1278 score += weight;
1279 }
1280 total_weight += weight;
1281 }
1282
1283 if is_cyclonedx {
1285 let weight = 10.0;
1286 if self.lifecycle_phase.is_some() {
1287 score += weight;
1288 }
1289 total_weight += weight;
1290
1291 let citations_weight = 5.0;
1293 if self.has_citations {
1294 score += citations_weight;
1295 }
1296 total_weight += citations_weight;
1297 }
1298
1299 if total_weight > 0.0 {
1300 (score / total_weight) * 100.0
1301 } else {
1302 0.0
1303 }
1304 }
1305}
1306
1307#[derive(Debug, Clone, Serialize, Deserialize)]
1313pub struct AuditabilityMetrics {
1314 pub components_with_vcs: usize,
1316 pub components_with_website: usize,
1318 pub components_with_advisories: usize,
1320 pub components_with_any_external_ref: usize,
1322 pub has_security_contact: bool,
1324 pub has_vuln_disclosure_url: bool,
1326}
1327
1328impl AuditabilityMetrics {
1329 #[must_use]
1331 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1332 let mut with_vcs = 0;
1333 let mut with_website = 0;
1334 let mut with_advisories = 0;
1335 let mut with_any = 0;
1336
1337 for comp in sbom.components.values() {
1338 if comp.external_refs.is_empty() {
1339 continue;
1340 }
1341 with_any += 1;
1342
1343 let has_vcs = comp
1344 .external_refs
1345 .iter()
1346 .any(|r| r.ref_type == ExternalRefType::Vcs);
1347 let has_website = comp
1348 .external_refs
1349 .iter()
1350 .any(|r| r.ref_type == ExternalRefType::Website);
1351 let has_advisories = comp
1352 .external_refs
1353 .iter()
1354 .any(|r| r.ref_type == ExternalRefType::Advisories);
1355
1356 if has_vcs {
1357 with_vcs += 1;
1358 }
1359 if has_website {
1360 with_website += 1;
1361 }
1362 if has_advisories {
1363 with_advisories += 1;
1364 }
1365 }
1366
1367 Self {
1368 components_with_vcs: with_vcs,
1369 components_with_website: with_website,
1370 components_with_advisories: with_advisories,
1371 components_with_any_external_ref: with_any,
1372 has_security_contact: sbom.document.security_contact.is_some(),
1373 has_vuln_disclosure_url: sbom.document.vulnerability_disclosure_url.is_some(),
1374 }
1375 }
1376
1377 #[must_use]
1381 pub fn quality_score(&self, total_components: usize) -> f32 {
1382 if total_components == 0 {
1383 return 0.0;
1384 }
1385
1386 let ref_coverage =
1388 (self.components_with_any_external_ref as f32 / total_components as f32) * 40.0;
1389 let vcs_coverage = (self.components_with_vcs as f32 / total_components as f32) * 20.0;
1390
1391 let security_contact_score = if self.has_security_contact { 20.0 } else { 0.0 };
1393 let disclosure_score = if self.has_vuln_disclosure_url {
1394 20.0
1395 } else {
1396 0.0
1397 };
1398
1399 (ref_coverage + vcs_coverage + security_contact_score + disclosure_score).min(100.0)
1400 }
1401}
1402
1403#[derive(Debug, Clone, Serialize, Deserialize)]
1409pub struct LifecycleMetrics {
1410 pub eol_components: usize,
1412 pub stale_components: usize,
1414 pub deprecated_components: usize,
1416 pub archived_components: usize,
1418 pub outdated_components: usize,
1420 pub enriched_components: usize,
1422 pub enrichment_coverage: f32,
1424}
1425
1426impl LifecycleMetrics {
1427 #[must_use]
1433 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1434 let total = sbom.components.len();
1435 let mut eol = 0;
1436 let mut stale = 0;
1437 let mut deprecated = 0;
1438 let mut archived = 0;
1439 let mut outdated = 0;
1440 let mut enriched = 0;
1441
1442 for comp in sbom.components.values() {
1443 let has_lifecycle_data = comp.eol.is_some() || comp.staleness.is_some();
1444 if has_lifecycle_data {
1445 enriched += 1;
1446 }
1447
1448 if let Some(ref eol_info) = comp.eol
1449 && eol_info.status == EolStatus::EndOfLife
1450 {
1451 eol += 1;
1452 }
1453
1454 if let Some(ref stale_info) = comp.staleness {
1455 match stale_info.level {
1456 StalenessLevel::Stale | StalenessLevel::Abandoned => stale += 1,
1457 StalenessLevel::Deprecated => deprecated += 1,
1458 StalenessLevel::Archived => archived += 1,
1459 _ => {}
1460 }
1461 if stale_info.is_deprecated {
1462 deprecated += 1;
1463 }
1464 if stale_info.is_archived {
1465 archived += 1;
1466 }
1467 if stale_info.latest_version.is_some() {
1468 outdated += 1;
1469 }
1470 }
1471 }
1472
1473 let coverage = if total > 0 {
1474 (enriched as f32 / total as f32) * 100.0
1475 } else {
1476 0.0
1477 };
1478
1479 Self {
1480 eol_components: eol,
1481 stale_components: stale,
1482 deprecated_components: deprecated,
1483 archived_components: archived,
1484 outdated_components: outdated,
1485 enriched_components: enriched,
1486 enrichment_coverage: coverage,
1487 }
1488 }
1489
1490 #[must_use]
1492 pub fn has_data(&self) -> bool {
1493 self.enriched_components > 0
1494 }
1495
1496 #[must_use]
1501 pub fn quality_score(&self) -> Option<f32> {
1502 if !self.has_data() {
1503 return None;
1504 }
1505
1506 let mut score = 100.0_f32;
1507
1508 score -= (self.eol_components as f32 * 15.0).min(60.0);
1510 score -= (self.stale_components as f32 * 5.0).min(30.0);
1512 score -= ((self.deprecated_components + self.archived_components) as f32 * 3.0).min(20.0);
1514 score -= (self.outdated_components as f32 * 1.0).min(10.0);
1516
1517 Some(score.clamp(0.0, 100.0))
1518 }
1519}
1520
1521#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1531pub struct CryptographyMetrics {
1532 pub total_crypto_components: usize,
1534 pub algorithms_count: usize,
1536 pub certificates_count: usize,
1538 pub keys_count: usize,
1540 pub protocols_count: usize,
1542 pub quantum_safe_count: usize,
1544 pub quantum_vulnerable_count: usize,
1546 pub weak_algorithm_count: usize,
1548 pub hybrid_pqc_count: usize,
1550 pub expired_certificates: usize,
1552 pub expiring_soon_certificates: usize,
1554 pub compromised_keys: usize,
1556 pub inadequate_key_sizes: usize,
1558 pub weak_algorithm_names: Vec<String>,
1560
1561 pub algorithms_with_oid: usize,
1564 pub algorithms_with_family: usize,
1566 pub algorithms_with_primitive: usize,
1568 pub algorithms_with_security_level: usize,
1570
1571 pub certs_with_signature_algo_ref: usize,
1574 pub keys_with_algorithm_ref: usize,
1576 pub protocols_with_cipher_suites: usize,
1578
1579 pub keys_with_state: usize,
1582 pub keys_with_protection: usize,
1584 pub keys_with_lifecycle_dates: usize,
1586
1587 pub certs_with_validity_dates: usize,
1590}
1591
1592impl CryptographyMetrics {
1593 #[must_use]
1595 pub fn from_sbom(sbom: &NormalizedSbom) -> Self {
1596 let mut m = Self::default();
1597
1598 for comp in sbom.components.values() {
1599 if comp.component_type != ComponentType::Cryptographic {
1600 continue;
1601 }
1602 m.total_crypto_components += 1;
1603
1604 let Some(cp) = &comp.crypto_properties else {
1605 continue;
1606 };
1607
1608 match cp.asset_type {
1609 CryptoAssetType::Algorithm => {
1610 m.algorithms_count += 1;
1611 if cp.oid.is_some() {
1612 m.algorithms_with_oid += 1;
1613 }
1614 if let Some(algo) = &cp.algorithm_properties {
1615 if algo.algorithm_family.is_some() {
1616 m.algorithms_with_family += 1;
1617 }
1618 if !matches!(algo.primitive, CryptoPrimitive::Other(_)) {
1619 m.algorithms_with_primitive += 1;
1620 }
1621 if algo.classical_security_level.is_some()
1622 || algo.nist_quantum_security_level.is_some()
1623 {
1624 m.algorithms_with_security_level += 1;
1625 }
1626 if algo.is_quantum_safe() {
1627 m.quantum_safe_count += 1;
1628 } else if algo.nist_quantum_security_level == Some(0) {
1629 m.quantum_vulnerable_count += 1;
1630 }
1631 if algo.is_weak_by_name(&comp.name) {
1632 m.weak_algorithm_count += 1;
1633 m.weak_algorithm_names.push(comp.name.clone());
1634 }
1635 if algo.is_hybrid_pqc() {
1636 m.hybrid_pqc_count += 1;
1637 }
1638 }
1639 }
1640 CryptoAssetType::Certificate => {
1641 m.certificates_count += 1;
1642 if let Some(cert) = &cp.certificate_properties {
1643 if cert.not_valid_before.is_some() && cert.not_valid_after.is_some() {
1644 m.certs_with_validity_dates += 1;
1645 }
1646 if cert.signature_algorithm_ref.is_some() {
1647 m.certs_with_signature_algo_ref += 1;
1648 }
1649 if cert.is_expired() {
1650 m.expired_certificates += 1;
1651 } else if cert.is_expiring_soon(90) {
1652 m.expiring_soon_certificates += 1;
1653 }
1654 }
1655 }
1656 CryptoAssetType::RelatedCryptoMaterial => {
1657 m.keys_count += 1;
1658 if let Some(mat) = &cp.related_crypto_material_properties {
1659 if mat.state.is_some() {
1660 m.keys_with_state += 1;
1661 }
1662 if mat.secured_by.is_some() {
1663 m.keys_with_protection += 1;
1664 }
1665 if mat.creation_date.is_some() || mat.activation_date.is_some() {
1666 m.keys_with_lifecycle_dates += 1;
1667 }
1668 if mat.algorithm_ref.is_some() {
1669 m.keys_with_algorithm_ref += 1;
1670 }
1671 if mat.state == Some(CryptoMaterialState::Compromised) {
1672 m.compromised_keys += 1;
1673 }
1674 if let Some(size) = mat.size {
1676 let is_symmetric = matches!(
1677 mat.material_type,
1678 crate::model::CryptoMaterialType::SymmetricKey
1679 | crate::model::CryptoMaterialType::SecretKey
1680 );
1681 if (is_symmetric && size < 128) || (!is_symmetric && size < 2048) {
1682 m.inadequate_key_sizes += 1;
1683 }
1684 }
1685 }
1686 }
1687 CryptoAssetType::Protocol => {
1688 m.protocols_count += 1;
1689 if let Some(proto) = &cp.protocol_properties
1690 && !proto.cipher_suites.is_empty()
1691 {
1692 m.protocols_with_cipher_suites += 1;
1693 }
1694 }
1695 _ => {}
1696 }
1697 }
1698
1699 m
1700 }
1701
1702 #[must_use]
1704 pub fn has_data(&self) -> bool {
1705 self.total_crypto_components > 0
1706 }
1707
1708 #[must_use]
1711 pub fn quantum_readiness_score(&self) -> f32 {
1712 if self.algorithms_count == 0 {
1713 return 100.0;
1714 }
1715 (self.quantum_safe_count as f32 / self.algorithms_count as f32) * 100.0
1716 }
1717
1718 #[must_use]
1720 pub fn quality_score(&self) -> Option<f32> {
1721 if !self.has_data() {
1722 return None;
1723 }
1724
1725 let mut score = 100.0_f32;
1726
1727 score -= (self.weak_algorithm_count as f32 * 15.0).min(50.0);
1729 score -= (self.quantum_vulnerable_count as f32 * 8.0).min(40.0);
1731 score -= (self.expired_certificates as f32 * 10.0).min(30.0);
1733 score -= (self.compromised_keys as f32 * 20.0).min(40.0);
1735 score -= (self.inadequate_key_sizes as f32 * 5.0).min(20.0);
1737 score -= (self.expiring_soon_certificates as f32 * 3.0).min(15.0);
1739 score += (self.hybrid_pqc_count as f32 * 2.0).min(10.0);
1741
1742 Some(score.clamp(0.0, 100.0))
1743 }
1744
1745 #[must_use]
1749 pub fn crypto_completeness_score(&self) -> f32 {
1750 if self.algorithms_count == 0 {
1751 return 100.0;
1752 }
1753 let family_pct = self.algorithms_with_family as f32 / self.algorithms_count as f32;
1754 let primitive_pct = self.algorithms_with_primitive as f32 / self.algorithms_count as f32;
1755 let level_pct = self.algorithms_with_security_level as f32 / self.algorithms_count as f32;
1756 (family_pct * 40.0 + primitive_pct * 30.0 + level_pct * 30.0).clamp(0.0, 100.0)
1757 }
1758
1759 #[must_use]
1761 pub fn crypto_identifier_score(&self) -> f32 {
1762 if self.algorithms_count == 0 {
1763 return 100.0;
1764 }
1765 let oid_pct = self.algorithms_with_oid as f32 / self.algorithms_count as f32;
1766 (oid_pct * 100.0).clamp(0.0, 100.0)
1767 }
1768
1769 #[must_use]
1771 pub fn algorithm_strength_score(&self) -> f32 {
1772 if self.algorithms_count == 0 {
1773 return 100.0;
1774 }
1775 let mut score = 100.0_f32;
1776 score -= (self.weak_algorithm_count as f32 * 15.0).min(60.0);
1777 score -= (self.inadequate_key_sizes as f32 * 8.0).min(30.0);
1778 if self.algorithms_count > 0 {
1779 let vuln_pct = self.quantum_vulnerable_count as f32 / self.algorithms_count as f32;
1780 score -= vuln_pct * 30.0;
1781 }
1782 score.clamp(0.0, 100.0)
1783 }
1784
1785 #[must_use]
1787 pub fn crypto_dependency_score(&self) -> f32 {
1788 let linkable = self.certificates_count + self.keys_count + self.protocols_count;
1789 if linkable == 0 {
1790 return 100.0;
1791 }
1792 let resolved = self.certs_with_signature_algo_ref
1793 + self.keys_with_algorithm_ref
1794 + self.protocols_with_cipher_suites;
1795 let pct = resolved as f32 / linkable as f32;
1796 (pct * 100.0).clamp(0.0, 100.0)
1797 }
1798
1799 #[must_use]
1801 pub fn crypto_lifecycle_score(&self) -> f32 {
1802 let mut score = 100.0_f32;
1803
1804 if self.keys_count > 0 {
1805 let state_pct = self.keys_with_state as f32 / self.keys_count as f32;
1806 let protection_pct = self.keys_with_protection as f32 / self.keys_count as f32;
1807 let lifecycle_pct = self.keys_with_lifecycle_dates as f32 / self.keys_count as f32;
1808 let key_completeness =
1809 (state_pct * 0.4 + protection_pct * 0.3 + lifecycle_pct * 0.3) * 100.0;
1810 score = score * 0.5 + key_completeness * 0.5;
1811 score -= (self.compromised_keys as f32 * 20.0).min(40.0);
1812 score -= (self.inadequate_key_sizes as f32 * 5.0).min(20.0);
1813 }
1814
1815 if self.certificates_count > 0 {
1816 let validity_pct =
1817 self.certs_with_validity_dates as f32 / self.certificates_count as f32;
1818 score -= (1.0 - validity_pct) * 15.0;
1819 score -= (self.expired_certificates as f32 * 15.0).min(45.0);
1820 score -= (self.expiring_soon_certificates as f32 * 5.0).min(20.0);
1821 }
1822
1823 score.clamp(0.0, 100.0)
1824 }
1825
1826 #[must_use]
1828 pub fn pqc_readiness_score(&self) -> f32 {
1829 if self.algorithms_count == 0 {
1830 return 100.0;
1831 }
1832 let mut score = 0.0_f32;
1833 let qs_pct = self.quantum_safe_count as f32 / self.algorithms_count as f32;
1834 score += qs_pct * 60.0;
1835 if self.hybrid_pqc_count > 0 {
1836 score += 15.0;
1837 }
1838 if self.weak_algorithm_count == 0 {
1839 score += 25.0;
1840 } else {
1841 score += (25.0 - self.weak_algorithm_count as f32 * 5.0).max(0.0);
1842 }
1843 score.clamp(0.0, 100.0)
1844 }
1845
1846 #[must_use]
1848 pub fn quantum_readiness_pct(&self) -> f32 {
1849 if self.algorithms_count == 0 {
1850 return 0.0;
1851 }
1852 (self.quantum_safe_count as f32 / self.algorithms_count as f32) * 100.0
1853 }
1854
1855 #[must_use]
1857 pub const fn cbom_category_labels() -> [&'static str; 8] {
1858 ["Crpt", "OIDs", "Algo", "Refs", "Life", "PQC", "Prov", "Lic"]
1859 }
1860}
1861
1862fn is_valid_purl(purl: &str) -> bool {
1867 purl.starts_with("pkg:") && purl.contains('/')
1869}
1870
1871fn extract_ecosystem_from_purl(purl: &str) -> Option<String> {
1872 if let Some(rest) = purl.strip_prefix("pkg:")
1874 && let Some(slash_idx) = rest.find('/')
1875 {
1876 return Some(rest[..slash_idx].to_string());
1877 }
1878 None
1879}
1880
1881fn is_valid_cpe(cpe: &str) -> bool {
1882 cpe.starts_with("cpe:2.3:") || cpe.starts_with("cpe:/")
1884}
1885
1886fn is_valid_spdx_license(expr: &str) -> bool {
1887 const COMMON_SPDX: &[&str] = &[
1889 "MIT",
1890 "Apache-2.0",
1891 "GPL-2.0",
1892 "GPL-3.0",
1893 "BSD-2-Clause",
1894 "BSD-3-Clause",
1895 "ISC",
1896 "MPL-2.0",
1897 "LGPL-2.1",
1898 "LGPL-3.0",
1899 "AGPL-3.0",
1900 "Unlicense",
1901 "CC0-1.0",
1902 "0BSD",
1903 "EPL-2.0",
1904 "CDDL-1.0",
1905 "Artistic-2.0",
1906 "GPL-2.0-only",
1907 "GPL-2.0-or-later",
1908 "GPL-3.0-only",
1909 "GPL-3.0-or-later",
1910 "LGPL-2.1-only",
1911 "LGPL-2.1-or-later",
1912 "LGPL-3.0-only",
1913 "LGPL-3.0-or-later",
1914 ];
1915
1916 let trimmed = expr.trim();
1918 COMMON_SPDX.contains(&trimmed)
1919 || trimmed.contains(" AND ")
1920 || trimmed.contains(" OR ")
1921 || trimmed.contains(" WITH ")
1922}
1923
1924fn is_deprecated_spdx_license(expr: &str) -> bool {
1929 const DEPRECATED: &[&str] = &[
1930 "GPL-2.0",
1931 "GPL-2.0+",
1932 "GPL-3.0",
1933 "GPL-3.0+",
1934 "LGPL-2.0",
1935 "LGPL-2.0+",
1936 "LGPL-2.1",
1937 "LGPL-2.1+",
1938 "LGPL-3.0",
1939 "LGPL-3.0+",
1940 "AGPL-1.0",
1941 "AGPL-3.0",
1942 "GFDL-1.1",
1943 "GFDL-1.2",
1944 "GFDL-1.3",
1945 "BSD-2-Clause-FreeBSD",
1946 "BSD-2-Clause-NetBSD",
1947 "eCos-2.0",
1948 "Nunit",
1949 "StandardML-NJ",
1950 "wxWindows",
1951 ];
1952 let trimmed = expr.trim();
1953 DEPRECATED.contains(&trimmed)
1954}
1955
1956fn is_restrictive_license(expr: &str) -> bool {
1961 let trimmed = expr.trim().to_uppercase();
1962 trimmed.starts_with("GPL")
1963 || trimmed.starts_with("LGPL")
1964 || trimmed.starts_with("AGPL")
1965 || trimmed.starts_with("EUPL")
1966 || trimmed.starts_with("SSPL")
1967 || trimmed.starts_with("OSL")
1968 || trimmed.starts_with("CPAL")
1969 || trimmed.starts_with("CC-BY-SA")
1970 || trimmed.starts_with("CC-BY-NC")
1971}
1972
1973#[cfg(test)]
1974mod tests {
1975 use super::*;
1976
1977 #[test]
1978 fn test_purl_validation() {
1979 assert!(is_valid_purl("pkg:npm/@scope/name@1.0.0"));
1980 assert!(is_valid_purl("pkg:maven/group/artifact@1.0"));
1981 assert!(!is_valid_purl("npm:something"));
1982 assert!(!is_valid_purl("invalid"));
1983 }
1984
1985 #[test]
1986 fn test_cpe_validation() {
1987 assert!(is_valid_cpe("cpe:2.3:a:vendor:product:1.0:*:*:*:*:*:*:*"));
1988 assert!(is_valid_cpe("cpe:/a:vendor:product:1.0"));
1989 assert!(!is_valid_cpe("something:else"));
1990 }
1991
1992 #[test]
1993 fn test_spdx_license_validation() {
1994 assert!(is_valid_spdx_license("MIT"));
1995 assert!(is_valid_spdx_license("Apache-2.0"));
1996 assert!(is_valid_spdx_license("MIT AND Apache-2.0"));
1997 assert!(is_valid_spdx_license("GPL-2.0 OR MIT"));
1998 }
1999
2000 #[test]
2001 fn test_strong_hash_classification() {
2002 assert!(is_strong_hash(&HashAlgorithm::Sha256));
2003 assert!(is_strong_hash(&HashAlgorithm::Sha3_256));
2004 assert!(is_strong_hash(&HashAlgorithm::Blake3));
2005 assert!(!is_strong_hash(&HashAlgorithm::Md5));
2006 assert!(!is_strong_hash(&HashAlgorithm::Sha1));
2007 assert!(!is_strong_hash(&HashAlgorithm::Other("custom".to_string())));
2008 }
2009
2010 #[test]
2011 fn test_deprecated_license_detection() {
2012 assert!(is_deprecated_spdx_license("GPL-2.0"));
2013 assert!(is_deprecated_spdx_license("LGPL-2.1"));
2014 assert!(is_deprecated_spdx_license("AGPL-3.0"));
2015 assert!(!is_deprecated_spdx_license("GPL-2.0-only"));
2016 assert!(!is_deprecated_spdx_license("MIT"));
2017 assert!(!is_deprecated_spdx_license("Apache-2.0"));
2018 }
2019
2020 #[test]
2021 fn test_restrictive_license_detection() {
2022 assert!(is_restrictive_license("GPL-3.0-only"));
2023 assert!(is_restrictive_license("LGPL-2.1-or-later"));
2024 assert!(is_restrictive_license("AGPL-3.0-only"));
2025 assert!(is_restrictive_license("EUPL-1.2"));
2026 assert!(is_restrictive_license("CC-BY-SA-4.0"));
2027 assert!(!is_restrictive_license("MIT"));
2028 assert!(!is_restrictive_license("Apache-2.0"));
2029 assert!(!is_restrictive_license("BSD-3-Clause"));
2030 }
2031
2032 #[test]
2033 fn test_hash_quality_score_no_components() {
2034 let metrics = HashQualityMetrics {
2035 components_with_any_hash: 0,
2036 components_with_strong_hash: 0,
2037 components_with_weak_only: 0,
2038 algorithm_distribution: BTreeMap::new(),
2039 total_hashes: 0,
2040 vendor_components_total: 0,
2041 vendor_components_with_hash: 0,
2042 vendor_components_with_strong_hash: 0,
2043 };
2044 assert_eq!(metrics.quality_score(0), 0.0);
2045 }
2046
2047 #[test]
2048 fn test_hash_quality_score_all_strong() {
2049 let metrics = HashQualityMetrics {
2050 components_with_any_hash: 10,
2051 components_with_strong_hash: 10,
2052 components_with_weak_only: 0,
2053 algorithm_distribution: BTreeMap::new(),
2054 total_hashes: 10,
2055 vendor_components_total: 0,
2056 vendor_components_with_hash: 0,
2057 vendor_components_with_strong_hash: 0,
2058 };
2059 assert_eq!(metrics.quality_score(10), 100.0);
2060 }
2061
2062 #[test]
2063 fn test_hash_quality_score_weak_only_penalty() {
2064 let metrics = HashQualityMetrics {
2065 components_with_any_hash: 10,
2066 components_with_strong_hash: 0,
2067 components_with_weak_only: 10,
2068 algorithm_distribution: BTreeMap::new(),
2069 total_hashes: 10,
2070 vendor_components_total: 0,
2071 vendor_components_with_hash: 0,
2072 vendor_components_with_strong_hash: 0,
2073 };
2074 assert_eq!(metrics.quality_score(10), 50.0);
2076 }
2077
2078 #[test]
2079 fn test_lifecycle_no_enrichment_returns_none() {
2080 let metrics = LifecycleMetrics {
2081 eol_components: 0,
2082 stale_components: 0,
2083 deprecated_components: 0,
2084 archived_components: 0,
2085 outdated_components: 0,
2086 enriched_components: 0,
2087 enrichment_coverage: 0.0,
2088 };
2089 assert!(!metrics.has_data());
2090 assert!(metrics.quality_score().is_none());
2091 }
2092
2093 #[test]
2094 fn test_lifecycle_with_eol_penalty() {
2095 let metrics = LifecycleMetrics {
2096 eol_components: 2,
2097 stale_components: 0,
2098 deprecated_components: 0,
2099 archived_components: 0,
2100 outdated_components: 0,
2101 enriched_components: 10,
2102 enrichment_coverage: 100.0,
2103 };
2104 assert_eq!(metrics.quality_score(), Some(70.0));
2106 }
2107
2108 #[test]
2109 fn test_cycle_detection_no_cycles() {
2110 let children: HashMap<&str, Vec<&str>> =
2111 HashMap::from([("a", vec!["b"]), ("b", vec!["c"])]);
2112 let all_nodes = vec!["a", "b", "c"];
2113 assert_eq!(detect_cycles(&all_nodes, &children), 0);
2115 }
2116
2117 #[test]
2118 fn test_cycle_detection_with_cycle() {
2119 let children: HashMap<&str, Vec<&str>> =
2120 HashMap::from([("a", vec!["b"]), ("b", vec!["c"]), ("c", vec!["a"])]);
2121 let all_nodes = vec!["a", "b", "c"];
2122 assert_eq!(detect_cycles(&all_nodes, &children), 1);
2124 }
2125
2126 #[test]
2127 fn test_cycle_detection_deep_linear_chain_no_overflow() {
2128 let n = 40_000usize;
2129 let names: Vec<String> = (0..n).map(|i| format!("node-{i}")).collect();
2130 let all_nodes: Vec<&str> = names.iter().map(String::as_str).collect();
2131 let mut children: HashMap<&str, Vec<&str>> = HashMap::new();
2132 for w in all_nodes.windows(2) {
2133 children.entry(w[0]).or_default().push(w[1]);
2134 }
2135 assert_eq!(detect_cycles(&all_nodes, &children), 0);
2136 }
2137
2138 #[test]
2139 fn test_cycle_detection_diamond_dag() {
2140 let children: HashMap<&str, Vec<&str>> =
2141 HashMap::from([("a", vec!["b", "c"]), ("b", vec!["d"]), ("c", vec!["d"])]);
2142 let all_nodes = vec!["a", "b", "c", "d"];
2143 assert_eq!(detect_cycles(&all_nodes, &children), 0);
2144 }
2145
2146 #[test]
2147 fn test_cycle_detection_multi_back_edge_scc_counts_once() {
2148 let children: HashMap<&str, Vec<&str>> = HashMap::from([
2149 ("a", vec!["b", "c"]),
2150 ("b", vec!["a", "c"]),
2151 ("c", vec!["a", "b"]),
2152 ]);
2153 let all_nodes = vec!["a", "b", "c"];
2154 assert_eq!(detect_cycles(&all_nodes, &children), 1);
2156 }
2157
2158 #[test]
2159 fn test_cycle_detection_self_loop_counts_once() {
2160 let children: HashMap<&str, Vec<&str>> =
2161 HashMap::from([("a", vec!["a", "b"]), ("b", vec!["c"])]);
2162 let all_nodes = vec!["a", "b", "c"];
2163 assert_eq!(detect_cycles(&all_nodes, &children), 1);
2164 }
2165
2166 #[test]
2167 fn test_quality_scorer_deep_chain_end_to_end() {
2168 use crate::model::{Component, DependencyEdge, DependencyType};
2169 use crate::quality::{QualityScorer, ScoringProfile};
2170
2171 let n = 40_000usize;
2172 let mut sbom = NormalizedSbom::default();
2173 let mut ids = Vec::with_capacity(n);
2174 for i in 0..n {
2175 let component = Component::new(format!("node-{i}"), format!("ref-{i}"));
2176 ids.push(component.canonical_id.clone());
2177 sbom.add_component(component);
2178 }
2179 for w in ids.windows(2) {
2180 sbom.add_edge(DependencyEdge::new(
2181 w[0].clone(),
2182 w[1].clone(),
2183 DependencyType::DependsOn,
2184 ));
2185 }
2186
2187 let report = QualityScorer::new(ScoringProfile::Standard).score(&sbom);
2188 assert!(!report.dependency_metrics.graph_analysis_skipped);
2189 assert_eq!(report.dependency_metrics.cycle_count, 0);
2190 assert_eq!(report.dependency_metrics.max_depth, Some(n - 1));
2191 }
2192
2193 #[test]
2194 fn test_depth_computation() {
2195 let children: HashMap<&str, Vec<&str>> =
2196 HashMap::from([("root", vec!["a", "b"]), ("a", vec!["c"])]);
2197 let roots = vec!["root"];
2198 let (max_d, avg_d) = compute_depth(&roots, &children);
2199 assert_eq!(max_d, Some(2)); assert!(avg_d.is_some());
2201 }
2202
2203 #[test]
2204 fn test_depth_empty_roots() {
2205 let children: HashMap<&str, Vec<&str>> = HashMap::new();
2206 let roots: Vec<&str> = vec![];
2207 let (max_d, avg_d) = compute_depth(&roots, &children);
2208 assert_eq!(max_d, None);
2209 assert_eq!(avg_d, None);
2210 }
2211
2212 #[test]
2213 fn test_provenance_quality_score() {
2214 let metrics = ProvenanceMetrics {
2215 has_tool_creator: true,
2216 has_tool_version: true,
2217 has_org_creator: true,
2218 has_contact_email: true,
2219 has_serial_number: true,
2220 has_document_name: true,
2221 timestamp_age_days: 10,
2222 is_fresh: true,
2223 has_primary_component: true,
2224 lifecycle_phase: Some("build".to_string()),
2225 completeness_declaration: CompletenessDeclaration::Complete,
2226 has_signature: true,
2227 has_citations: true,
2228 citations_count: 3,
2229 };
2230 assert_eq!(metrics.quality_score(true), 100.0);
2232 }
2233
2234 #[test]
2235 fn test_provenance_score_without_cyclonedx() {
2236 let metrics = ProvenanceMetrics {
2237 has_tool_creator: true,
2238 has_tool_version: true,
2239 has_org_creator: true,
2240 has_contact_email: true,
2241 has_serial_number: true,
2242 has_document_name: true,
2243 timestamp_age_days: 10,
2244 is_fresh: true,
2245 has_primary_component: true,
2246 lifecycle_phase: None,
2247 completeness_declaration: CompletenessDeclaration::Complete,
2248 has_signature: true,
2249 has_citations: false,
2250 citations_count: 0,
2251 };
2252 assert_eq!(metrics.quality_score(false), 100.0);
2254 }
2255
2256 #[test]
2257 fn test_complexity_empty_graph() {
2258 let (simplicity, level, factors) = compute_complexity(0, 0, 0, 0, 0, 0, 0);
2259 assert_eq!(simplicity, 100.0);
2260 assert_eq!(level, ComplexityLevel::Low);
2261 assert_eq!(factors.dependency_volume, 0.0);
2262 }
2263
2264 #[test]
2265 fn test_complexity_single_node() {
2266 let (simplicity, level, _) = compute_complexity(0, 1, 0, 0, 0, 1, 1);
2268 assert!(
2269 simplicity >= 80.0,
2270 "Single node simplicity {simplicity} should be >= 80"
2271 );
2272 assert_eq!(level, ComplexityLevel::Low);
2273 }
2274
2275 #[test]
2276 fn test_complexity_monotonic_edges() {
2277 let (s1, _, _) = compute_complexity(5, 10, 2, 3, 0, 1, 1);
2279 let (s2, _, _) = compute_complexity(20, 10, 2, 3, 0, 1, 1);
2280 assert!(
2281 s2 <= s1,
2282 "More edges should not increase simplicity: {s2} vs {s1}"
2283 );
2284 }
2285
2286 #[test]
2287 fn test_complexity_monotonic_cycles() {
2288 let (s1, _, _) = compute_complexity(10, 10, 2, 3, 0, 1, 1);
2289 let (s2, _, _) = compute_complexity(10, 10, 2, 3, 3, 1, 1);
2290 assert!(
2291 s2 <= s1,
2292 "More cycles should not increase simplicity: {s2} vs {s1}"
2293 );
2294 }
2295
2296 #[test]
2297 fn test_complexity_monotonic_depth() {
2298 let (s1, _, _) = compute_complexity(10, 10, 2, 3, 0, 1, 1);
2299 let (s2, _, _) = compute_complexity(10, 10, 10, 3, 0, 1, 1);
2300 assert!(
2301 s2 <= s1,
2302 "More depth should not increase simplicity: {s2} vs {s1}"
2303 );
2304 }
2305
2306 #[test]
2307 fn test_complexity_graph_skipped() {
2308 let (simplicity, _, _) = compute_complexity(100, 50, 5, 10, 2, 5, 3);
2311 assert!(simplicity >= 0.0 && simplicity <= 100.0);
2312 }
2313
2314 #[test]
2315 fn test_complexity_level_bands() {
2316 assert_eq!(ComplexityLevel::from_score(100.0), ComplexityLevel::Low);
2317 assert_eq!(ComplexityLevel::from_score(75.0), ComplexityLevel::Low);
2318 assert_eq!(ComplexityLevel::from_score(74.0), ComplexityLevel::Moderate);
2319 assert_eq!(ComplexityLevel::from_score(50.0), ComplexityLevel::Moderate);
2320 assert_eq!(ComplexityLevel::from_score(49.0), ComplexityLevel::High);
2321 assert_eq!(ComplexityLevel::from_score(25.0), ComplexityLevel::High);
2322 assert_eq!(ComplexityLevel::from_score(24.0), ComplexityLevel::VeryHigh);
2323 assert_eq!(ComplexityLevel::from_score(0.0), ComplexityLevel::VeryHigh);
2324 }
2325
2326 #[test]
2327 fn test_completeness_declaration_display() {
2328 assert_eq!(CompletenessDeclaration::Complete.to_string(), "complete");
2329 assert_eq!(
2330 CompletenessDeclaration::IncompleteFirstPartyOnly.to_string(),
2331 "incomplete (first-party only)"
2332 );
2333 assert_eq!(CompletenessDeclaration::Unknown.to_string(), "unknown");
2334 }
2335
2336 #[test]
2339 fn crypto_completeness_all_documented() {
2340 let m = CryptographyMetrics {
2341 algorithms_count: 4,
2342 algorithms_with_family: 4,
2343 algorithms_with_primitive: 4,
2344 algorithms_with_security_level: 4,
2345 ..Default::default()
2346 };
2347 let score = m.crypto_completeness_score();
2348 assert!(
2349 (score - 100.0).abs() < 0.1,
2350 "fully documented → 100, got {score}"
2351 );
2352 }
2353
2354 #[test]
2355 fn crypto_completeness_partial() {
2356 let m = CryptographyMetrics {
2357 algorithms_count: 4,
2358 algorithms_with_family: 2, algorithms_with_primitive: 4, algorithms_with_security_level: 0, ..Default::default()
2362 };
2363 let score = m.crypto_completeness_score();
2365 assert!((score - 50.0).abs() < 0.1, "partial → 50, got {score}");
2366 }
2367
2368 #[test]
2369 fn crypto_identifier_full_oid_coverage() {
2370 let m = CryptographyMetrics {
2371 algorithms_count: 5,
2372 algorithms_with_oid: 5,
2373 ..Default::default()
2374 };
2375 assert!((m.crypto_identifier_score() - 100.0).abs() < 0.1);
2376 }
2377
2378 #[test]
2379 fn crypto_identifier_no_oids() {
2380 let m = CryptographyMetrics {
2381 algorithms_count: 5,
2382 algorithms_with_oid: 0,
2383 ..Default::default()
2384 };
2385 assert!((m.crypto_identifier_score() - 0.0).abs() < 0.1);
2386 }
2387
2388 #[test]
2389 fn algorithm_strength_weak_penalty() {
2390 let m = CryptographyMetrics {
2391 algorithms_count: 5,
2392 weak_algorithm_count: 2,
2393 ..Default::default()
2394 };
2395 let score = m.algorithm_strength_score();
2397 assert!((score - 70.0).abs() < 0.1, "2 weak → 70, got {score}");
2398 }
2399
2400 #[test]
2401 fn algorithm_strength_quantum_vulnerable() {
2402 let m = CryptographyMetrics {
2403 algorithms_count: 10,
2404 quantum_vulnerable_count: 10,
2405 ..Default::default()
2406 };
2407 let score = m.algorithm_strength_score();
2409 assert!(
2410 (score - 70.0).abs() < 0.1,
2411 "all quantum vuln → 70, got {score}"
2412 );
2413 }
2414
2415 #[test]
2416 fn crypto_lifecycle_compromised_keys() {
2417 let m = CryptographyMetrics {
2418 keys_count: 3,
2419 keys_with_state: 3,
2420 keys_with_protection: 3,
2421 keys_with_lifecycle_dates: 3,
2422 compromised_keys: 1,
2423 ..Default::default()
2424 };
2425 let score = m.crypto_lifecycle_score();
2426 assert!(score < 85.0);
2428 assert!(score > 50.0);
2429 }
2430
2431 #[test]
2432 fn crypto_lifecycle_expired_certs() {
2433 let m = CryptographyMetrics {
2434 certificates_count: 4,
2435 certs_with_validity_dates: 4,
2436 expired_certificates: 2,
2437 expiring_soon_certificates: 1,
2438 ..Default::default()
2439 };
2440 let score = m.crypto_lifecycle_score();
2441 assert!(score < 70.0);
2443 }
2444
2445 #[test]
2446 fn pqc_readiness_all_quantum_safe() {
2447 let m = CryptographyMetrics {
2448 algorithms_count: 5,
2449 quantum_safe_count: 5,
2450 hybrid_pqc_count: 2,
2451 weak_algorithm_count: 0,
2452 ..Default::default()
2453 };
2454 let score = m.pqc_readiness_score();
2456 assert!(
2457 (score - 100.0).abs() < 0.1,
2458 "all safe + hybrid → 100, got {score}"
2459 );
2460 }
2461
2462 #[test]
2463 fn pqc_readiness_no_quantum_safe() {
2464 let m = CryptographyMetrics {
2465 algorithms_count: 5,
2466 quantum_safe_count: 0,
2467 hybrid_pqc_count: 0,
2468 weak_algorithm_count: 0,
2469 ..Default::default()
2470 };
2471 let score = m.pqc_readiness_score();
2473 assert!(
2474 (score - 25.0).abs() < 0.1,
2475 "no safe, no weak → 25, got {score}"
2476 );
2477 }
2478
2479 #[test]
2480 fn crypto_dependency_all_resolved() {
2481 let m = CryptographyMetrics {
2482 certificates_count: 2,
2483 keys_count: 3,
2484 protocols_count: 1,
2485 certs_with_signature_algo_ref: 2,
2486 keys_with_algorithm_ref: 3,
2487 protocols_with_cipher_suites: 1,
2488 ..Default::default()
2489 };
2490 assert!((m.crypto_dependency_score() - 100.0).abs() < 0.1);
2491 }
2492
2493 #[test]
2494 fn crypto_dependency_none_resolved() {
2495 let m = CryptographyMetrics {
2496 certificates_count: 2,
2497 keys_count: 3,
2498 protocols_count: 1,
2499 ..Default::default()
2500 };
2501 assert!((m.crypto_dependency_score() - 0.0).abs() < 0.1);
2502 }
2503
2504 #[test]
2505 fn quality_score_none_when_no_crypto() {
2506 let m = CryptographyMetrics::default();
2507 assert!(m.quality_score().is_none());
2508 }
2509
2510 #[test]
2511 fn quantum_readiness_pct_zero_algorithms() {
2512 let m = CryptographyMetrics::default();
2513 assert!((m.quantum_readiness_pct() - 0.0).abs() < 0.01);
2514 }
2515}