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