Skip to main content

sbom_tools/quality/
scorer.rs

1//! SBOM Quality Scorer.
2//!
3//! Main scoring engine that combines metrics and compliance checking
4//! into an overall quality assessment.
5
6use 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
16/// Quality scoring engine version
17pub const SCORING_ENGINE_VERSION: &str = "2.0";
18
19/// Scoring profile determines weights and thresholds
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[non_exhaustive]
22pub enum ScoringProfile {
23    /// Minimal requirements - basic identification
24    Minimal,
25    /// Standard requirements - recommended for most use cases
26    Standard,
27    /// Security-focused - emphasizes vulnerability info and supply chain
28    Security,
29    /// License-focused - emphasizes license compliance
30    LicenseCompliance,
31    /// EU Cyber Resilience Act - emphasizes supply chain transparency and security disclosure
32    Cra,
33    /// Comprehensive - all aspects equally weighted
34    Comprehensive,
35    /// CBOM - cryptographic BOM focus (algorithm strength, PQC readiness, key/cert lifecycle)
36    Cbom,
37}
38
39impl ScoringProfile {
40    /// Get the compliance level associated with this profile
41    #[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    /// Get weights for this profile
54    ///
55    /// All weights sum to 1.0. The lifecycle weight is applied only when
56    /// enrichment data is available; otherwise it is redistributed.
57    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            // CBOM slots are reinterpreted:
120            // completeness->CryptoCompl, identifiers->OIDs, licenses->AlgoStrength,
121            // vulnerabilities->CryptoRefs, dependencies->CryptoLifecycle,
122            // integrity->PQCReadiness, provenance->Provenance(std), lifecycle->Licenses(std)
123            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/// Weights for overall score calculation (sum to 1.0)
138#[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    /// Return weights as an array for iteration
152    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    /// Renormalize weights, excluding categories marked as N/A.
166    ///
167    /// When a category has no applicable data (e.g., lifecycle without
168    /// enrichment), its weight is proportionally redistributed.
169    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/// Quality grade based on score
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[non_exhaustive]
194pub enum QualityGrade {
195    /// Excellent: 90-100
196    A,
197    /// Good: 80-89
198    B,
199    /// Fair: 70-79
200    C,
201    /// Poor: 60-69
202    D,
203    /// Failing: <60
204    F,
205}
206
207impl QualityGrade {
208    /// Create grade from score
209    #[must_use]
210    pub const fn from_score(score: f32) -> Self {
211        // Guard against NaN (all comparisons return false) and out-of-range values
212        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    /// Get grade letter
229    #[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    /// Get grade description
241    #[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/// Recommendation for improving quality
254#[derive(Debug, Clone, Serialize, Deserialize)]
255pub struct Recommendation {
256    /// Priority (1 = highest, 5 = lowest)
257    pub priority: u8,
258    /// Category of the recommendation
259    pub category: RecommendationCategory,
260    /// Human-readable message
261    pub message: String,
262    /// Estimated impact on score (0-100)
263    pub impact: f32,
264    /// Affected components (if applicable)
265    pub affected_count: usize,
266}
267
268/// Category for recommendations
269#[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/// Complete quality report for an SBOM
301#[derive(Debug, Clone, Serialize, Deserialize)]
302#[must_use]
303pub struct QualityReport {
304    /// Scoring engine version
305    pub scoring_engine_version: String,
306    /// Overall score (0-100)
307    pub overall_score: f32,
308    /// Overall grade
309    pub grade: QualityGrade,
310    /// Scoring profile used
311    pub profile: ScoringProfile,
312
313    // Individual category scores (0-100)
314    /// Completeness score
315    pub completeness_score: f32,
316    /// Identifier quality score
317    pub identifier_score: f32,
318    /// License quality score
319    pub license_score: f32,
320    /// Vulnerability documentation score (`None` if no vulnerability data)
321    pub vulnerability_score: Option<f32>,
322    /// Dependency graph quality score
323    pub dependency_score: f32,
324    /// Hash/integrity quality score
325    pub integrity_score: f32,
326    /// Provenance quality score (combined provenance + auditability)
327    pub provenance_score: f32,
328    /// Lifecycle quality score (`None` if no enrichment data)
329    pub lifecycle_score: Option<f32>,
330
331    // Detailed metrics
332    /// Detailed completeness metrics
333    pub completeness_metrics: CompletenessMetrics,
334    /// Detailed identifier metrics
335    pub identifier_metrics: IdentifierMetrics,
336    /// Detailed license metrics
337    pub license_metrics: LicenseMetrics,
338    /// Detailed vulnerability metrics
339    pub vulnerability_metrics: VulnerabilityMetrics,
340    /// Detailed dependency metrics
341    pub dependency_metrics: DependencyMetrics,
342    /// Hash/integrity metrics
343    pub hash_quality_metrics: HashQualityMetrics,
344    /// Provenance metrics
345    pub provenance_metrics: ProvenanceMetrics,
346    /// Auditability metrics
347    pub auditability_metrics: AuditabilityMetrics,
348    /// Lifecycle metrics (enrichment-dependent)
349    pub lifecycle_metrics: LifecycleMetrics,
350    /// Cryptography quality score (`None` if no crypto components)
351    pub cryptography_score: Option<f32>,
352    /// Cryptography metrics (CBOM)
353    pub cryptography_metrics: CryptographyMetrics,
354
355    /// Compliance check result
356    pub compliance: ComplianceResult,
357    /// Prioritized recommendations
358    pub recommendations: Vec<Recommendation>,
359}
360
361/// Quality scorer for SBOMs
362#[derive(Debug, Clone)]
363pub struct QualityScorer {
364    /// Scoring profile
365    profile: ScoringProfile,
366    /// Completeness weights
367    completeness_weights: CompletenessWeights,
368}
369
370impl QualityScorer {
371    /// Create a new quality scorer with the given profile
372    #[must_use]
373    pub fn new(profile: ScoringProfile) -> Self {
374        Self {
375            profile,
376            completeness_weights: CompletenessWeights::default(),
377        }
378    }
379
380    /// Set custom completeness weights
381    #[must_use]
382    pub const fn with_completeness_weights(mut self, weights: CompletenessWeights) -> Self {
383        self.completeness_weights = weights;
384        self
385    }
386
387    /// Score an SBOM
388    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        // Calculate all metrics
393        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        // Calculate individual category scores
405        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        // Combine provenance and auditability (60/40 split)
414        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        // For CBOM profile, substitute crypto-specific scores into the 8 slots
419        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], // all categories available for CBOM
424                [
425                    cm.crypto_completeness_score(), // slot 1: Crpt
426                    cm.crypto_identifier_score(),   // slot 2: OIDs
427                    cm.algorithm_strength_score(),  // slot 3: Algo
428                    cm.crypto_dependency_score(),   // slot 4: Refs
429                    cm.crypto_lifecycle_score(),    // slot 5: Life
430                    cm.pqc_readiness_score(),       // slot 6: PQC
431                    provenance_score,               // slot 7: Prov (standard)
432                    license_score,                  // slot 8: Lic  (standard)
433                ],
434            )
435        } else {
436            // Standard SBOM scoring
437            let vuln_available = vulnerability_score.is_some();
438            let lifecycle_available = lifecycle_score.is_some();
439            (
440                [
441                    true,                // completeness
442                    true,                // identifiers
443                    true,                // licenses
444                    vuln_available,      // vulnerabilities
445                    true,                // dependencies
446                    true,                // integrity
447                    true,                // provenance
448                    lifecycle_available, // lifecycle
449                ],
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        // Calculate weighted overall score with N/A renormalization
464        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        // Apply hard penalty caps for critical issues
471        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        // Run compliance check
481        let compliance_checker = ComplianceChecker::new(self.profile.compliance_level());
482        let compliance = compliance_checker.check(sbom);
483
484        // Generate recommendations
485        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    /// Apply hard score caps for critical issues
527    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        // EOL components: cap at D grade for security-focused profiles
540        if is_security_profile && lifecycle.eol_components > 0 {
541            score = score.min(69.0);
542        }
543
544        // Dependency cycles: cap at B grade
545        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        // No hashes at all: cap at C grade for Security profile
555        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        // Weak-only hashes: cap at B grade for Security profile
563        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        // CBOM-specific hard caps
571        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        // Priority 1: Compliance errors
602        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        // Priority 1: EOL components
617        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        // Priority 1: Missing versions (critical for identification)
631        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        // Priority 2: Weak-only hashes
644        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        // Priority 2: Missing PURLs (important for identification)
655        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        // Priority 2: Invalid identifiers
668        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        // Priority 2: Missing tool creator info
680        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        // Priority 3: Dependency cycles
691        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        // Priority 2-3: Software complexity
705        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        // Priority 3: Missing licenses
734        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        // Priority 3: NOASSERTION licenses
747        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        // Priority 3: VCS URL coverage
758        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        // Priority 4: Non-standard licenses
774        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        // Priority 4: Outdated components
785        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        // Priority 4: Missing completeness declaration
799        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        // Priority 4: Missing dependency information
816        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        // Priority 4: Many orphan components
827        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        // Priority 5: Missing supplier information
841        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        // Priority 5: Missing hashes
856        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        // Priority 5: Consider SBOM signing (only if not already signed)
874        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        // Sort by priority, then by impact
890        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; // lifecycle
972        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        // Add a weak crypto component (MD5 algorithm)
992        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        // Weak algorithm → D max (69)
1007        assert!(
1008            report.overall_score <= 69.0,
1009            "weak algo should cap at D, got {}",
1010            report.overall_score
1011        );
1012    }
1013}