1use crate::model::{CompletenessDeclaration, NormalizedSbom, SbomFormat};
7use serde::{Deserialize, Serialize};
8
9use super::compliance::{ComplianceChecker, ComplianceLevel, ComplianceResult};
10use super::metrics::{
11 AuditabilityMetrics, CompletenessMetrics, CompletenessWeights, CryptographyMetrics,
12 DependencyMetrics, HashQualityMetrics, IdentifierMetrics, LicenseMetrics, LifecycleMetrics,
13 ProvenanceMetrics, VulnerabilityMetrics,
14};
15
16pub const SCORING_ENGINE_VERSION: &str = "2.0";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[non_exhaustive]
22pub enum ScoringProfile {
23 Minimal,
25 Standard,
27 Security,
29 LicenseCompliance,
31 Cra,
33 BsiTr03183_2,
36 Comprehensive,
38 Cbom,
40}
41
42impl ScoringProfile {
43 #[must_use]
45 pub const fn compliance_level(&self) -> ComplianceLevel {
46 match self {
47 Self::Minimal => ComplianceLevel::Minimum,
48 Self::Standard | Self::LicenseCompliance => ComplianceLevel::Standard,
49 Self::Security => ComplianceLevel::NtiaMinimum,
50 Self::Cra => ComplianceLevel::CraPhase2,
51 Self::BsiTr03183_2 => ComplianceLevel::BsiTr03183_2,
52 Self::Comprehensive => ComplianceLevel::Comprehensive,
53 Self::Cbom => ComplianceLevel::Comprehensive,
54 }
55 }
56
57 const fn weights(self) -> ScoringWeights {
62 match self {
63 Self::Minimal => ScoringWeights {
64 completeness: 0.35,
65 identifiers: 0.20,
66 licenses: 0.10,
67 vulnerabilities: 0.05,
68 dependencies: 0.10,
69 integrity: 0.05,
70 provenance: 0.10,
71 lifecycle: 0.05,
72 },
73 Self::Standard => ScoringWeights {
74 completeness: 0.25,
75 identifiers: 0.20,
76 licenses: 0.12,
77 vulnerabilities: 0.08,
78 dependencies: 0.10,
79 integrity: 0.08,
80 provenance: 0.10,
81 lifecycle: 0.07,
82 },
83 Self::Security => ScoringWeights {
84 completeness: 0.12,
85 identifiers: 0.18,
86 licenses: 0.05,
87 vulnerabilities: 0.20,
88 dependencies: 0.10,
89 integrity: 0.15,
90 provenance: 0.10,
91 lifecycle: 0.10,
92 },
93 Self::LicenseCompliance => ScoringWeights {
94 completeness: 0.15,
95 identifiers: 0.12,
96 licenses: 0.35,
97 vulnerabilities: 0.05,
98 dependencies: 0.10,
99 integrity: 0.05,
100 provenance: 0.10,
101 lifecycle: 0.08,
102 },
103 Self::Cra => ScoringWeights {
104 completeness: 0.12,
105 identifiers: 0.18,
106 licenses: 0.08,
107 vulnerabilities: 0.15,
108 dependencies: 0.12,
109 integrity: 0.12,
110 provenance: 0.15,
111 lifecycle: 0.08,
112 },
113 Self::BsiTr03183_2 => ScoringWeights {
116 completeness: 0.10,
117 identifiers: 0.22,
118 licenses: 0.08,
119 vulnerabilities: 0.10,
120 dependencies: 0.12,
121 integrity: 0.18,
122 provenance: 0.12,
123 lifecycle: 0.08,
124 },
125 Self::Comprehensive => ScoringWeights {
126 completeness: 0.15,
127 identifiers: 0.13,
128 licenses: 0.13,
129 vulnerabilities: 0.10,
130 dependencies: 0.12,
131 integrity: 0.12,
132 provenance: 0.13,
133 lifecycle: 0.12,
134 },
135 Self::Cbom => ScoringWeights {
140 completeness: 0.15,
141 identifiers: 0.15,
142 licenses: 0.22,
143 vulnerabilities: 0.10,
144 dependencies: 0.13,
145 integrity: 0.15,
146 provenance: 0.08,
147 lifecycle: 0.02,
148 },
149 }
150 }
151}
152
153#[derive(Debug, Clone)]
155struct ScoringWeights {
156 completeness: f32,
157 identifiers: f32,
158 licenses: f32,
159 vulnerabilities: f32,
160 dependencies: f32,
161 integrity: f32,
162 provenance: f32,
163 lifecycle: f32,
164}
165
166impl ScoringWeights {
167 fn as_array(&self) -> [f32; 8] {
169 [
170 self.completeness,
171 self.identifiers,
172 self.licenses,
173 self.vulnerabilities,
174 self.dependencies,
175 self.integrity,
176 self.provenance,
177 self.lifecycle,
178 ]
179 }
180
181 fn renormalize(&self, available: &[bool; 8]) -> [f32; 8] {
186 let raw = self.as_array();
187 let total_available: f32 = raw
188 .iter()
189 .zip(available)
190 .filter(|&(_, a)| *a)
191 .map(|(w, _)| w)
192 .sum();
193
194 if total_available <= 0.0 {
195 return [0.0; 8];
196 }
197
198 let scale = 1.0 / total_available;
199 let mut result = [0.0_f32; 8];
200 for (i, (&w, &avail)) in raw.iter().zip(available).enumerate() {
201 result[i] = if avail { w * scale } else { 0.0 };
202 }
203 result
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
209#[non_exhaustive]
210pub enum QualityGrade {
211 A,
213 B,
215 C,
217 D,
219 F,
221}
222
223impl QualityGrade {
224 #[must_use]
226 pub const fn from_score(score: f32) -> Self {
227 let clamped = if score > 100.0 {
229 100
230 } else if score >= 0.0 {
231 score as u32
232 } else {
233 0
234 };
235 match clamped {
236 90..=100 => Self::A,
237 80..=89 => Self::B,
238 70..=79 => Self::C,
239 60..=69 => Self::D,
240 _ => Self::F,
241 }
242 }
243
244 #[must_use]
246 pub const fn letter(&self) -> &'static str {
247 match self {
248 Self::A => "A",
249 Self::B => "B",
250 Self::C => "C",
251 Self::D => "D",
252 Self::F => "F",
253 }
254 }
255
256 #[must_use]
258 pub const fn description(&self) -> &'static str {
259 match self {
260 Self::A => "Excellent",
261 Self::B => "Good",
262 Self::C => "Fair",
263 Self::D => "Poor",
264 Self::F => "Failing",
265 }
266 }
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct Recommendation {
272 pub priority: u8,
274 pub category: RecommendationCategory,
276 pub message: String,
278 pub impact: f32,
280 pub affected_count: usize,
282}
283
284#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
286#[non_exhaustive]
287pub enum RecommendationCategory {
288 Completeness,
289 Identifiers,
290 Licenses,
291 Vulnerabilities,
292 Dependencies,
293 Compliance,
294 Integrity,
295 Provenance,
296 Lifecycle,
297}
298
299impl RecommendationCategory {
300 #[must_use]
301 pub const fn name(&self) -> &'static str {
302 match self {
303 Self::Completeness => "Completeness",
304 Self::Identifiers => "Identifiers",
305 Self::Licenses => "Licenses",
306 Self::Vulnerabilities => "Vulnerabilities",
307 Self::Dependencies => "Dependencies",
308 Self::Compliance => "Compliance",
309 Self::Integrity => "Integrity",
310 Self::Provenance => "Provenance",
311 Self::Lifecycle => "Lifecycle",
312 }
313 }
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize)]
318#[must_use]
319pub struct QualityReport {
320 pub scoring_engine_version: String,
322 pub overall_score: f32,
324 pub grade: QualityGrade,
326 pub profile: ScoringProfile,
328
329 pub completeness_score: f32,
332 pub identifier_score: f32,
334 pub license_score: f32,
336 pub vulnerability_score: Option<f32>,
338 pub dependency_score: f32,
340 pub integrity_score: f32,
342 pub provenance_score: f32,
344 pub lifecycle_score: Option<f32>,
346
347 pub completeness_metrics: CompletenessMetrics,
350 pub identifier_metrics: IdentifierMetrics,
352 pub license_metrics: LicenseMetrics,
354 pub vulnerability_metrics: VulnerabilityMetrics,
356 pub dependency_metrics: DependencyMetrics,
358 pub hash_quality_metrics: HashQualityMetrics,
360 pub provenance_metrics: ProvenanceMetrics,
362 pub auditability_metrics: AuditabilityMetrics,
364 pub lifecycle_metrics: LifecycleMetrics,
366 pub cryptography_score: Option<f32>,
368 pub cryptography_metrics: CryptographyMetrics,
370
371 pub compliance: ComplianceResult,
373 pub recommendations: Vec<Recommendation>,
375}
376
377#[derive(Debug, Clone)]
379pub struct QualityScorer {
380 profile: ScoringProfile,
382 completeness_weights: CompletenessWeights,
384 cra_sidecar: Option<crate::model::CraSidecarMetadata>,
388 cra_product_class: Option<crate::model::CraProductClass>,
391}
392
393impl QualityScorer {
394 #[must_use]
396 pub fn new(profile: ScoringProfile) -> Self {
397 Self {
398 profile,
399 completeness_weights: CompletenessWeights::default(),
400 cra_sidecar: None,
401 cra_product_class: None,
402 }
403 }
404
405 #[must_use]
407 pub const fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
408 self.completeness_weights = weights;
409 self
410 }
411
412 #[must_use]
414 pub fn with_cra_sidecar(mut self, sidecar: crate::model::CraSidecarMetadata) -> Self {
415 self.cra_sidecar = Some(sidecar);
416 self
417 }
418
419 #[must_use]
423 pub const fn with_cra_product_class(mut self, class: crate::model::CraProductClass) -> Self {
424 self.cra_product_class = Some(class);
425 self
426 }
427
428 pub fn score(&self, sbom: &NormalizedSbom) -> QualityReport {
430 let total_components = sbom.components.len();
431 let is_cyclonedx = sbom.document.format == SbomFormat::CycloneDx;
432
433 let completeness_metrics = CompletenessMetrics::from_sbom(sbom);
435 let identifier_metrics = IdentifierMetrics::from_sbom(sbom);
436 let license_metrics = LicenseMetrics::from_sbom(sbom);
437 let vulnerability_metrics = VulnerabilityMetrics::from_sbom(sbom);
438 let dependency_metrics = DependencyMetrics::from_sbom(sbom);
439 let hash_quality_metrics = HashQualityMetrics::from_sbom(sbom);
440 let provenance_metrics = ProvenanceMetrics::from_sbom(sbom);
441 let auditability_metrics = AuditabilityMetrics::from_sbom(sbom);
442 let lifecycle_metrics = LifecycleMetrics::from_sbom(sbom);
443 let cryptography_metrics = CryptographyMetrics::from_sbom(sbom);
444
445 let completeness_score = completeness_metrics.overall_score(&self.completeness_weights);
447 let identifier_score = identifier_metrics.quality_score(total_components);
448 let license_score = license_metrics.quality_score(total_components);
449 let vulnerability_score = vulnerability_metrics.documentation_score();
450 let dependency_score = dependency_metrics.quality_score(total_components);
451 let integrity_score = hash_quality_metrics.quality_score(total_components);
452 let provenance_raw = provenance_metrics.quality_score(is_cyclonedx);
453 let auditability_raw = auditability_metrics.quality_score(total_components);
454 let provenance_score = provenance_raw * 0.6 + auditability_raw * 0.4;
456 let lifecycle_score = lifecycle_metrics.quality_score();
457 let cryptography_score = cryptography_metrics.quality_score();
458
459 let is_cbom = self.profile == ScoringProfile::Cbom;
461 let (available, scores) = if is_cbom && cryptography_metrics.has_data() {
462 let cm = &cryptography_metrics;
463 (
464 [true; 8], [
466 cm.crypto_completeness_score(), cm.crypto_identifier_score(), cm.algorithm_strength_score(), cm.crypto_dependency_score(), cm.crypto_lifecycle_score(), cm.pqc_readiness_score(), provenance_score, license_score, ],
475 )
476 } else {
477 let vuln_available = vulnerability_score.is_some();
479 let lifecycle_available = lifecycle_score.is_some();
480 (
481 [
482 true, true, true, vuln_available, true, true, true, lifecycle_available, ],
491 [
492 completeness_score,
493 identifier_score,
494 license_score,
495 vulnerability_score.unwrap_or(0.0),
496 dependency_score,
497 integrity_score,
498 provenance_score,
499 lifecycle_score.unwrap_or(0.0),
500 ],
501 )
502 };
503
504 let weights = self.profile.weights();
506 let norm = weights.renormalize(&available);
507
508 let mut overall_score: f32 = scores.iter().zip(norm.iter()).map(|(s, w)| s * w).sum();
509 overall_score = overall_score.min(100.0);
510
511 overall_score = self.apply_score_caps(
513 overall_score,
514 &lifecycle_metrics,
515 &dependency_metrics,
516 &hash_quality_metrics,
517 &cryptography_metrics,
518 total_components,
519 );
520
521 let mut compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
523 if let Some(sc) = self.cra_sidecar.clone() {
524 compliance_checker = compliance_checker.with_sidecar(sc);
525 }
526 if let Some(c) = self.cra_product_class {
527 compliance_checker = compliance_checker.with_product_class(c);
528 }
529 let compliance = compliance_checker.check(sbom);
530
531 let recommendations = self.generate_recommendations(
533 &completeness_metrics,
534 &identifier_metrics,
535 &license_metrics,
536 &dependency_metrics,
537 &hash_quality_metrics,
538 &provenance_metrics,
539 &lifecycle_metrics,
540 &compliance,
541 total_components,
542 );
543
544 QualityReport {
545 scoring_engine_version: SCORING_ENGINE_VERSION.to_string(),
546 overall_score,
547 grade: QualityGrade::from_score(overall_score),
548 profile: self.profile,
549 completeness_score,
550 identifier_score,
551 license_score,
552 vulnerability_score,
553 dependency_score,
554 integrity_score,
555 provenance_score,
556 lifecycle_score,
557 completeness_metrics,
558 identifier_metrics,
559 license_metrics,
560 vulnerability_metrics,
561 dependency_metrics,
562 hash_quality_metrics,
563 provenance_metrics,
564 auditability_metrics,
565 lifecycle_metrics,
566 cryptography_score,
567 cryptography_metrics,
568 compliance,
569 recommendations,
570 }
571 }
572
573 fn apply_score_caps(
575 &self,
576 mut score: f32,
577 lifecycle: &LifecycleMetrics,
578 deps: &DependencyMetrics,
579 hashes: &HashQualityMetrics,
580 crypto: &CryptographyMetrics,
581 total_components: usize,
582 ) -> f32 {
583 let is_security_profile =
584 matches!(self.profile, ScoringProfile::Security | ScoringProfile::Cra);
585
586 if is_security_profile && lifecycle.eol_components > 0 {
588 score = score.min(69.0);
589 }
590
591 if deps.cycle_count > 0
593 && matches!(
594 self.profile,
595 ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
596 )
597 {
598 score = score.min(89.0);
599 }
600
601 if matches!(self.profile, ScoringProfile::Security)
603 && total_components > 0
604 && hashes.components_with_any_hash == 0
605 {
606 score = score.min(79.0);
607 }
608
609 if matches!(self.profile, ScoringProfile::Security)
611 && hashes.components_with_weak_only > 0
612 && hashes.components_with_strong_hash == 0
613 {
614 score = score.min(89.0);
615 }
616
617 if self.profile == ScoringProfile::Cbom && crypto.has_data() {
619 if crypto.weak_algorithm_count > 0 {
620 score = score.min(69.0);
621 }
622 if crypto.compromised_keys > 0 {
623 score = score.min(79.0);
624 }
625 if crypto.quantum_safe_count == 0 && crypto.algorithms_count > 0 {
626 score = score.min(79.0);
627 }
628 }
629
630 score
631 }
632
633 #[allow(clippy::too_many_arguments)]
634 fn generate_recommendations(
635 &self,
636 completeness: &CompletenessMetrics,
637 identifiers: &IdentifierMetrics,
638 licenses: &LicenseMetrics,
639 dependencies: &DependencyMetrics,
640 hashes: &HashQualityMetrics,
641 provenance: &ProvenanceMetrics,
642 lifecycle: &LifecycleMetrics,
643 compliance: &ComplianceResult,
644 total_components: usize,
645 ) -> Vec<Recommendation> {
646 let mut recommendations = Vec::new();
647
648 if compliance.error_count > 0 {
650 recommendations.push(Recommendation {
651 priority: 1,
652 category: RecommendationCategory::Compliance,
653 message: format!(
654 "Fix {} compliance error(s) to meet {} requirements",
655 compliance.error_count,
656 compliance.level.name()
657 ),
658 impact: 20.0,
659 affected_count: compliance.error_count,
660 });
661 }
662
663 if lifecycle.eol_components > 0 {
665 recommendations.push(Recommendation {
666 priority: 1,
667 category: RecommendationCategory::Lifecycle,
668 message: format!(
669 "{} component(s) have reached end-of-life — upgrade or replace",
670 lifecycle.eol_components
671 ),
672 impact: 15.0,
673 affected_count: lifecycle.eol_components,
674 });
675 }
676
677 let missing_versions = total_components
679 - ((completeness.components_with_version / 100.0) * total_components as f32) as usize;
680 if missing_versions > 0 {
681 recommendations.push(Recommendation {
682 priority: 1,
683 category: RecommendationCategory::Completeness,
684 message: "Add version information to all components".to_string(),
685 impact: (missing_versions as f32 / total_components.max(1) as f32) * 15.0,
686 affected_count: missing_versions,
687 });
688 }
689
690 if hashes.components_with_weak_only > 0 {
692 recommendations.push(Recommendation {
693 priority: 2,
694 category: RecommendationCategory::Integrity,
695 message: "Upgrade weak hashes (MD5/SHA-1) to SHA-256 or stronger".to_string(),
696 impact: 10.0,
697 affected_count: hashes.components_with_weak_only,
698 });
699 }
700
701 if identifiers.missing_all_identifiers > 0 {
703 recommendations.push(Recommendation {
704 priority: 2,
705 category: RecommendationCategory::Identifiers,
706 message: "Add PURL or CPE identifiers to components".to_string(),
707 impact: (identifiers.missing_all_identifiers as f32
708 / total_components.max(1) as f32)
709 * 20.0,
710 affected_count: identifiers.missing_all_identifiers,
711 });
712 }
713
714 let invalid_ids = identifiers.invalid_purls + identifiers.invalid_cpes;
716 if invalid_ids > 0 {
717 recommendations.push(Recommendation {
718 priority: 2,
719 category: RecommendationCategory::Identifiers,
720 message: "Fix malformed PURL/CPE identifiers".to_string(),
721 impact: 10.0,
722 affected_count: invalid_ids,
723 });
724 }
725
726 if !provenance.has_tool_creator {
728 recommendations.push(Recommendation {
729 priority: 2,
730 category: RecommendationCategory::Provenance,
731 message: "Add SBOM creation tool information".to_string(),
732 impact: 8.0,
733 affected_count: 0,
734 });
735 }
736
737 if dependencies.cycle_count > 0 {
739 recommendations.push(Recommendation {
740 priority: 3,
741 category: RecommendationCategory::Dependencies,
742 message: format!(
743 "{} dependency cycle(s) detected — review dependency graph",
744 dependencies.cycle_count
745 ),
746 impact: 10.0,
747 affected_count: dependencies.cycle_count,
748 });
749 }
750
751 if let Some(level) = &dependencies.complexity_level {
753 match level {
754 super::metrics::ComplexityLevel::VeryHigh => {
755 recommendations.push(Recommendation {
756 priority: 2,
757 category: RecommendationCategory::Dependencies,
758 message:
759 "Dependency structure is very complex — review for unnecessary transitive dependencies"
760 .to_string(),
761 impact: 8.0,
762 affected_count: dependencies.total_dependencies,
763 });
764 }
765 super::metrics::ComplexityLevel::High => {
766 recommendations.push(Recommendation {
767 priority: 3,
768 category: RecommendationCategory::Dependencies,
769 message:
770 "Dependency structure is complex — consider reducing hub dependencies or flattening deep chains"
771 .to_string(),
772 impact: 5.0,
773 affected_count: dependencies.total_dependencies,
774 });
775 }
776 _ => {}
777 }
778 }
779
780 let missing_licenses = total_components - licenses.with_declared;
782 if missing_licenses > 0 && (missing_licenses as f32 / total_components.max(1) as f32) > 0.2
783 {
784 recommendations.push(Recommendation {
785 priority: 3,
786 category: RecommendationCategory::Licenses,
787 message: "Add license information to components".to_string(),
788 impact: (missing_licenses as f32 / total_components.max(1) as f32) * 12.0,
789 affected_count: missing_licenses,
790 });
791 }
792
793 if licenses.noassertion_count > 0 {
795 recommendations.push(Recommendation {
796 priority: 3,
797 category: RecommendationCategory::Licenses,
798 message: "Replace NOASSERTION with actual license information".to_string(),
799 impact: 5.0,
800 affected_count: licenses.noassertion_count,
801 });
802 }
803
804 if total_components > 0 {
806 let missing_vcs = total_components.saturating_sub(
807 ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize,
808 );
809 if missing_vcs > total_components / 2 {
810 recommendations.push(Recommendation {
811 priority: 3,
812 category: RecommendationCategory::Provenance,
813 message: "Add VCS (source repository) URLs to components".to_string(),
814 impact: 5.0,
815 affected_count: missing_vcs,
816 });
817 }
818 }
819
820 if licenses.non_standard_licenses > 0 {
822 recommendations.push(Recommendation {
823 priority: 4,
824 category: RecommendationCategory::Licenses,
825 message: "Use SPDX license identifiers for better interoperability".to_string(),
826 impact: 3.0,
827 affected_count: licenses.non_standard_licenses,
828 });
829 }
830
831 if lifecycle.outdated_components > 0 {
833 recommendations.push(Recommendation {
834 priority: 4,
835 category: RecommendationCategory::Lifecycle,
836 message: format!(
837 "{} component(s) are outdated — newer versions available",
838 lifecycle.outdated_components
839 ),
840 impact: 5.0,
841 affected_count: lifecycle.outdated_components,
842 });
843 }
844
845 if provenance.completeness_declaration == CompletenessDeclaration::Unknown
847 && matches!(
848 self.profile,
849 ScoringProfile::Cra | ScoringProfile::Comprehensive
850 )
851 {
852 recommendations.push(Recommendation {
853 priority: 4,
854 category: RecommendationCategory::Provenance,
855 message: "Add compositions section with aggregate completeness declaration"
856 .to_string(),
857 impact: 5.0,
858 affected_count: 0,
859 });
860 }
861
862 if total_components > 1 && dependencies.total_dependencies == 0 {
864 recommendations.push(Recommendation {
865 priority: 4,
866 category: RecommendationCategory::Dependencies,
867 message: "Add dependency relationships between components".to_string(),
868 impact: 10.0,
869 affected_count: total_components,
870 });
871 }
872
873 if dependencies.orphan_components > 1
875 && (dependencies.orphan_components as f32 / total_components.max(1) as f32) > 0.3
876 {
877 recommendations.push(Recommendation {
878 priority: 4,
879 category: RecommendationCategory::Dependencies,
880 message: "Review orphan components that have no dependency relationships"
881 .to_string(),
882 impact: 5.0,
883 affected_count: dependencies.orphan_components,
884 });
885 }
886
887 let missing_suppliers = total_components
889 - ((completeness.components_with_supplier / 100.0) * total_components as f32) as usize;
890 if missing_suppliers > 0
891 && (missing_suppliers as f32 / total_components.max(1) as f32) > 0.5
892 {
893 recommendations.push(Recommendation {
894 priority: 5,
895 category: RecommendationCategory::Completeness,
896 message: "Add supplier information to components".to_string(),
897 impact: (missing_suppliers as f32 / total_components.max(1) as f32) * 8.0,
898 affected_count: missing_suppliers,
899 });
900 }
901
902 let missing_hashes = total_components
904 - ((completeness.components_with_hashes / 100.0) * total_components as f32) as usize;
905 if missing_hashes > 0
906 && matches!(
907 self.profile,
908 ScoringProfile::Security | ScoringProfile::Comprehensive
909 )
910 {
911 recommendations.push(Recommendation {
912 priority: 5,
913 category: RecommendationCategory::Integrity,
914 message: "Add cryptographic hashes for integrity verification".to_string(),
915 impact: (missing_hashes as f32 / total_components.max(1) as f32) * 5.0,
916 affected_count: missing_hashes,
917 });
918 }
919
920 if !provenance.has_signature
922 && matches!(
923 self.profile,
924 ScoringProfile::Security | ScoringProfile::Cra | ScoringProfile::Comprehensive
925 )
926 {
927 recommendations.push(Recommendation {
928 priority: 5,
929 category: RecommendationCategory::Integrity,
930 message: "Consider adding a digital signature to the SBOM".to_string(),
931 impact: 3.0,
932 affected_count: 0,
933 });
934 }
935
936 recommendations.sort_by(|a, b| {
938 a.priority.cmp(&b.priority).then_with(|| {
939 b.impact
940 .partial_cmp(&a.impact)
941 .unwrap_or(std::cmp::Ordering::Equal)
942 })
943 });
944
945 recommendations
946 }
947}
948
949impl Default for QualityScorer {
950 fn default() -> Self {
951 Self::new(ScoringProfile::Standard)
952 }
953}
954
955#[cfg(test)]
956mod tests {
957 use super::*;
958
959 #[test]
960 fn test_grade_from_score() {
961 assert_eq!(QualityGrade::from_score(95.0), QualityGrade::A);
962 assert_eq!(QualityGrade::from_score(85.0), QualityGrade::B);
963 assert_eq!(QualityGrade::from_score(75.0), QualityGrade::C);
964 assert_eq!(QualityGrade::from_score(65.0), QualityGrade::D);
965 assert_eq!(QualityGrade::from_score(55.0), QualityGrade::F);
966 }
967
968 #[test]
969 fn test_scoring_profile_compliance_level() {
970 assert_eq!(
971 ScoringProfile::Minimal.compliance_level(),
972 ComplianceLevel::Minimum
973 );
974 assert_eq!(
975 ScoringProfile::Security.compliance_level(),
976 ComplianceLevel::NtiaMinimum
977 );
978 assert_eq!(
979 ScoringProfile::Comprehensive.compliance_level(),
980 ComplianceLevel::Comprehensive
981 );
982 }
983
984 #[test]
985 fn test_scoring_weights_sum_to_one() {
986 let profiles = [
987 ScoringProfile::Minimal,
988 ScoringProfile::Standard,
989 ScoringProfile::Security,
990 ScoringProfile::LicenseCompliance,
991 ScoringProfile::Cra,
992 ScoringProfile::Comprehensive,
993 ScoringProfile::Cbom,
994 ];
995 for profile in &profiles {
996 let w = profile.weights();
997 let sum: f32 = w.as_array().iter().sum();
998 assert!(
999 (sum - 1.0).abs() < 0.01,
1000 "{profile:?} weights sum to {sum}, expected 1.0"
1001 );
1002 }
1003 }
1004
1005 #[test]
1006 fn test_renormalize_all_available() {
1007 let w = ScoringProfile::Standard.weights();
1008 let available = [true; 8];
1009 let norm = w.renormalize(&available);
1010 let sum: f32 = norm.iter().sum();
1011 assert!((sum - 1.0).abs() < 0.001);
1012 }
1013
1014 #[test]
1015 fn test_renormalize_lifecycle_unavailable() {
1016 let w = ScoringProfile::Standard.weights();
1017 let mut available = [true; 8];
1018 available[7] = false; let norm = w.renormalize(&available);
1020 let sum: f32 = norm.iter().sum();
1021 assert!((sum - 1.0).abs() < 0.001);
1022 assert_eq!(norm[7], 0.0);
1023 }
1024
1025 #[test]
1026 fn test_scoring_engine_version() {
1027 assert_eq!(SCORING_ENGINE_VERSION, "2.0");
1028 }
1029
1030 #[test]
1031 fn cbom_hard_cap_weak_algorithms() {
1032 use crate::model::{
1033 AlgorithmProperties, CanonicalId, Component, ComponentType, CryptoAssetType,
1034 CryptoPrimitive, CryptoProperties, NormalizedSbom,
1035 };
1036
1037 let mut sbom = NormalizedSbom::default();
1038 let mut comp = Component::new("MD5".to_string(), "md5-ref".to_string());
1040 comp.component_type = ComponentType::Cryptographic;
1041 comp.crypto_properties = Some(
1042 CryptoProperties::new(CryptoAssetType::Algorithm).with_algorithm_properties(
1043 AlgorithmProperties::new(CryptoPrimitive::Hash)
1044 .with_algorithm_family("MD5".to_string())
1045 .with_nist_quantum_security_level(0),
1046 ),
1047 );
1048 sbom.components
1049 .insert(CanonicalId::from_name_version("md5", None), comp);
1050
1051 let scorer = QualityScorer::new(ScoringProfile::Cbom);
1052 let report = scorer.score(&sbom);
1053 assert!(
1055 report.overall_score <= 69.0,
1056 "weak algo should cap at D, got {}",
1057 report.overall_score
1058 );
1059 }
1060}